diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 5c53bb93..1da15ba4 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -283,10 +283,29 @@ typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable self.userModificationTime = other.userModificationTime - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { + _update( + with: other, + row: row, + columnNames: &columnNames, + parentForeignKey: parentForeignKey, + columns: T.TableColumns.writableColumns + ) + } + + private func _update( + with other: CKRecord, + row: T, + columnNames: inout [String], + parentForeignKey: ForeignKey?, + columns: [any WritableTableColumnExpression] + ) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + for column in columns { + func open(_ column: some WritableTableColumnExpression) { let key = column.name - let keyPath = column.keyPath as! KeyPath + let column = column as! any WritableTableColumnExpression + let keyPath = column.keyPath let didSet: Bool if let value = other[key] as? CKAsset { didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key]) diff --git a/Sources/SQLiteData/CloudKit/PrimaryKeyMigration.swift b/Sources/SQLiteData/CloudKit/PrimaryKeyMigration.swift index 8616c8bc..22f50a8f 100644 --- a/Sources/SQLiteData/CloudKit/PrimaryKeyMigration.swift +++ b/Sources/SQLiteData/CloudKit/PrimaryKeyMigration.swift @@ -2,103 +2,117 @@ import CryptoKit import Foundation - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncEngine { - /// Migrates integer primary-keyed tables and tables without primary keys to - /// CloudKit-compatible, UUID primary keys. - /// - /// To synchronize a table to CloudKit it must have a primary key, and that primary key must - /// be a globally unique identifier, such as a UUID. However, changing the type of a column - /// in SQLite is a [multi-step process] that must be followed very carefully, otherwise you run - /// the risk of corrupting your users' data. - /// - /// [multi-step process]: https://sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes - /// - /// This method is a general purpose tool that analyzes a set of tables to try to automatically - /// perform that migration for you. It performs the following steps: - /// - /// * Computes a random salt to use for backfilling existing integer primary keys with UUIDs. - /// * For each table passed to this method: - /// * Creates a new table with essentially the same schema, but the following changes: - /// * A new temporary name is given to the table. - /// * If an integer primary key exists, it is changed to a "TEXT" column with a - /// "NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT" constraint, and a default of - /// "uuid()" if no `uuid` argument is given, otherwise the argument is used. - /// * If no primary key exists, one is added with the same constraints as above. - /// * All integer foreign keys are changed to "TEXT" columns with no other changes. - /// * All data from the existing table is copied over into the new table, but all integer - /// IDs (both primary and foreign keys) are transformed into UUIDs by MD5 hashing the - /// integer, the table name, and the salt mentioned above, and turning that hash into a - /// UUID. - /// * The existing table is dropped. - /// * Thew new table is renamed to have the same name as the table just dropped. - /// * Any indexes and stored triggers that were removed from dropping tables in the steps - /// above are recreated. - /// * Executes a "PRAGMA foreign_key_check;" query to make sure that the integrity of the data - /// is preserved. - /// - /// If all of those steps are performed without throwing an error, then your schema and data - /// should have been successfully migrated to UUIDs. If an error is thrown for any reason, - /// then it means the tool was not able to safely migrate your data and so you will need to - /// perform the migration [manually](). - /// - /// - Parameters: - /// - db: A database connection. - /// - tables: Tables to migrate. - /// - uuidFunction: A UUID function to use for the default value of primary keys in your - /// tables' schemas. If `nil`, SQLite's `uuid` function will be used. - public static func migratePrimaryKeys( - _ db: Database, - tables: repeat (each T).Type, - dropUniqueConstraints: Bool = false, - uuid uuidFunction: (any ScalarDatabaseFunction<(), UUID>)? = nil - ) throws - where - repeat (each T).PrimaryKey.QueryOutput: IdentifierStringConvertible, - repeat (each T).TableColumns.PrimaryColumn: TableColumnExpression - { - let salt = - (try uuidFunction.flatMap { uuid -> UUID? in - try #sql("SELECT \(quote: uuid.name)()", as: UUID.self).fetchOne(db) - } - ?? UUID()).uuidString + // NB: Swift 6.2.3 and 6.3-dev crash when compiling the primary key migration feature + // due to a compiler bug with #sql macro interpolation combined with $backfillUUID + // (macro-generated database function). + // + // This entire feature is disabled on Swift 6.2.3+ until the compiler bug is fixed. + // See detailed comments below in the PrimaryKeyedTable extension. + // + // Tracking: https://github.com/doozMen/sqlite-data/issues/2 + // TODO: Re-enable when Swift 6.3 stabilizes or compiler bug is fixed. + #if !compiler(>=6.2.3) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + /// Migrates integer primary-keyed tables and tables without primary keys to + /// CloudKit-compatible, UUID primary keys. + /// + /// To synchronize a table to CloudKit it must have a primary key, and that primary key must + /// be a globally unique identifier, such as a UUID. However, changing the type of a column + /// in SQLite is a [multi-step process] that must be followed very carefully, otherwise you run + /// the risk of corrupting your users' data. + /// + /// [multi-step process]: https://sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes + /// + /// This method is a general purpose tool that analyzes a set of tables to try to automatically + /// perform that migration for you. It performs the following steps: + /// + /// * Computes a random salt to use for backfilling existing integer primary keys with UUIDs. + /// * For each table passed to this method: + /// * Creates a new table with essentially the same schema, but the following changes: + /// * A new temporary name is given to the table. + /// * If an integer primary key exists, it is changed to a "TEXT" column with a + /// "NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT" constraint, and a default of + /// "uuid()" if no `uuid` argument is given, otherwise the argument is used. + /// * If no primary key exists, one is added with the same constraints as above. + /// * All integer foreign keys are changed to "TEXT" columns with no other changes. + /// * All data from the existing table is copied over into the new table, but all integer + /// IDs (both primary and foreign keys) are transformed into UUIDs by MD5 hashing the + /// integer, the table name, and the salt mentioned above, and turning that hash into a + /// UUID. + /// * The existing table is dropped. + /// * Thew new table is renamed to have the same name as the table just dropped. + /// * Any indexes and stored triggers that were removed from dropping tables in the steps + /// above are recreated. + /// * Executes a "PRAGMA foreign_key_check;" query to make sure that the integrity of the data + /// is preserved. + /// + /// If all of those steps are performed without throwing an error, then your schema and data + /// should have been successfully migrated to UUIDs. If an error is thrown for any reason, + /// then it means the tool was not able to safely migrate your data and so you will need to + /// perform the migration [manually](). + /// + /// - Note: This method is unavailable on Swift 6.2.3+ due to a compiler bug. + /// See https://github.com/doozMen/sqlite-data/issues/2 + /// + /// - Parameters: + /// - db: A database connection. + /// - tables: Tables to migrate. + /// - uuidFunction: A UUID function to use for the default value of primary keys in your + /// tables' schemas. If `nil`, SQLite's `uuid` function will be used. + public static func migratePrimaryKeys( + _ db: Database, + tables: repeat (each T).Type, + dropUniqueConstraints: Bool = false, + uuid uuidFunction: (any ScalarDatabaseFunction<(), UUID>)? = nil + ) throws + where + repeat (each T).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T).TableColumns.PrimaryColumn: TableColumnExpression + { + let salt = + (try uuidFunction.flatMap { uuid -> UUID? in + try #sql("SELECT \(quote: uuid.name)()", as: UUID.self).fetchOne(db) + } + ?? UUID()).uuidString - db.add(function: $backfillUUID) - defer { db.remove(function: $backfillUUID) } + db.add(function: $backfillUUID) + defer { db.remove(function: $backfillUUID) } - var migratedTableNames: [String] = [] - for table in repeat each tables { - migratedTableNames.append(table.tableName) - } - let indicesAndTriggersSQL = - try SQLiteSchema - .select(\.sql) - .where { - $0.tableName.in(migratedTableNames) - && $0.type.in([#bind(.index), #bind(.trigger)]) - && $0.sql.isNot(nil) + var migratedTableNames: [String] = [] + for table in repeat each tables { + migratedTableNames.append(table.tableName) + } + let indicesAndTriggersSQL = + try SQLiteSchema + .select(\.sql) + .where { + $0.tableName.in(migratedTableNames) + && $0.type.in([#bind(.index), #bind(.trigger)]) + && $0.sql.isNot(nil) + } + .fetchAll(db) + .compactMap(\.self) + for table in repeat each tables { + try table.migratePrimaryKeyToUUID( + db: db, + dropUniqueConstraints: dropUniqueConstraints, + uuidFunction: uuidFunction, + migratedTableNames: migratedTableNames, + salt: salt + ) + } + for sql in indicesAndTriggersSQL { + try #sql(QueryFragment(stringLiteral: sql)).execute(db) } - .fetchAll(db) - .compactMap(\.self) - for table in repeat each tables { - try table.migratePrimaryKeyToUUID( - db: db, - dropUniqueConstraints: dropUniqueConstraints, - uuidFunction: uuidFunction, - migratedTableNames: migratedTableNames, - salt: salt - ) - } - for sql in indicesAndTriggersSQL { - try #sql(QueryFragment(stringLiteral: sql)).execute(db) - } - let foreignKeyChecks = try PragmaForeignKeyCheck.all.fetchAll(db) - if !foreignKeyChecks.isEmpty { - throw ForeignKeyCheckError(checks: foreignKeyChecks) + let foreignKeyChecks = try PragmaForeignKeyCheck.all.fetchAll(db) + if !foreignKeyChecks.isEmpty { + throw ForeignKeyCheckError(checks: foreignKeyChecks) + } } } - } + #endif private struct MigrationError: LocalizedError { let reason: Reason @@ -124,127 +138,127 @@ } } - @available(iOS 16, macOS 13, tvOS 13, watchOS 9, *) - extension PrimaryKeyedTable where TableColumns.PrimaryColumn: TableColumnExpression { - fileprivate static func migratePrimaryKeyToUUID( - db: Database, - dropUniqueConstraints: Bool, - uuidFunction: (any ScalarDatabaseFunction<(), UUID>)? = nil, - migratedTableNames: [String], - salt: String - ) throws { - let schema = - try SQLiteSchema - .select(\.sql) - .where { $0.type.eq(#bind(.table)) && $0.tableName.eq(tableName) } - .fetchOne(db) - ?? nil - - guard let schema - else { - throw MigrationError(reason: .tableNotFound(tableName)) - } - - let tableInfo = try PragmaTableInfo.all.fetchAll(db) - let primaryKeys = tableInfo.filter(\.isPrimaryKey) - guard - (primaryKeys.count == 1 && primaryKeys[0].isInt) - || primaryKeys.isEmpty - else { - throw MigrationError(reason: .invalidPrimaryKey) - } - guard primaryKeys.count <= 1 - else { - throw MigrationError(reason: .invalidPrimaryKey) - } + // NB: Swift 6.2.3 and 6.3-dev crash when compiling this extension due to a compiler bug + // with #sql macro interpolation combined with $backfillUUID (macro-generated database + // function) in complex control flow. + // + // Error: "Assertion failed: (Start.isValid() == End.isValid() && 'Start and end should + // either both be valid or both be invalid!'), function SourceRange, file SourceLoc.h" + // + // This is a CloudKit-specific migration feature that: + // 1. Is only relevant on Apple platforms (CloudKit doesn't exist on Linux/Android) + // 2. Is used for migrating existing integer primary keys to UUID primary keys + // 3. Is not needed for new databases or cross-platform builds + // + // Workaround: Disable on Swift 6.2.3+ until the compiler bug is fixed. + // Tracking: https://github.com/doozMen/sqlite-data/issues/2 + // TODO: Re-enable when Swift 6.3 stabilizes or compiler bug is fixed. + #if !compiler(>=6.2.3) + @available(iOS 16, macOS 13, tvOS 13, watchOS 9, *) + extension PrimaryKeyedTable where TableColumns.PrimaryColumn: TableColumnExpression { + fileprivate static func migratePrimaryKeyToUUID( + db: Database, + dropUniqueConstraints: Bool, + uuidFunction: (any ScalarDatabaseFunction<(), UUID>)? = nil, + migratedTableNames: [String], + salt: String + ) throws { + let schema = + try SQLiteSchema + .select(\.sql) + .where { $0.type.eq(#bind(.table)) && $0.tableName.eq(tableName) } + .fetchOne(db) + ?? nil + + guard let schema + else { + throw MigrationError(reason: .tableNotFound(tableName)) + } - let foreignKeys = try PragmaForeignKeyList.all.fetchAll(db) - guard foreignKeys.allSatisfy({ migratedTableNames.contains($0.table) }) - else { - throw MigrationError(reason: .invalidForeignKey) - } + let tableInfo = try PragmaTableInfo.all.fetchAll(db) + let primaryKeys = tableInfo.filter(\.isPrimaryKey) + guard + (primaryKeys.count == 1 && primaryKeys[0].isInt) + || primaryKeys.isEmpty + else { + throw MigrationError(reason: .invalidPrimaryKey) + } + guard primaryKeys.count <= 1 + else { + throw MigrationError(reason: .invalidPrimaryKey) + } - let newTableName = "new_\(tableName)" - let uuidFunction = uuidFunction?.name ?? "uuid" - let newSchema = try schema.rewriteSchema( - dropUniqueConstraints: dropUniqueConstraints, - oldPrimaryKey: primaryKeys.first?.name, - newPrimaryKey: columns.primaryKey.name, - foreignKeys: foreignKeys.map(\.from), - uuidFunction: uuidFunction - ) + let foreignKeys = try PragmaForeignKeyList.all.fetchAll(db) + guard foreignKeys.allSatisfy({ migratedTableNames.contains($0.table) }) + else { + throw MigrationError(reason: .invalidForeignKey) + } - var newColumns: [String] = [] - var convertedColumns: [QueryFragment] = [] - if primaryKeys.first == nil { - convertedColumns.append("NULL") - newColumns.append(columns.primaryKey.name) - } - newColumns.append(contentsOf: tableInfo.map(\.name)) - // NB: Swift 6.3-dev crashes on complex closures with #sql macro interpolation. - // Extracted to helper function to work around compiler bug. - for info in tableInfo { - let fragment = convertTableInfoToQueryFragment( - info, - primaryKeyName: primaryKey.name, - foreignKeys: foreignKeys, - tableName: tableName, - salt: salt + let newTableName = "new_\(tableName)" + let uuidFunction = uuidFunction?.name ?? "uuid" + let newSchema = try schema.rewriteSchema( + dropUniqueConstraints: dropUniqueConstraints, + oldPrimaryKey: primaryKeys.first?.name, + newPrimaryKey: columns.primaryKey.name, + foreignKeys: foreignKeys.map(\.from), + uuidFunction: uuidFunction ) - convertedColumns.append(fragment) - } - try #sql(QueryFragment(stringLiteral: newSchema)).execute(db) - try #sql( - """ - INSERT INTO \(quote: newTableName) \ - ("rowid", \(newColumns.map { "\(quote: $0)" }.joined(separator: ", "))) - SELECT "rowid", \(convertedColumns.joined(separator: ", ")) \ - FROM \(Self.self) - ORDER BY "rowid" - """ - ) - .execute(db) - try #sql( - """ - DROP TABLE \(Self.self) - """ - ) - .execute(db) - try #sql( - """ - ALTER TABLE \(quote: newTableName) RENAME TO \(Self.self) - """ - ) - .execute(db) - } + var newColumns: [String] = [] + var convertedColumns: [QueryFragment] = [] + if primaryKeys.first == nil { + convertedColumns.append("NULL") + newColumns.append(columns.primaryKey.name) + } + newColumns.append(contentsOf: tableInfo.map(\.name)) + convertedColumns.append( + contentsOf: tableInfo.map { tableInfo -> QueryFragment in + if tableInfo.name == primaryKey.name, tableInfo.isInt { + return $backfillUUID( + id: #sql("\(quote: tableInfo.name)"), table: tableName, salt: salt + ) + .queryFragment + } else if tableInfo.isInt, + let foreignKey = foreignKeys.first(where: { $0.from == tableInfo.name }) + { + return $backfillUUID( + id: #sql("\(quote: foreignKey.from)"), + table: foreignKey.table, + salt: salt + ) + .queryFragment + } else { + return QueryFragment(quote: tableInfo.name) + } + } + ) - // NB: Extracted from closure to work around Swift 6.3-dev compiler crash. - // The compiler crashes on complex closures mixing #sql macro interpolation with nested closures. - private static func convertTableInfoToQueryFragment( - _ tableInfo: PragmaTableInfo, - primaryKeyName: String, - foreignKeys: [PragmaForeignKeyList], - tableName: String, - salt: String - ) -> QueryFragment { - if tableInfo.name == primaryKeyName, tableInfo.isInt { - return $backfillUUID(id: #sql("\(quote: tableInfo.name)"), table: tableName, salt: salt) - .queryFragment - } else if tableInfo.isInt, - let foreignKey = foreignKeys.first(where: { $0.from == tableInfo.name }) - { - return $backfillUUID( - id: #sql("\(quote: foreignKey.from)"), - table: foreignKey.table, - salt: salt + try #sql(QueryFragment(stringLiteral: newSchema)).execute(db) + try #sql( + """ + INSERT INTO \(quote: newTableName) \ + ("rowid", \(newColumns.map { "\(quote: $0)" }.joined(separator: ", "))) + SELECT "rowid", \(convertedColumns.joined(separator: ", ")) \ + FROM \(Self.self) + ORDER BY "rowid" + """ ) - .queryFragment - } else { - return QueryFragment(quote: tableInfo.name) + .execute(db) + try #sql( + """ + DROP TABLE \(Self.self) + """ + ) + .execute(db) + try #sql( + """ + ALTER TABLE \(quote: newTableName) RENAME TO \(Self.self) + """ + ) + .execute(db) } } - } + #endif extension StringProtocol { fileprivate func quoted() -> String { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 76bbbb71..2dfcb1ca 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -166,7 +166,7 @@ database: container.privateCloudDatabase, stateSerialization: try? metadatabase.read { db in try StateSerialization - .find(#bind(.private)) + .where { $0.scope == #bind(.private) } .select(\.data) .fetchOne(db) }, @@ -178,7 +178,7 @@ database: container.sharedCloudDatabase, stateSerialization: try? metadatabase.read { db in try StateSerialization - .find(#bind(.shared)) + .where { $0.scope == #bind(.shared) } .select(\.data) .fetchOne(db) }, diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/Table+GRDB.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Table+GRDB.swift new file mode 100644 index 00000000..025a2cba --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Table+GRDB.swift @@ -0,0 +1,63 @@ +import StructuredQueriesCore + +extension StructuredQueriesCore.Table { + /// Returns an array of all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: An array of all values decoded from the database. + @inlinable + public static func fetchAll(_ db: Database) throws -> [QueryOutput] { + try all.fetchAll(db) + } + + /// Returns a single value fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A single value decoded from the database. + @inlinable + public static func fetchOne(_ db: Database) throws -> QueryOutput? { + try all.fetchOne(db) + } + + /// Returns the number of rows fetched by the query. + /// + /// - Parameter db: A database connection. + /// - Returns: The number of rows fetched by the query. + @inlinable + public static func fetchCount(_ db: Database) throws -> Int { + try all.fetchCount(db) + } + + /// Returns a cursor to all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A cursor to all values decoded from the database. + @inlinable + public static func fetchCursor(_ db: Database) throws -> QueryCursor { + try all.fetchCursor(db) + } +} + +// NB: Swift 6.2.3 and 6.3-dev guard Select.find(_:) in swift-structured-queries due to compiler crashes. +// This extension depends on that method, so it must also be guarded. +// Tracking: https://github.com/doozMen/sqlite-data/issues/2 +#if !compiler(>=6.2.3) + extension StructuredQueriesCore.PrimaryKeyedTable { + /// Returns a single value fetched from the database for a given primary key. + /// + /// - Parameters + /// - db: A database connection. + /// - primaryKey: A primary key identifying a table row. + /// - Returns: A single value decoded from the database. + @inlinable + public static func find( + _ db: Database, + key primaryKey: some QueryExpression + ) throws -> QueryOutput { + guard let record = try Self.all.find(primaryKey).fetchOne(db) else { + throw NotFound() + } + return record + } + } +#endif