diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 2c64af7b..c03123a1 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -146,6 +146,10 @@ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { + func hasSet(key: String) -> Bool { + self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil + } + @discardableResult package func setValue( _ newValue: some CKRecordValueProtocol & Equatable, @@ -233,6 +237,9 @@ encryptedValues[at: key] = userModificationTime self.userModificationTime = userModificationTime return true + } else if !hasSet(key: key) { + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime } return false } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 878e832f..ec6c5455 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2003,7 +2003,9 @@ ) async throws -> QueryFragment { let nonPrimaryKeyChangedColumns = changedColumnNames - .filter { $0 != T.primaryKey.name } + .filter { + $0 != T.primaryKey.name && record.hasSet(key: $0) + } guard !nonPrimaryKeyChangedColumns.isEmpty else { @@ -2435,13 +2437,19 @@ record: CKRecord, columnNames: some Collection ) -> QueryFragment { - let allColumnNames = T.TableColumns.writableColumns.map(\.name) + let setColumnNames = T.TableColumns.writableColumns.map(\.name) + .filter { record.hasSet(key: $0) } + guard !setColumnNames.isEmpty + else { + return "" + } + let columnNames = columnNames.filter { setColumnNames.contains($0) } let hasNonPrimaryKeyColumns = columnNames.contains { $0 != T.primaryKey.name } var query: QueryFragment = "INSERT INTO \(T.self) (" - query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(setColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") query.append( - allColumnNames + setColumnNames .map { columnName in if let asset = record[columnName] as? CKAsset { @Dependency(\.dataManager) var dataManager diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 5c0a9347..eb9f2aa0 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -24,7 +24,7 @@ tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, @@ -34,7 +34,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "\'\'", @@ -101,7 +101,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -124,7 +124,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", @@ -177,7 +177,7 @@ tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -188,7 +188,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -210,7 +210,7 @@ tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """, tableInfo: [ @@ -219,7 +219,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -227,7 +227,7 @@ tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, @@ -237,7 +237,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -252,7 +252,7 @@ tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -263,7 +263,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -278,7 +278,7 @@ tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -296,7 +296,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -304,7 +304,7 @@ tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -315,7 +315,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -337,7 +337,7 @@ tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -348,7 +348,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 207728c7..d0a82cb3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -404,8 +404,8 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveRecord_SingleFieldPrimaryKey() async throws { - let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) - tagRecord.encryptedValues["title"] = "weekend" + let tagRecord = CKRecord(recordType: Tag.tableName, recordID: Tag.recordID(for: "weekend")) + tagRecord.setValue("weekend", forKey: "title", at: 0) try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() try await userDatabase.read { db in diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index cc2883d6..9596a66e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -119,10 +119,13 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) + modelARecord.setValue(1, forKey: "id", at: now) let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) + modelBRecord.setValue(1, forKey: "id", at: now) modelBRecord.setValue(1, forKey: "modelAID", at: now) modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) + modelCRecord.setValue(1, forKey: "id", at: now) modelCRecord.setValue(1, forKey: "modelBID", at: now) modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) @@ -205,7 +208,8 @@ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), recordType: "modelAs", parent: nil, - share: nil + share: nil, + id: 1 ) ] ), diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index e3a5964a..c2e36742 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -33,10 +33,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "", @@ -90,10 +92,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", @@ -135,10 +139,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 30, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", @@ -187,10 +193,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "", @@ -244,10 +252,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", @@ -289,10 +299,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 60, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", @@ -358,10 +370,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 60, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", @@ -445,10 +459,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Get milk", @@ -514,10 +530,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Get milk", @@ -584,10 +602,12 @@ recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, + dueDate🗓️: 0, id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, + priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, title: "Get milk", @@ -626,86 +646,90 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue( - Date(timeIntervalSince1970: Double(now + 30)), - forKey: "dueDate", - at: now - ) - let modificationsFinished = try syncEngine.modifyRecords( - scope: .private, - saving: [reminderRecord] - ) - try await withDependencies { $0.currentTime.now += 1 } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.priority = 3 }.execute(db) + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue( + Date(timeIntervalSince1970: Double(30)), + forKey: "dueDate", + at: now + ) + let modificationsFinished = try syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord] + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.priority = 3 }.execute(db) + } + await modificationsFinished.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - await modificationsFinished.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate: Date(1970-01-01T00:00:30.000Z), - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority: 3, - priority🗓️: 1, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDate🗓️: 1, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + priority: 3, + priority🗓️: 2, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 2 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder - == Reminder( + try await userDatabase.read { db in + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + expectNoDifference( + reminder, + Reminder( id: 1, dueDate: Date(timeIntervalSince1970: 30), priority: 3, remindersListID: 1 ) - ) + ) + } } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index a7190930..8e624b72 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -22,7 +22,7 @@ tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, @@ -32,7 +32,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "\'\'", @@ -99,7 +99,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -122,7 +122,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", @@ -175,7 +175,7 @@ tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -186,7 +186,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -208,7 +208,7 @@ tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """, tableInfo: [ @@ -217,7 +217,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -225,7 +225,7 @@ tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, @@ -235,7 +235,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -250,7 +250,7 @@ tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -261,7 +261,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -276,7 +276,7 @@ tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -294,7 +294,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -302,7 +302,7 @@ tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -313,7 +313,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -335,7 +335,7 @@ tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -346,7 +346,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -445,7 +445,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -468,7 +468,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index 735d3cb4..54357da9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -218,7 +218,7 @@ try #sql( """ ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 42 """ ) .execute(db) @@ -236,6 +236,220 @@ ], privateTables: syncEngine.privateTables ) + + try await userDatabase.read { db in + try #expect( + RemindersListWithPosition.fetchAll(db) == [ + RemindersListWithPosition(id: 1, title: "Personal", position: 42) + ] + ) + } + } + + /* + * Old schema creates record and synchronizes to iCloud. + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + => Sync starts without emitting an error and default value is persisted in local database. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_OldRecordsSyncToNewSchema() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + _ = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "Personal", color: 42) + ] + ) + } + } + + /* + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + * Old record with no 'color' value synchronized + => Local database row created uses default for 'color'. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_OldDeviceSyncsMissingColor() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("My stuff", forKey: "title", at: now) + try await relaunchedSyncEngine + .modifyRecords(scope: .private, saving: [remindersListRecord]) + .notify() + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "My stuff", color: 42) + ] + ) + } + assertInlineSnapshot(of: relaunchedSyncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + + /* + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + * New record with NULL 'color' value synchronized + => Local database row created uses NULL for color + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_NewDeviceSyncsNullColor() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("My stuff", forKey: "title", at: now) + remindersListRecord.removeValue(forKey: "color", at: now) + try await relaunchedSyncEngine + .modifyRecords(scope: .private, saving: [remindersListRecord]) + .notify() + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "My stuff", color: nil) + ] + ) + } + assertInlineSnapshot(of: relaunchedSyncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } /* @@ -671,11 +885,18 @@ } } +@Table("remindersLists") +private struct RemindersListWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 +} + @Table("remindersLists") - private struct RemindersListWithPosition: Equatable, Identifiable { + private struct RemindersListWithColor: Equatable, Identifiable { let id: Int var title = "" - var position = 0 + var color: Int? } @Table("reminders") diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 6283c358..7fff8667 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1592,6 +1592,7 @@ recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1, zoneID: externalZone.zoneID) ) + modelARecord.setValue(1, forKey: "id", at: now) modelARecord.setValue(42, forKey: "count", at: now) let share = CKShare( @@ -1657,7 +1658,8 @@ recordType: "modelAs", parent: nil, share: CKReference(recordID: CKRecord.ID(share-1:modelAs/external.zone/external.owner)), - count: 42 + count: 42, + id: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index e5ad0bcd..4f755037 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -26,7 +26,7 @@ @FetchAll var modelAs: [ModelA] = [] try await database.write { db in try db.seed { - ModelA.Draft() + ModelA.Draft(id: 1) } } try await $modelAs.load() diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index ba018d1c..a9e965b9 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -94,7 +94,7 @@ func database( try #sql( """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -123,7 +123,7 @@ func database( try #sql( """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -146,7 +146,7 @@ func database( try #sql( """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -156,7 +156,7 @@ func database( try #sql( """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """ ) @@ -164,7 +164,7 @@ func database( try #sql( """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """ @@ -173,7 +173,7 @@ func database( try #sql( """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -183,7 +183,7 @@ func database( try #sql( """ CREATE TABLE "localUsers" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT @@ -193,7 +193,7 @@ func database( try #sql( """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -203,7 +203,7 @@ func database( try #sql( """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -213,7 +213,7 @@ func database( try #sql( """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -223,7 +223,7 @@ func database( try #sql( """ CREATE TABLE "unsyncedModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) """ )