Perfect CRUD
CRUD is an object-relational mapping (ORM) system for Swift 4+. CRUD takes Swift 4 Codable
types and maps them to SQL database tables. CRUD can create tables based on Codable
types and perform inserts and updates of objects in those tables. CRUD can also perform selects and joins of tables, all in a type-safe manner.
CRUD uses a simple, expressive, and type safe methodology for constructing queries as a series of operations. It is designed to be light-weight and has zero additional dependencies. It uses generics, KeyPaths and Codables to ensure as much misuse as possible is caught at compile time.
Database client library packages can add CRUD support by implementing a few protocols. Support is available for SQLite, Postgres, and MySQL.
To use CRUD in your project simply include the database connector of your choice as a dependency in your Package.swift file. For example:
CRUD support is built directly into each of these database connector packages.
Contents
General Usage
This is a simple example to show how CRUD is used.
Operations
Activity in CRUD is accomplished by obtaining a database connection object and then chaining a series of operations on that database. Some operations execute immediately while others (select) are executed lazily. Each operation that is chained will return an object which can be further chained or executed.
Operations are grouped here according to the objects which implement them. Note that many of the type definitions shown below have been abbreviated for simplicity and some functions implemented in extensions have been moved in to keep things in a single block.
Database
A Database object wraps and maintains a connection to a database. Database connectivity is specified by using a DatabaseConfigurationProtocol
object. These will be specific to the database in question.
Database objects implement this set of logical functions:
The operations available on a Database object include transaction
, create
, and table
.
Transaction
The transaction
operation will execute the body between a set of "BEGIN" and "COMMIT" or "ROLLBACK" statements. If the body completes execution without throwing an error then the transaction will be committed, otherwise it is rolled-back.
Example usage:
The body of a transaction may optionally return a value.
Create
The create
operation is given a Codable type. It will create a table corresponding to the type's structure. The table's primary key can be indicated as well as a "create policy" which determines some aspects of the operation.
Example usage:
TableCreatePolicy
consists of the following options:
- .reconcileTable - If the database table already exists then any columns which differ between the type and the table will be either removed or added. Note that this policy will not alter columns which exist but have changed their types. For example, if a type's property changes from a String to an Int, this policy will not alter the column changing its type to Int.
- .shallow - If indicated, then joined type tables will not be automatically created. If not indicated, then any joined type tables will be automatically created.
- .dropTable - The database table will be dropped before it is created. This can be useful during development and testing, or for tables that contain ephemeral data which can be reset after a restart.
Calling create on a table which already exists is a harmless operation resulting in no changes unless the .reconcileTable
or .dropTable
policies are indicated. Existing tables will not be modified to match changes in the corresponding Codable type unless .reconcileTable
is indicated.
Table
The table
operation returns a Table object based on the indicated Codable type. Table objects are used to perform further operations.
Example usage:
SQL
CRUD can also execute bespoke SQL statements, mapping the results to an array of any suitable Codable type.
Example Usage:
Table
Table can follow: Database
.
Table supports: update
, insert
, delete
, join
, order
, limit
, where
, select
, and count
.
A Table object can be used to perform updates, inserts, deletes or selects. Tables can only be accessed through a database object by providing the Codable type which is to be mapped. A table object can only appear in an operation chain once, and it must be the first item.
Table objects are parameterized based on the Swift object type you provide when you retrieve the table. Tables indicate the over-all resulting type of any operation. This will be referred to as the OverAllForm.
Example usage:
In the example above, TestTable1 is the OverAllForm. Any destructive operations will affect the corresponding database table. Any selects will produce a collection of TestTable1 objects.
Index
Index can follow: table
.
Database indexes are important for good query performance. Given a table object, a database index can be added by calling the index
function. Indexes should be added along with the code which creates the table.
The index
function accepts one or more table keypaths.
Example usage:
Indexes can be created for individual columns, or for columns as a group. If multiple columns are frequenty used together in queries, then it can often improve performance by adding indexes including those columns.
By including the unique: true
parameter, a unique index will be created, meaning that only one row can contain any possible column value. This can be applied to multiple columns, as seen in the example above. Consult your specific database's documentation for the exact behaviours of database indexes.
The index
function is defined as:
Join
Join can follow: table
, order
, limit
, or another join
.
Join supports: join
, where
, order
, limit
, select
, count
.
A join
brings in objects from another table in either a parent-child or many-to-many relationship scheme.
Parent-child example usage:
The example above joins Child objects on the Parent.children property, which is of type [Child]?
. When the query is executed, all objects from the Child table that have a parentId which matches the Parent id 1 will be included in the results. This is a typical parent-child relationship.
Many-to-many example usage:
Self Join example usage:
Junction Join example usage:
Joins are not currently supported in updates, inserts, or deletes (cascade deletes/recursive updates are not supported).
The Join protocol has two functions. The first handles standard two table joins. The second handles junction (three table) joins.
A standard join requires three parameters:
to
- keypath to a property of the OverAllForm. This keypath should point to an Optional array of non-integral Codable types. This property will be set with the resulting objects.
on
- keypath to a property of the OverAllForm which should be used as the primary key for the join (typically one would use the actual table primary key column).
equals
- keypath to a property of the joined type which should be equal to the OverAllForm's on
property. This would be the foreign key.
A junction join requires six parameters:
to
- keypath to a property of the OverAllForm. This keypath should point to an Optional array of non-integral Codable types. This property will be set with the resulting objects.
with
- The type of the junction table.
on
- keypath to a property of the OverAllForm which should be used as the primary key for the join (typically one would use the actual table primary key column).
equals
- keypath to a property of the junction type which should be equal to the OverAllForm's on
property. This would be the foreign key.
and
- keypath to a property of the child table type which should be used as the key for the join (typically one would use the actual table primary key column).
is
- keypath to a property of the junction type which should be equal to the child table's and
property.
Any joined type tables which are not explicitly included in a join will be set to nil for any resulting OverAllForm objects.
If a joined table is included in a join but there are no resulting joined objects, the OverAllForm's property will be set to an empty array.
Where
Where can follow: table
, join
, order
.
Where supports: select
, count
, update
(when following table
), delete
(when following table
).
A where
operation introduces a criteria which will be used to filter exactly which objects should be selected, updated, or deleted from the database. Where can only be used when performing a select/count, update, or delete.
Where
operations are optional, but only one where
can be included in an operation chain and it must be the penultimate operation in the chain.
Example usage:
The parameter given to the where
operation is a CRUDBooleanExpression
object. These are produced by using any of the supported expression operators.
Standard Swift Operators:
• Equality: ==
, !=
• Comparison: <
, <=
, >
, >=
• Logical: !
, &&
, ||
Custom Comparison Operators:
• Contained/In: ~
, !~
• Like: %=%
, =%
, %=
, %!=%
, !=%
, %!=
For the equality and comparison operators, the left-hand operand must be a KeyPath indicating a Codable property of a Codable type. The right-hand operand can be Int, Double, String, [UInt8], Bool, UUID, or Date. The KeyPath can indicate an Optional property value, in which case the right-hand operand may be nil
to indicate an "IS NULL", "IS NOT NULL" type of query.
The equality and comparison operators are type-safe, meaning you can not make a comparison between, for example, an Int and a String. The type of the right-hand operand must match the KeyPath property type. This is how Swift normally works, so it should not come with any surprises.
Any type which has been introduced to the query through a table
or join
operation can be used in an expression. Using KeyPaths for types not used elsewhere in the query is a runtime error.
In this snippet:
\TestTable1.id
is the KeyPath, pointing to the Int id for the object. 20 is the literal operand value. The >
operator between them produces a CRUDBooleanExpression
which can be given directly to where
or used with other operators to make more complex expressions.
The logical operators permit and
, or
, and not
operations given two CRUDBooleanExpression
objects. These use the standard Swift &&
, ||
, and !
operators.
The contained/in operators take a KeyPath in the right-hand side and an array of objects on the left.
The above will select all TestTable1 objects whose id
is in the array, or not in the array, respectively.
The Like operators are used only with String values. These permit begins with, ends with, and contains searches on String based columns.
Property Optionals
In some cases you may need to query using an optional parameter in your model. In the Person
model above, we may need to add an optional height
field:
In the case we wanted to search for People
who have not yet provided their height, this simple query will find all rows where height
is NULL
.
Alternatively, you may need to query for individuals a certain height or taller.
Notice the force-unwraped key path - \Person.height!
. This is type-safe and required by the compiler in order to compare the optional type on the model to the non-optional value in the query.
Order
Order can follow: table
, join
.
Order supports: join
, where
, order
, limit
select
, count
.
An order
operation introduces an ordering of the over-all resulting objects and/or of the objects selected for a particular join. An order operation should immediately follow either a table
or a join
. You may also order over fields with optional types.
Example usage:
When the above query is executed it will apply orderings to both the main list of returned objects and to their individual "subTables" collections.
Ordering by a nullable field:
Limit
Limit can follow: order
, join
, table
.
Limit supports: join
, where
, order
, select
, count
.
A limit
operation can follow a table
, join
, or order
operation. Limit can both apply an upper bound on the number of resulting objects and impose a skip value. For example the first five found records may be skipped and the result set will begin at the sixth row.
Ranges of Ints can also be passed to the limit
func. This includes ranges in the form of a..<b
, a...b
, a...
, ...b
, ..<b
.
A limit applies only to the most recent table
or join
. A limit placed after a table
limits the over-all number of results. A limit placed after a join
limits the number of joined type objects returned.
Example usage:
Update
Update can follow: table
, where
(when where
follows table
).
Update supports: immediate execution.
An update
operation can be used to replace values in the existing records which match the query. An update will almost always have a where
operation in the chain, but it is not required. Providing no where
operation in the chain will match all records.
An update requires an instance of the OverAllForm. This instance provides the values which will be set in any records which match the query. The update can be performed with either a setKeys
or ignoreKeys
parameter, or with no additional parameter to indicate that all columns should be included in the update.
Example usage:
Insert
Insert can follow: table
.
Insert supports: immediate execution.
Insert is used to add new records to the database. One or more objects can be inserted at a time. Particular keys/columns can be added or excluded. An insert must immediately follow a table
.
Usage example:
Delete
Delete can follow: table
, where
(when where
follows table
).
Delete supports: immediate execution.
A delete
operation is used to remove records from the table which match the query. A delete will almost always have a where
operation in the chain, but it is not required. Providing no where
operation in the chain will delete all records.
Example usage:
Select & Count
Select can follow: where
, order
, limit
, join
, table
.
Select supports: iteration.
Select returns an object which can be used to iterate over the resulting values.
Count works similarly to select
but it will execute the query immediately and simply return the number of resulting objects. Object data is not actually fetched.
Usage example:
Database Specific Operations
MySQL
Last Insert Id
lastInsertId() can follow: insert
.
The lastInsertId()
function can be called after an insert. It will return the last insert id, if available.
Example Usage:
SQLite
Last Insert Id
lastInsertId() can follow: insert
.
The lastInsertId()
function can be called after an insert. It will return the last insert id, if available.
PostgreSQL
Returning
Returning can follow: where
, table
.
Returning executes either an insert or an update and returns values from the inserted/updated row(s). Returning can return either column values or the Codable objects representing the current table.
Insert:
Update:
Example Usage:
Codable Types
Most Codable
types can be used with CRUD, often, depending on your needs, with no modifications. All of a type's relevant properties will be mapped to columns in the database table. You can customize the column names by adding a CodingKeys
property to your type.
By default, the type name will be used as the table name. To customize the name used for a type's table, have the type implement the TableNameProvider
protocol. This requires a static let tableName: String
property.
CRUD supports the following property types:
- All Ints, Double, Float, Bool, String
- [UInt8], [Int8], Data
- Date, UUID
The actual storage in the database for each of these types will depend on the client library in use. For example, Postgres will have an actual "date" and "uuid" column types while in SQLite these will be stored as strings.
A type used with CRUD can also have one or more arrays of child, or joined types. These arrays can be populated by using a join
operation in a query. Note that a table column will not be created for joined type properties.
The following example types illustrate valid CRUD Codables
using CodingKeys
, TableNameProvider
and joined types.
Joined types should be an Optional array of Codable objects. Above, the TestTable1
struct has a joined type on its subTables
property: let subTables: [TestTable2]?
. Joined types will only be populated when the corresponding table is joined using the join
operation.
Identity
When CRUD creates the table corresponding to a type it attempts to determine what the primary key for the table will be. You can explicitly indicate which property is the primary key when you call the create
operation. If you do not indicate the key then a property named "id" will be sought. If there is no "id" property then the table will be created without a primary key.
Note that a custom primary key name can be specified when creating tables "shallow" but not when recursively creating them. See the "Create" operation for more details.
Error Handling
Any error which occurs during SQL generation, execution, or results fetching will produce a thrown Error object.
CRUD will throw CRUDDecoderError
or CRUDEncoderError
for errors occurring during type encoding and decoding, respectively.
CRUD will throw CRUDSQLGenError
for errors occurring during SQL statement generation.
CRUD will throw CRUDSQLExeError
for errors occurring during SQL statement execution.
All of the CRUD errors are tied into the logging system. When they are thrown the error messages will appear in the log. Individual database client libraries may throw other errors when they occur.
Logging
CRUD contains a built-in logging system which is designed to record errors which occur. It can also record individual SQL statements which are generated. CRUD logging is done asynchronously. You can flush all pending log messages by calling CRUDLogging.flush()
.
Messages can be added to the log by calling CRUDLogging.log(_ type: CRUDLogEventType, _ msg: String)
.
Example usage:
CRUDLogEventType
is one of: .info
, .warning
, .error
, or .query
.
You can control where log messages go by setting the CRUDLogging.queryLogDestinations
and CRUDLogging.errorLogDestinations
static properties. Modifying the log destinations is a thread-safe operation. Handling for errors and queries can be set separately as SQL statement logging may be desirable during development but not in production.
Log destinations are defined as:
Each message can go to multiple destinations. By default, both errors and queries are logged to the console.