diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index fa347e46..3fe61b71 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -171,7 +171,7 @@ public func unshare(record: T) async throws where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { - let share = try await userDatabase.read { [recordName = record.recordName] db in + let share = try await metadatabase.read { [recordName = record.recordName] db in try SyncMetadata .where { $0.recordName.eq(recordName) } .select(\.share) @@ -289,8 +289,10 @@ } public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try syncEngine.deleteShare(recordID: share.recordID) + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await syncEngine.deleteShare(recordID: share.recordID) + } } didStopSharing() } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 73a4e84f..af81fb75 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -247,7 +247,7 @@ !hasSchemaChanges, """ A previously run migration has been removed or edited. - + Metadatabase migrations must not be modified after release. """ ) @@ -344,9 +344,9 @@ private func start() throws -> Task { guard !isRunning else { return Task {} } - let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) $0 = SyncEngines( private: privateSyncEngine, shared: sharedSyncEngine @@ -434,13 +434,11 @@ try await userDatabase.write { db in try PendingRecordZoneChange.delete().execute(db) - } - let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in - previousRecordTypeByTableName[tableName] == nil - } + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in + previousRecordTypeByTableName[tableName] == nil + } - try await userDatabase.write { db in try Self.$_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { try self.uploadRecordsToCloudKit(tableName: tableName, db: db) @@ -671,7 +669,7 @@ case .accountChange(let changeType): await handleAccountChange(changeType: changeType, syncEngine: syncEngine) case .stateUpdate(let stateSerialization): - handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + await handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let modifications, let deletions): await handleFetchedDatabaseChanges( modifications: modifications, @@ -987,13 +985,6 @@ switch changeType { case .signIn: syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - for table in self.tables { - try self.uploadRecordsToCloudKit(table: table, db: db) - } - } - } case .signOut, .switchAccounts: withErrorReporting(.sqliteDataCloudKitFailure) { try deleteLocalData() @@ -1006,9 +997,9 @@ package func handleStateUpdate( stateSerialization: CKSyncEngine.State.Serialization, syncEngine: any SyncEngineProtocol - ) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + ) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try StateSerialization.upsert { StateSerialization.Draft( scope: syncEngine.database.databaseScope, @@ -1136,8 +1127,8 @@ open(table) } else if recordType == CKRecord.SystemType.share { for recordID in recordIDs { - withErrorReporting(.sqliteDataCloudKitFailure) { - try deleteShare(recordID: recordID) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteShare(recordID: recordID) } } } else { @@ -1249,9 +1240,9 @@ syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for (failedRecord, error) in failedRecordSaves { - func clearServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + func clearServerRecord() async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(failedRecord.recordID.recordName) } .update { $0.setLastKnownServerRecord(nil) } @@ -1270,14 +1261,14 @@ let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) newPendingDatabaseChanges.append(.saveZone(zone)) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() + await clearServerRecord() case .unknownItem: newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() + await clearServerRecord() case .serverRejectedRequest: - clearServerRecord() + await clearServerRecord() case .referenceViolation: guard @@ -1286,8 +1277,8 @@ foreignKeysByTableName[table.tableName]?.count == 1, let foreignKey = foreignKeysByTableName[table.tableName]?.first else { continue } - func open(_: T.Type) throws { - try userDatabase.write { db in + func open(_: T.Type) async throws { + try await userDatabase.write { db in try Self.$_isSynchronizingChanges.withValue(false) { switch foreignKey.onDelete { case .cascade: @@ -1333,8 +1324,8 @@ } } } - withErrorReporting(.sqliteDataCloudKitFailure) { - try open(table) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) } case .permissionFailure: @@ -1411,8 +1402,8 @@ } } - func deleteShare(recordID: CKRecord.ID) throws { - try userDatabase.write { db in + func deleteShare(recordID: CKRecord.ID) async throws { + try await userDatabase.write { db in let shareAndRecordName = try SyncMetadata .where(\.isShared) @@ -1522,9 +1513,9 @@ private func refreshLastKnownServerRecord(_ record: CKRecord) async { let metadata = await metadataFor(recordName: record.recordID.recordName) - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + func updateLastKnownServerRecord() async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(record.recordID.recordName) } .update { $0.setLastKnownServerRecord(record) } @@ -1535,10 +1526,10 @@ if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } else { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 3ecfac86..7b4ab1b8 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -658,13 +658,13 @@ And in preivews you can use it like so: ### Convert Int primary keys to UUID The most important step for migrating an existing SQLite database to be compatible with CloudKit -synchronization is converting any `Int` primary keys in your tables to UUID, or some other +synchronization is converting any `Int` primary keys in your tables to UUID, or some other globally unique identifier. This can be done in a new migration that is registered when provisioning -your database, but it does take a few queries to accomplish because SQLite does not support -changing the definition of an existing column. +your database, but it does take a few queries to accomplish because SQLite does not support +changing the definition of an existing column. -The steps are roughly: 1) create a table with the new schema, 2) copy data over from old -table to new table and convert integer IDs to UUIDs, 3) drop the old table, and finally 4) rename +The steps are roughly: 1) create a table with the new schema, 2) copy data over from old +table to new table and convert integer IDs to UUIDs, 3) drop the old table, and finally 4) rename the new table to have the same name as the old table. ```swift @@ -683,7 +683,7 @@ migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") try #sql(""" INSERT INTO "new_remindersLists" ( - "id", + "id", -- all other columns from 'remindersLists' table ) SELECT @@ -708,7 +708,7 @@ migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") } ``` -This will need to be done for every table that uses an integer for its primary key. Further, +This will need to be done for every table that uses an integer for its primary key. Further, for tables with foreign keys, you will need to adapt step 1 to change the types of those columns to TEXT and will need to perform the integer-to-UUID conversion for those columns in step 2: @@ -730,7 +730,7 @@ migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db try #sql(""" INSERT INTO "new_reminders" ( - "id", + "id", "remindersListID", -- all other columns from 'reminders' table ) @@ -866,7 +866,7 @@ from CloudKit. ### Developing in the simulator It is possible to develop your app with CloudKit synchronization using the iOS simulator, but -you must be aware that simulators do not support push notifications, and so changes do not +you must be aware that simulators do not support push notifications, and so changes do not synchronize from CloudKit to simulator automatically. Sometimes you can simply close and re-open the app to have the simulator sync with CloudKit, but the most certain way to force synchronization is to kill the app and relaunch it fresh. diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index e5e601bd..e547a132 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -25,15 +25,16 @@ await signOut() - try { - try userDatabase.userRead { db in - try #expect(RemindersList.count().fetchOne(db) == 0) - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect(RemindersListPrivate.count().fetchOne(db) == 0) - try #expect(UnsyncedModel.count().fetchOne(db) == 1) - try #expect(SyncMetadata.count().fetchOne(db) == 0) - } - }() + try await userDatabase.userRead { db in + try #expect(RemindersList.count().fetchOne(db) == 0) + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect(RemindersListPrivate.count().fetchOne(db) == 0) + try #expect(UnsyncedModel.count().fetchOne(db) == 1) + } + + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.count().fetchOne(db) == 0) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -47,15 +48,15 @@ } } - try { - try userDatabase.read { db in - try #expect(RemindersList.count().fetchOne(db) == 1) - try #expect(Reminder.count().fetchOne(db) == 1) - try #expect(RemindersListPrivate.count().fetchOne(db) == 1) - try #expect(UnsyncedModel.count().fetchOne(db) == 1) - try #expect(SyncMetadata.count().fetchOne(db) == 3) - } - }() + try await userDatabase.read { db in + try #expect(RemindersList.count().fetchOne(db) == 1) + try #expect(Reminder.count().fetchOne(db) == 1) + try #expect(RemindersListPrivate.count().fetchOne(db) == 1) + try #expect(UnsyncedModel.count().fetchOne(db) == 1) + } + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.count().fetchOne(db) == 3) + } await signIn() @@ -106,40 +107,35 @@ } } - @MainActor - @Suite(.accountStatus(.noAccount)) - final class SignedOutTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init { userDatabase in - try await userDatabase.write { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - RemindersListPrivate(id: 1, remindersListID: 1) - UnsyncedModel(id: 1) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + .accountStatus(.noAccount), + .prepareDatabase { userDatabase in + try await userDatabase.write { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + RemindersListPrivate(id: 1, remindersListID: 1) + UnsyncedModel(id: 1) } } } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { - assertQuery(SyncMetadata.all, database: userDatabase.database) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + ) + func doNotUploadExistingDataToCloudKitWhenSignedOut() { + assertQuery(SyncMetadata.all, database: userDatabase.database) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] ) - """ - } + ) + """ } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 89d9a155..e3a06d93 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -5,6 +5,7 @@ import InlineSnapshotTesting import OrderedCollections import SQLiteData + import SQLiteDataTestSupport import SnapshotTestingCustomDump import Testing @@ -396,6 +397,14 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.select(\.recordName), database: syncEngine.metadatabase) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -419,12 +428,6 @@ ) """ } - - let metadata = - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1).fetchOne(db) - } - #expect(metadata != nil) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -564,59 +567,35 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + record.setValue("Work", forKey: "title", at: now) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + } + + assertQuery(RemindersList.all, database: userDatabase.database) { """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) + ┌─────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Work" │ + │ ) │ + └─────────────────┘ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationDate), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────────────────┐ + │ Date(1970-01-01T00:01:00.000Z) │ + └────────────────────────────────┘ """ } - - let userModificationDate = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .select(\.userModificationDate) - .fetchOne(db) ?? nil - } - ) - - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - let serverModificationDate = userModificationDate.addingTimeInterval(60) - record.setValue("Work", forKey: "title", at: serverModificationDate) - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - - expectNoDifference( - try { - try userDatabase.userRead { db in - try RemindersList.find(1).fetchOne(db) - } - }(), - RemindersList(id: 1, title: "Work") - ) - - let metadata = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) - } - ) - #expect(metadata.userModificationDate == serverModificationDate) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -695,56 +674,24 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: 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: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let userModificationDate = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .select(\.userModificationDate) - .fetchOne(db) ?? nil - } - ) let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - record.encryptedValues["title"] = "Work" + record.setValue("Work", forKey: "title", at: now) // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - expectNoDifference( - try { try userDatabase.userRead { db in try RemindersList.find(1).fetchOne(db) } }(), - RemindersList(id: 1, title: "Personal") - ) - - let metadata = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) - } - ) - #expect(metadata.userModificationDate == userModificationDate) + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery( + SyncMetadata.select(\.userModificationDate), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────────────────┐ + │ Date(1970-01-01T00:00:00.000Z) │ + └────────────────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -778,43 +725,21 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 1)] + ) + .notify() + + assertQuery(RemindersList.all, database: userDatabase.database) { """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) """ } - - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - try await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]).notify() - - #expect( - try await userDatabase.userRead { db in - try RemindersList.find(1).fetchAll(db) - } == [] - ) - let metadata = try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + """ } - #expect(metadata == nil) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index c0847578..1e6beb65 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -133,89 +133,38 @@ try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() } - assertInlineSnapshot( - of: syncEngine.private.database - .storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], - as: .customDump - ) { - """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: "2", - title: "Get milk" - ) - """ - } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) - } - try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ - Reminder.recordID(for: 1) - ], - as: .customDump - ) { + assertQuery(Reminder.all, database: userDatabase.database) { """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: "2", - title: "Get milk" - ) + ┌──────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: true, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 2 │ + │ ) │ + └──────────────────────┘ """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder - == Reminder( - id: 1, - isCompleted: true, - title: "Get milk", - remindersListID: 2 - ) - ) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "2:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveNewRecordFromCloudKit() async throws { - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1) - ) - remindersListRecord.setValue("1", forKey: "id", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -223,12 +172,30 @@ databaseScope: .private, storage: [ [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: "2", + title: "Get milk" + ), + [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, - id: "1", + id: 1, title: "Personal" + ), + [2]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 2, + title: "Business" ) ] ), @@ -239,15 +206,18 @@ ) """ } + } - try await userDatabase.read { db in - let metadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.recordName == RemindersList.recordName(for: 1)) - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordReceivedFromCloudKit() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() try await withDependencies { $0.datetime.now.addTimeInterval(1) @@ -255,10 +225,29 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "My stuff" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -282,11 +271,6 @@ ) """ } - - try await userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "My stuff")) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -315,72 +299,41 @@ saving: [remindersListRecord] ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - - 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, - id: "1", - remindersListID: "1", - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "1", - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - await remindersListModification.notify() - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } - try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -414,11 +367,6 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index dcb1e869..c0ecaf7e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -2,6 +2,7 @@ import CloudKit import CustomDump import Foundation + import SQLiteDataTestSupport import InlineSnapshotTesting import SQLiteData import SnapshotTestingCustomDump @@ -10,6 +11,9 @@ extension BaseCloudKitTests { @MainActor final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { + // * Receive child record with no parent record. + // * Receive parent record. + // => Both records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChildBeforeParent() async throws { let remindersListRecord = CKRecord( @@ -36,79 +40,41 @@ saving: [remindersListRecord] ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - - 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, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.read { db in - let remindersList = try RemindersList.find(1).fetchOne(db) - #expect(remindersList == nil) - let reminder = try Reminder.find(1).fetchOne(db) - #expect(reminder == nil) - } - await remindersListModification.notify() - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - } - try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -142,18 +108,13 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) - } } /* * Remote client creates records A <- B <- C * Records A and C are sync'd to local client. * Remote deletes record B and C. - * C should be deleted from local client. + * Unsynced C should be deleted from local client. */ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { @@ -169,12 +130,35 @@ _ = try syncEngine.modifyRecords(scope: .private, saving: [modelBRecord]) try await syncEngine.modifyRecords(scope: .private, saving: [modelCRecord]).notify() - try await userDatabase.read { db in - try #expect( - UnsyncedRecordID.all.fetchAll(db) == [ - UnsyncedRecordID(recordID: ModelC.recordID(for: 1)) - ] - ) + assertQuery(ModelA.all, database: userDatabase.database) { + """ + ┌────────────────┐ + │ ModelA( │ + │ id: 1, │ + │ count: 0, │ + │ isEven: true │ + │ ) │ + └────────────────┘ + """ + } + assertQuery(ModelB.all, database: userDatabase.database) { + """ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + """ + } + assertQuery(UnsyncedRecordID.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────┐ + │ UnsyncedRecordID( │ + │ recordName: "1:modelCs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__" │ + │ ) │ + └─────────────────────────────────┘ + """ } try await syncEngine.modifyRecords( @@ -183,12 +167,29 @@ ) .notify() - try await userDatabase.read { db in - try #expect( - UnsyncedRecordID.all.fetchAll(db) == [] - ) + assertQuery(ModelA.all, database: userDatabase.database) { + """ + ┌────────────────┐ + │ ModelA( │ + │ id: 1, │ + │ count: 0, │ + │ isEven: true │ + │ ) │ + └────────────────┘ + """ + } + assertQuery(ModelB.all, database: userDatabase.database) { + """ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + """ + } + assertQuery(UnsyncedRecordID.all, database: syncEngine.metadatabase) { + """ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -213,7 +214,7 @@ } // * Receive child record with no parent record. - // * Receive parent record. + // * Receive both child and parent together. // => Both records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() async throws { @@ -250,6 +251,30 @@ ) .notify() + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -282,19 +307,6 @@ ) """ } - - try await userDatabase.read { db in - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] - ) - try #expect( - Reminder.all.fetchAll(db) == [ - Reminder(id: 1, title: "Get milk", remindersListID: 1) - ] - ) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -318,47 +330,14 @@ action: .none ) - _ = try { try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) }() + _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - assertInlineSnapshot(of: container, as: .customDump) { + assertQuery(Reminder.all, database: userDatabase.database) { """ - 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, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) """ } - try await userDatabase.read { db in - let reminder = try Reminder.find(1).fetchOne(db) - #expect(reminder == nil) - } - let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, userDatabase: syncEngine.userDatabase, @@ -372,24 +351,40 @@ syncEngine: relaunchedSyncEngine.private ) - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "1:remindersLists" │ + │ "1:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ } try await withDependencies { @@ -402,6 +397,20 @@ try await relaunchedSyncEngine.processPendingRecordZoneChanges(scope: .private) } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -435,11 +444,6 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) - } } // * Remote moves child to a parent the local client does not know about. @@ -480,39 +484,6 @@ saving: [reminderRecord, personalListRecord] ).notify() - 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, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let modifications = try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { @@ -532,6 +503,47 @@ await modifications.notify() + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "2:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 2 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + ├─────────────────────┤ + │ RemindersList( │ + │ id: 2, │ + │ title: "Business" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -572,19 +584,15 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) - - let reminderMetadata = try #require( - try Reminder.metadata(for: 1) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == "2:remindersLists") - } } + // * Create 3 reminders lists and a reminder + // * Sync to CloudKit + // * Move reminder to different list on CloudKit, do not synchronize it right away. + // * A moment ater, move local reminder to different list + // * Sync CloudKit to local + // * Then send local to CloudKit + // => Local edit wins @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemotelyThenLocally() async throws { try await userDatabase.userWrite { db in @@ -624,18 +632,35 @@ } await modifications.notify() - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 3)) - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "3:remindersLists" │ nil │ + │ "1:reminders" │ "3:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 3 │ + │ ) │ + └───────────────────────┘ + """ + } assertInlineSnapshot( of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ Reminder.recordID(for: 1) @@ -655,17 +680,15 @@ ) """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 3)) - } } + // * Create 3 reminders lists and a reminder + // * Sync to CloudKit + // * Move reminder to different list on CloudKit, do not synchronize it right away. + // * A moment ater, move local reminder to different list + // * Send local data to CloudKit + // * The synchronize CloudKit to local + // => Local edit wins @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() @@ -703,40 +726,36 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) - } - await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ - Reminder.recordID(for: 1) - ], - as: .customDump + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase ) { """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 3, - title: "Get milk" - ) + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "3:remindersLists" │ nil │ + │ "1:reminders" │ "3:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 3 │ + │ ) │ + └───────────────────────┘ """ } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ Reminder.recordID(for: 1) @@ -756,15 +775,6 @@ ) """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 67d4d1e3..6559843e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import InlineSnapshotTesting import OrderedCollections @@ -12,7 +13,7 @@ @MainActor final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordName() async throws { + @Test func parentRecordNameUpdatesAfterMovingReminderToDifferentList() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -22,76 +23,45 @@ } 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, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Groceries" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [2]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 2, - title: "Work" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.userRead { db in - let reminderMetadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: 1)) } - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - } try withDependencies { $0.datetime.now.addTimeInterval(60) } operation: { - _ = try { - try userDatabase.userWrite { db in - try Reminder.find(1) - .update { $0.remindersListID = 2 } - .execute(db) - let reminderMetadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: 1)) } - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 2)) - } - }() + try userDatabase.userWrite { db in + try Reminder.find(1) + .update { $0.remindersListID = 2 } + .execute(db) + } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Groceries", │ + │ remindersListID: 2 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "1:reminders" │ "2:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -135,6 +105,7 @@ } } + // 'parent' association is not set on CKRecord for records with multiple foreign keys. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { try await userDatabase.userWrite { db in @@ -197,95 +168,324 @@ """ } - let parentRecordNames = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType != Reminder.tableName } - .select(\.parentRecordName) - .fetchAll(db) - } - #expect(parentRecordNames.allSatisfy { $0 == nil }) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func recordType() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let reminderMetadata = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType == Reminder.tableName } - .fetchAll(db) - } - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordType() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.userRead { db in - let reminderMetadata = - try SyncMetadata - .where { $0.parentRecordType == RemindersList.tableName } - .fetchAll(db) - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminderTags" │ nil │ + │ "1:reminders" │ "1:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "weekend:tags" │ nil │ + └────────────────────┴────────────────────┘ + """ } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordPrimaryKey() async throws { + @Test func metadataFields() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Reminder(id: 2, title: "Take a walk", remindersListID: 1) + Reminder(id: 3, title: "Call accountant", remindersListID: 2) + Tag(title: "weekend") + Tag(title: "optional") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + ReminderTag(id: 2, reminderID: 2, tagID: "weekend") + ReminderTag(id: 3, reminderID: 3, tagID: "optional") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userRead { db in - let reminderMetadata = - try SyncMetadata - .where { $0.parentRecordPrimaryKey.eq("1") } - .fetchAll(db) - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) + assertQuery( + SyncMetadata.order(by: \.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminderTags", │ + │ recordName: "1:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ reminderID: 1, │ + │ tagID: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Groceries" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminderTags", │ + │ recordName: "2:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 2, │ + │ reminderID: 2, │ + │ tagID: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminders", │ + │ recordName: "2:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 2, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Take a walk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "remindersLists", │ + │ recordName: "2:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 2, │ + │ title: "Business" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminderTags", │ + │ recordName: "3:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(3:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(3:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 3, │ + │ reminderID: 3, │ + │ tagID: "optional" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminders", │ + │ recordName: "3:reminders", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "2:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 3, │ + │ isCompleted: 0, │ + │ remindersListID: 2, │ + │ title: "Call accountant" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "optional", │ + │ recordType: "tags", │ + │ recordName: "optional:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "optional" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "weekend", │ + │ recordType: "tags", │ + │ recordName: "weekend:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index fd94e0f8..bddfd88b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -12,7 +12,7 @@ @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { + override init() async throws { try await super.init() let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( saving: [ diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index e2577b62..6c0868fb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import InlineSnapshotTesting import SQLiteData @@ -9,21 +10,19 @@ extension BaseCloudKitTests { @MainActor - final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init( - setUpUserDatabase: { userDatabase in - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Write blog post", remindersListID: 1) - } - } + @Suite( + .prepareDatabase { userDatabase in + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Write blog post", remindersListID: 1) } - ) + } } - + ) + final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { + // * Create records before sync engine starts + // => Records are sent to CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func initialSync() async throws { try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -61,67 +60,15 @@ """ } - let metadata = try await userDatabase.userRead { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "reminders", - recordName: "1:reminders", - parentRecordPrimaryKey: "1", - parentRecordType: "remindersLists", - parentRecordName: "1:remindersLists", - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil - ), - _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Write blog post" - ), - share: nil, - _isDeleted: false, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ), - [1]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "remindersLists", - recordName: "1:remindersLists", - parentRecordPrimaryKey: nil, - parentRecordType: nil, - parentRecordName: nil, - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil - ), - _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - share: nil, - _isDeleted: false, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] + ┌────────────────────┐ + │ "1:reminders" │ + │ "1:remindersLists" │ + └────────────────────┘ """ } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 69a2dbf9..6853cc5c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -12,7 +12,7 @@ final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try await userDatabase.userRead { db in + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -393,15 +393,15 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func resetUp() async throws { - let recordTypes = try await userDatabase.userRead { db in + @Test func reSetUp() async throws { + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } syncEngine.stop() try syncEngine.tearDownSyncEngine() try syncEngine.setUpSyncEngine() try await syncEngine.start() - let recordTypesAfterReSetup = try await userDatabase.userRead { db in + let recordTypesAfterReSetup = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) @@ -409,7 +409,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func migration() async throws { - let recordTypes = try await userDatabase.userRead { db in + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.order(by: \.tableName).fetchAll(db) } syncEngine.stop() @@ -425,7 +425,7 @@ try syncEngine.setUpSyncEngine() try await syncEngine.start() - let recordTypesAfterMigration = try await userDatabase.userRead { db in + let recordTypesAfterMigration = try await syncEngine.metadatabase.read { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index a43fe6da..d6d63194 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import GRDB import InlineSnapshotTesting @@ -283,45 +284,42 @@ """ } - let metadata = try await userDatabase.read { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { - """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "remindersLists", - recordName: "1:remindersLists", - parentRecordPrimaryKey: nil, - parentRecordType: nil, - parentRecordName: nil, - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)) - ), - _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), - id: 1, - isCompleted: 0, - title: "Personal" - ), - share: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - _isDeleted: false, - isShared: true, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] + assertQuery(SyncMetadata.order(by: \.recordName), database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), │ + │ id: 1, │ + │ isCompleted: 0, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ isShared: true, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ """ } } @@ -474,15 +472,19 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - try await userDatabase.read { db in - let metadata = try #require( - try SyncMetadata - .where { $0.recordPrimaryKey.eq("1") } - .fetchOne(db) - ) - #expect(metadata.share?.recordID == sharedRecord.share.recordID) + assertQuery(SyncMetadata.select(\.share), database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────────────────────┐ + │ CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ) │ + └────────────────────────────────────────────────────────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -635,18 +637,17 @@ ) ) - try await userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - let metadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } - .fetchOne(db) - ) - #expect(remindersList.title == "Personal") - #expect( - metadata.share?.recordID.recordName - == "share-\(remindersListRecord.recordID.recordName)" - ) + assertQuery(SyncMetadata.select(\.share), database: syncEngine.metadatabase) { + """ + ┌───────────────────────────────────────────────────────────────────────────────┐ + │ CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ) │ + └───────────────────────────────────────────────────────────────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -722,13 +723,16 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await userDatabase.read { db in - let metadata = try #require( - try SyncMetadata - .where { $0.recordName.eq("1:reminders") } - .fetchOne(db) - ) - #expect(metadata.parentRecordName == "1:remindersLists") + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "1:reminders" │ "1:remindersLists" │ + └────────────────────┴────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -859,13 +863,12 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await userDatabase.read { db in - let share = - try SyncMetadata - .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } - .select(\.share) - .fetchOne(db) - #expect(share == .none) + assertQuery( + SyncMetadata.select { ($0.recordName, $0.share) }, + database: syncEngine.metadatabase + ) { + """ + """ } assertInlineSnapshot(of: container, as: .customDump) { diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 31efb4e2..24d6f633 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -2,6 +2,7 @@ import CloudKit import DependenciesTestSupport import InlineSnapshotTesting + import SQLiteDataTestSupport import OrderedCollections import SQLiteData import SnapshotTesting @@ -28,18 +29,43 @@ } } - try await Task.sleep(for: .seconds(0.5)) - - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) + try await Task.sleep(for: .seconds(1)) - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └────────────────────────────────────────────────────────┘ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -113,7 +139,7 @@ try RemindersList.find(1).delete().execute(db) } - try await Task.sleep(for: .seconds(0.5)) + try await Task.sleep(for: .seconds(1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -155,44 +181,56 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title += "!" }.execute(db) } - try await Task.sleep(for: .seconds(0.5)) + } + try await Task.sleep(for: .seconds(0.5)) - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) - try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") - } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ + │ PendingRecordZoneChange( │ + │ pendingRecordZoneChange: .saveRecord(CKRecord.ID(1:remindersLists/zone/__defaultOwner__)) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌──────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal!" │ + │ ) │ + └──────────────────────┘ + """ + } - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: 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: "Personal!" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) - } + assertInlineSnapshot(of: 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: "Personal!" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -292,6 +330,7 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -374,36 +413,58 @@ """ } } - } - - @MainActor - final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked - Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init(startImmediately: false) - } + // * Start with sync engine off + // * Write a few rows + // * Verify sync metadata is created. + // * Verify cloud data is still empty + // * Start sync engine + // * Verify that data is sent to CloudKit database and cached locally. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeAndThenStart() async throws { + @Test(.startImmediately(false)) func writeAndThenStart() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") Reminder(id: 1, title: "Get milk", remindersListID: 1) } } + try await Task.sleep(for: .seconds(1)) - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) - - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └────────────────────────────────────────────────────────┘ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -424,6 +485,67 @@ try await syncEngine.processPendingDatabaseChanges(scope: .private) try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Get milk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index 6d90cbfb..af67d365 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -6,7 +6,10 @@ @Suite struct UserlandTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { - let database = try SQLiteDataTests.database(containerIdentifier: "tests") + let database = try SQLiteDataTests.database( + containerIdentifier: "tests", + attachMetadatabase: false + ) let syncEngine = try SyncEngine( for: database, tables: ModelA.self, diff --git a/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift b/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift deleted file mode 100644 index a1683928..00000000 --- a/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift +++ /dev/null @@ -1,30 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - import Testing - - struct _AccountStatusScope: SuiteTrait, TestScoping, TestTrait { - @TaskLocal static var accountStatus = CKAccountStatus.available - - let accountStatus: CKAccountStatus - init(_ accountStatus: CKAccountStatus = .available) { - self.accountStatus = accountStatus - } - - func provideScope( - for test: Test, - testCase: Test.Case?, - performing function: @Sendable () async throws -> Void - ) async throws { - try await Self.$accountStatus.withValue(accountStatus) { - try await function() - } - } - } - - extension Trait where Self == _AccountStatusScope { - static var accountStatus: Self { .init() } - static func accountStatus(_ accountStatus: CKAccountStatus) -> Self { - .init(accountStatus) - } - } -#endif diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 198145d7..659acd77 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -31,21 +31,20 @@ class BaseCloudKitTests: @unchecked Sendable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init( - accountStatus: CKAccountStatus = _AccountStatusScope.accountStatus, - setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in }, - startImmediately: Bool = true - ) async throws { + init() async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" self.userDatabase = UserDatabase( - database: try SQLiteDataTests.database(containerIdentifier: testContainerIdentifier) + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase + ) ) - try await setUpUserDatabase(userDatabase) + try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) let container = MockCloudContainer( - accountStatus: accountStatus, + accountStatus: _AccountStatusScope.accountStatus, containerIdentifier: testContainerIdentifier, privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase @@ -72,9 +71,11 @@ class BaseCloudKitTests: @unchecked Sendable { privateTables: [ RemindersListPrivate.self ], - startImmediately: startImmediately + startImmediately: _StartImmediatelyTrait.startImmediately ) - if startImmediately, accountStatus == .available { + if _StartImmediatelyTrait.startImmediately, + _AccountStatusScope.accountStatus == .available + { await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index d1bc07b9..92614e21 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -73,10 +73,15 @@ import SQLiteData } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -func database(containerIdentifier: String) throws -> DatabasePool { +func database( + containerIdentifier: String, + attachMetadatabase: Bool +) throws -> DatabasePool { var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: containerIdentifier) + if attachMetadatabase { + try db.attachMetadatabase(containerIdentifier: containerIdentifier) + } } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) diff --git a/Tests/SQLiteDataTests/Internal/TestScopes.swift b/Tests/SQLiteDataTests/Internal/TestScopes.swift new file mode 100644 index 00000000..42ab482d --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/TestScopes.swift @@ -0,0 +1,104 @@ +#if canImport(CloudKit) + import CloudKit + import Testing + import SQLiteData + + struct _PrepareDatabaseTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var prepareDatabase: @Sendable (UserDatabase) async throws -> Void = + { _ in } + let prepareDatabase: @Sendable (UserDatabase) async throws -> Void + init(prepareDatabase: @escaping @Sendable (UserDatabase) async throws -> Void = { _ in }) { + self.prepareDatabase = prepareDatabase + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$prepareDatabase.withValue(prepareDatabase) { + try await function() + } + } + } + + extension Trait where Self == _PrepareDatabaseTrait { + static func prepareDatabase( + _ prepareDatabase: @escaping @Sendable (UserDatabase) async throws -> Void + ) -> Self { + .init(prepareDatabase: prepareDatabase) + } + } + + struct _StartImmediatelyTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var startImmediately = true + let startImmediately: Bool + init(startImmediately: Bool = true) { + self.startImmediately = startImmediately + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$startImmediately.withValue(startImmediately) { + try await function() + } + } + } + + extension Trait where Self == _StartImmediatelyTrait { + static func startImmediately(_ startImmediately: Bool) -> Self { + .init(startImmediately: startImmediately) + } + } + + struct _AttachMetadatabaseTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var attachMetadatabase = false + let attachMetadatabase: Bool + init(attachMetadatabase: Bool = false) { + self.attachMetadatabase = attachMetadatabase + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$attachMetadatabase.withValue(attachMetadatabase) { + try await function() + } + } + } + + extension Trait where Self == _AttachMetadatabaseTrait { + static var attachMetadatabase: Self { .init(attachMetadatabase: true) } + static func attachMetadatabase(_ attachMetadatabase: Bool) -> Self { + .init(attachMetadatabase: attachMetadatabase) + } + } + + struct _AccountStatusScope: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var accountStatus = CKAccountStatus.available + + let accountStatus: CKAccountStatus + init(_ accountStatus: CKAccountStatus = .available) { + self.accountStatus = accountStatus + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await Self.$accountStatus.withValue(accountStatus) { + try await function() + } + } + } + + extension Trait where Self == _AccountStatusScope { + static var accountStatus: Self { .init() } + static func accountStatus(_ accountStatus: CKAccountStatus) -> Self { + .init(accountStatus) + } + } +#endif