Repository navigation
Perfect-CRUD 3.0.0
The first release since 2.0.0 (January 2020). It brings the package up to Swift 6, adds connection pooling, async entry points, migrations and a dynamic (runtime) query API, and fixes a long list of correctness bugs in key path resolution, joins, optional foreign keys and table-structure caching. It is a major version: there are source-breaking changes and several queries that used to return wrong or empty results now throw instead. Read "Breaking changes and behaviour changes" before upgrading.
Requirements
- Swift tools 6.2 (
// swift-tools-version: 6.2); 2.0.0 declared 5.1. - Platforms: macOS 12+ (was 10.15+), iOS 15+ (newly declared), Linux.
- Still no external SwiftPM dependencies.
.package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.0")CI now runs on GitHub Actions for every push and pull request: Linux (Swift 6.2 and 6.4) and macOS (Xcode 26.0.1 and 26.6), each in debug and release, plus the tests on an iOS Simulator at the iOS 15 deployment target, with warnings treated as errors. This delivers the CI promised when #67 was closed.
Highlights
- Swift 6 and
Sendable(#70). The library builds cleanly in the Swift 6 language mode. The query-builder types (Database,Table,Where,Join,JoinPivot,Ordering,Limit) are conditionallySendable, andCRUDExpression, the logging types,TableCreatePolicyand the foreign-key action types areSendable. The global table-structure cache is now lock-protected (it was an unsynchronised dictionary that could be corrupted under concurrentcreate()/index()calls). - Connection pooling (#70).
DatabaseConnectionPool<C>is an actor that hands out connections throughwithConnection { db in … }, or manually throughacquire()/release(_:), with configurable minimum and maximum sizes andprewarm(). - Async entry points (#70).
Async-suffixed methods (sqlAsync,transactionAsync,firstAsync,insertAsync,updateAsync,deleteAsync) andfetchAll()run the existing blocking connector work on a dedicated dispatch queue so it doesn't block Swift's cooperative thread pool. The synchronous API is unchanged and remains the primary surface. Cancellation is best-effort: a query already running in the C library is not interrupted. - Nested transactions and isolation levels (#70).
transactioncalled inside a transaction on the sameDatabasevalue now usesSAVEPOINT/RELEASE SAVEPOINT/ROLLBACK TO SAVEPOINT. A newisolation:parameter takes aTransactionIsolationLevel; it applies only at the outermost level and only on connectors that implementSQLGenDelegate.setIsolationLevelSQL(_:)(the default is a no-op). No PerfectlySoft connector implements it yet, so for nowisolation:has no effect. Connector authors: the isolation SQL is sent beforeBEGIN, which suits MySQL'sSET TRANSACTION; on PostgreSQL aSET TRANSACTIONoutside a transaction block only warns, so a PostgreSQL implementation needs a different approach. - Migrations (#70).
DatabaseMigrator<C>applies registered migrations in registration order, records them in aperfectcrud_migrationstable, and can roll back the most recent one when it has an explicitdown. It sits on top ofTableCreatePolicy.reconcileTablerather than replacing it. - Dynamic queries (#70).
Databasegainsselect(_: DynamicQuery),mutate(_: DynamicMutation)andexecute(_: DynamicSQL)for queries whose table and field names are only known at runtime, returningDynamicResult/DynamicRow/DynamicValue. Reading dynamic rows needs connector support (SQLExeDelegate.nextDynamicRow()); the default implementation throws.executerejects what looks like multiple statements unlessallowsMultipleStatementsis set. CRUDTableStructure()is public (#70), so host modules can inspect a model's table structure.- Comparisons on Optional columns (#71).
<,<=,>and>=accept key paths to Optional columns of String, Double, Bool, UUID, Date and every Int/UInt width. The right-hand side must be non-optional; rows whose column is NULL never match. - Optional
@ForeignKey/@PrimaryKeycolumns work (#75, #79).@ForeignKey(...) var parentId: Int?now creates a nullable column of the wrapped type, binds NULL, can be filtered with==,== niland!= nil, and decodes from JSON with the key absent.ON DELETE SET NULLnow works. - Joins on optional keys (#82). New
joinoverloads accept an optional key on either side (for exampleid: IntagainstparentId: Int?), so parent/child relations and trees with nullable foreign keys can be joined. A NULL key matches nothing. - Self-referencing models (#74, #81). A
@ForeignKeyto the same type (or a cycle of types) and a Codable model that contains an optional of its own type (var next: Node?) no longer overflow the stack.
Breaking changes and behaviour changes
Source and build changes
- Toolchain and platforms. Swift tools 6.2 is required and the minimum macOS is 12. Upgrade: use a Swift 6.2 or newer toolchain and raise your own deployment target to at least macOS 12 / iOS 15.
Expressiontypealias removed (#71). It collided with Foundation'sExpressionwhen building against the macOS 15 SDK or later, making an unqualifiedExpressionambiguous. Upgrade: replaceExpressionwithCRUDExpression; it is the same type.CRUDLogDestination.customtakes a@Sendableclosure (#70). Upgrade: make the closure you pass@Sendable(capture onlySendablestate).- Foreign-key action providers'
actionis nowstatic let(#70), notstatic var. Upgrade: only code that assigned toForeignKeyActionCascade.action(etc.) is affected; stop mutating it. - Connector protocols gained requirements, all with default implementations (#70):
SQLGenDelegate.setIsolationLevelSQL(_:)(defaultnil), andSQLExeDelegate.nextDynamicRow()(default throws),affectedRowCount()(default 0) andlastInsertedID()(default nil). Existing connectors compile unchanged. Upgrade (connector authors): implement these to support isolation levels and the dynamic API. - Custom
decodeoverloads for the wrappers (#79). New publicKeyedDecodingContainer.decodeoverloads forPrimaryKey<V?>andForeignKey<T, D, U, V?>are now preferred over a package's own overload written generically overWrappedCodableProvider. Upgrade: if you had such an overload, it is no longer called for optional-wrapping primary and foreign keys.
Runtime behaviour changes
- Nested
transactionuses savepoints (#70). Top-level transactions still emit exactlyBEGIN/COMMIT/ROLLBACK. A nested call on the sameDatabasevalue used to issue a secondBEGIN; it now opens a savepoint, and an error in the inner body rolls back only to that savepoint. Upgrade: if you relied on an inner failure rolling back the whole outer transaction, rethrow from the outer body (the inner error does propagate unless you catch it). - Optional
@ForeignKey/@PrimaryKeycolumn schema (#75). These columns are now created as nullable columns of the wrapped type; in 2.0.0 they came out asjson NOT NULL(MySQL, whereCREATE TABLEthen failed),jsonb NOT NULL(PostgreSQL) orTEXT NOT NULL(SQLite)..reconcileTableonly adds and drops columns, so existing tables keep the old column. Upgrade: recreate orALTERthose columns. Separately, encoding an unassigned non-optional@ForeignKeynow throws fromencode(to:)instead of trapping (reads and insert/update still trap). - Missing optional wrapper columns read as nil (#79). If a query result lacks the column for an optional
@ForeignKey/@PrimaryKey, the value now reads nil, as a plain optional does (Perfect-SQLite and Perfect-PostgreSQL used to throw). Encoding is unchanged: a nil wrapped value still writes an explicitnull. - Key paths into nested properties throw (#76).
\T.sub?.x,\T.addr.idand similar used to resolve silently to one of the table's own columns (often the primary key), inwhere, the optional comparisons,order(by:)and insert/updatesetKeys/ignoreKeys. They now throwCRUDSQLGenError. A key path to a propertyinit(from:)never decodes also throws. One accepted false positive: a decoded non-Bool column derived so that shifting its input by one either way gives the same value (for example rounding to a multiple of 3) is reported as undecoded. Upgrade: filter or order only on top-level stored columns. - Same-typed Codable columns resolve correctly, or throw (#77). Two join arrays of one element type (
kidsandpets: [Kid]?) used to make\Parent.petsmeankids, so the join filled the wrong property. They now resolve to their own column. Some pairs that can't be told apart now throw for both columns (two non-optional empty structs, two non-optional dictionaries, two non-optional arrays of a String raw-value enum), as does a computed property of the shared type. Upgrade: if one of these now throws, give the columns distinguishable types or drop the key path. - Unresolvable join targets throw (#78). A join whose target key path can't be resolved made
select()return[]andfirst()returnnil. Both now throwCRUDSQLGenErrornaming the key path.count()is unaffected. - Joining the same key path twice throws (#80). This trapped in 2.0.0 (a duplicate-key crash);
select()now throws instead. - Unsupported join key types throw when the query is built (#82), including from
count(), which used to succeed because joins don't affect the count SQL. - Unlinked pivot rows no longer attach to a parent (#82). A pivot-joined row that no parent links to has a NULL pivot id. Going by the connector sources (not reproduced against live servers), Perfect-SQLite and Perfect-PostgreSQL would decode it as 0,
""orfalseand attach the row to a parent with that key, and Perfect-MySQL would throw. Such rows now match nothing. - A
whereon a type joined more than once throws (#84). When a type appears in a query more than once (two joins of one type, or a pivot table that is also a joined type) and is not the master type, awhereon it used to mean the first table of that type in every statement. It now throws an ambiguityCRUDSQLGenErrorfromselect()and fromcount(), which built valid SQL with first-table semantics in 2.0.0. Awhereon the master's own type still means the master table, including in self-joins. Upgrade: there is no API to say which join a key path means; drop thatwhereor restructure the query. - Orderings on self-joins sort the children (#84).
.order(by:)after a self-join or self-pivot join sorted the joined rows by the parent's column; it now sorts the joined table's own rows. Combined with.limit()on that join, this can change which rows are returned. More generally, an ordering now always applies to the statement of the table or join it follows.
Fixes
- Release builds with Swift 6.2 and 6.3 (Xcode 26.x) no longer crash computing the table structure of a model with an array property, which every
joinuses. The compiler mishandled the array-element existential; it's now opened through a generic function. Seen with Xcode 26.3 (Swift 6.2.4) and on CI with Xcode 26.0.1 and 26.6 (Swift 6.3); Swift 6.4 and Linux builds weren't affected. - #70: The table-structure cache is lock-protected against concurrent access; a primitive-typed
@ForeignKeycolumn read back through a connector no longer fails with "Unsupported type". - #71: Comparison operators on Optional columns;
Expressionalias removed; tests pin down subclass models that callsuper.init(from:)with the same decoder. - #72: The table-structure cache is keyed by type identity rather than unqualified name, so same-named nested, function-local or cross-module types no longer share a structure. SQL table names are unchanged.
- #73: The cache stores only canonical structures, so
primaryKey:is honoured after a cache hit and a type first seen as a sub-table no longer caches a structure missing its own sub-tables. - #74: Foreign-key cycles among a type's own columns (self-references, mutual references) no longer overflow the stack.
- #75:
@ForeignKey/@PrimaryKeywrapping an Optional becomes a nullable column and can be inserted, updated, filtered and read. - #76: Key paths through nested or optional-chained properties throw instead of resolving to the wrong column (and no longer crash on an undecoded
UUID). - #77: Same-typed columns that carry no decoded values (such as two join arrays of one element type) resolve to their own column.
- #78:
select()/first()throw when a join's target key path can't be resolved, instead of returning no rows. - #79: Optional
@ForeignKey/@PrimaryKeydecode from an absent key, like a plain optional. - #80: A pivot join followed by a plain join returns rows; select statements are paired with the right tables.
- #81: Self-referential Codable models no longer overflow the stack in the column-name and key-path decoders.
- #82: Joins on optional keys compile and return rows; a NULL key matches nothing.
- #83: A
whereon a pivot-joined type joins its pivot table; awhereon the pivot type works; awhereon a joinedTableNameProvidertype uses its table name. - #84: WHERE key paths on a type with more than one table resolve explicitly (or throw); ORDER BY applies to its own statement's table.
Upgrade notes
- Point your dependency at
from: "3.0.0"and build with Swift 6.2 or newer; raise deployment targets to macOS 12 / iOS 15. - Replace any
ExpressionwithCRUDExpression, and mark custom log closures@Sendable. - If you use optional
@ForeignKey/@PrimaryKeyproperties, recreate or alter the affected columns in existing databases;.reconcileTablewon't change them. - Run your query tests. Queries that previously returned wrong or empty results because of a bad key path, an unresolvable join or an ambiguous
wherenow throwCRUDSQLGenError; treat each new throw as a query that was already wrong. - Check any
.order(by:)placed after a self-join, especially with.limit(). - If you nest
transactioncalls, confirm the savepoint semantics suit you. - Connector authors: the new
SQLGenDelegate/SQLExeDelegaterequirements have defaults, so implement them only to support isolation levels and the dynamic API.
Known limitations that remain: Select's iterator still logs and swallows errors raised while decoding rows (some cases above now throw earlier, before iteration); creating two tables that reference each other needs database-specific steps (MySQL/MariaDB foreign_key_checks, PostgreSQL ALTER TABLE); key paths past a self-referential cut (\Node.next?.next) can't be resolved.
Legacy
The pre-Swift-6 code is preserved on the legacy branch, which points at the same commit as the 2.0.0 tag. Stay on 2.0.0 if you can't move to Swift 6.2 yet.