diff --git a/Package.swift b/Package.swift index 0d607e9d..503f61c1 100644 --- a/Package.swift +++ b/Package.swift @@ -82,6 +82,10 @@ let package = Package( .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), + ], + resources: [ + .copy("Resources/test-black.svg"), + .copy("Resources/test-red.svg") ] ), ], diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 6e9eacd9..fc0422b3 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -142,6 +142,10 @@ get { self["\(key)_hash"] as? Data } set { self["\(key)_hash"] = newValue } } + fileprivate subscript(data key: String) -> Data? { + get { self["\(key)_data"] as? Data } + set { self["\(key)_data"] = newValue } + } } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @@ -178,6 +182,9 @@ encryptedValues[hash: key] != hash else { return false } + if encryptedValues[data: key] != nil { + encryptedValues[data: key] = nil + } self[key] = newValue encryptedValues[hash: key] = hash encryptedValues[at: key] = userModificationTime @@ -194,6 +201,22 @@ guard encryptedValues[at: key] <= userModificationTime else { return false } + if newValue.isSmall { + let newData = Data(newValue) + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[data: key] != newData + else { return false } + if self[key] != nil { + self[key] = nil + encryptedValues[hash: key] = nil + } + encryptedValues[data: key] = newData + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + @Dependency(\.dataManager) var dataManager let hash = newValue.sha256 let fileURL = dataManager.temporaryDirectory.appending( @@ -301,7 +324,13 @@ var isRowValueModified: Bool { switch Value(queryOutput: row[keyPath: keyPath]).queryBinding { case .blob(let value): - return other.encryptedValues[hash: key] != value.sha256 + if value.isSmall { + return other.encryptedValues[key] != Data(value) + } else if let otherHash = other.encryptedValues[hash: key] { + return otherHash != value.sha256 + } else { + return true + } case .bool(let value): return other.encryptedValues[key] != value case .double(let value): @@ -357,6 +386,8 @@ return value.queryFragment } else if let value = self as? Date { return value.queryFragment + } else if let value = self as? [UInt8] { + return value.queryFragment } else { return "\(.invalid(Unbindable()))" } @@ -376,5 +407,10 @@ fileprivate var sha256: Data { Data(SHA256.hash(data: self)) } + + fileprivate var isSmall: Bool { + count * MemoryLayout.stride < 16384 + } } + #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift index 7744f34b..1d400a58 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -14,17 +14,20 @@ final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { - """ + #""" MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, @@ -37,8 +40,94 @@ coverImage_hash: Data(32 bytes), remindersListID: 1, coverImage: CKAsset( - fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), - dataString: "image" + fileURL: URL(file:///4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482), + dataString: """ + + + + + + + + + + + """ ) ), [1]: CKRecord( @@ -56,23 +145,26 @@ storage: [] ) ) - """ + """# } inMemoryDataManager.storage.withValue { storage in let url = URL( - string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + string: "file:///4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482" )! - #expect(storage[url] == Data("image".utf8)) + #expect(storage[url] == blackCoverImage) } + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) + try await withDependencies { $0.currentTime.now += 1 } operation: { try await userDatabase.userWrite { db in try RemindersListAsset .find(1) - .update { $0.coverImage = Data("new-image".utf8) } + .update { $0.coverImage = redCoverImage } .execute(db) } } @@ -80,7 +172,7 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { - """ + #""" MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, @@ -93,8 +185,94 @@ coverImage_hash: Data(32 bytes), remindersListID: 1, coverImage: CKAsset( - fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), - dataString: "new-image" + fileURL: URL(file:///43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f), + dataString: """ + + + + + + + + + + + """ ) ), [1]: CKRecord( @@ -112,14 +290,14 @@ storage: [] ) ) - """ + """# } inMemoryDataManager.storage.withValue { storage in let url = URL( - string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" + string: "file:///43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f" )! - #expect(storage[url] == Data("new-image".utf8)) + #expect(storage[url] == redCoverImage) } } @@ -127,6 +305,8 @@ // => Stored in database as bytes @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveAsset() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -135,7 +315,7 @@ remindersListRecord.setValue("Personal", forKey: "title", at: now) let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + try inMemoryDataManager.save(blackCoverImage, to: fileURL) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, recordID: RemindersListAsset.recordID(for: 1) @@ -158,7 +338,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("image".utf8)) + #expect(remindersListAsset.coverImage == blackCoverImage) } } @@ -166,19 +346,23 @@ // => Stored in database as bytes @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveUpdatedAsset() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) try await withDependencies { $0.currentTime.now += 1 } operation: { let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + try inMemoryDataManager.save(redCoverImage, to: fileURL) let remindersListAssetRecord = try syncEngine.private.database.record( for: RemindersListAsset.recordID(for: 1) ) @@ -198,7 +382,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + #expect(remindersListAsset.coverImage == redCoverImage) } } @@ -208,6 +392,8 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveAssetThenReceiveUpdate() async throws { do { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -216,7 +402,7 @@ remindersListRecord.setValue("Personal", forKey: "title", at: now) let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + try inMemoryDataManager.save(blackCoverImage, to: fileURL) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, recordID: RemindersListAsset.recordID(for: 1) @@ -240,11 +426,13 @@ .notify() } + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) try await withDependencies { $0.currentTime.now += 1 } operation: { let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + try inMemoryDataManager.save(redCoverImage, to: fileURL) let remindersListAssetRecord = try syncEngine.private.database.record( for: RemindersListAsset.recordID(for: 1) ) @@ -264,7 +452,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + #expect(remindersListAsset.coverImage == redCoverImage) } } @@ -273,6 +461,8 @@ // => Both records (and the image data) should be synchronized @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assetReceivedBeforeParentRecord() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -286,7 +476,7 @@ ) remindersListAssetRecord.setValue("1", forKey: "id", at: now) remindersListAssetRecord.setValue( - Array("image".utf8), + blackCoverImage, forKey: "coverImage", at: now ) @@ -320,12 +510,12 @@ } assertQuery(RemindersListAsset.all, database: userDatabase.database) { """ - ┌─────────────────────────────┐ - │ RemindersListAsset( │ - │ remindersListID: 1, │ - │ coverImage: Data(5 bytes) │ - │ ) │ - └─────────────────────────────┘ + ┌──────────────────────────────────┐ + │ RemindersListAsset( │ + │ remindersListID: 1, │ + │ coverImage: Data(16,811 bytes) │ + │ ) │ + └──────────────────────────────────┘ """ } diff --git a/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift new file mode 100644 index 00000000..6da43f38 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift @@ -0,0 +1,309 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class DataTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + coverImage_data: Data(5 bytes), + remindersListID: 1 + ), + [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 withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersListAsset + .find(1) + .update { $0.coverImage = Data("new-image".utf8) } + .execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + coverImage_data: Data(9 bytes), + remindersListID: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Receive record with CKAsset from CloudKit + // => Stored in database as bytes + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.disabled()) func receiveData() 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) + + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setValue(Data("image".utf8), forKey: "coverImage", at: now) + remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] + ) + .notify() + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("image".utf8)) + } + } + + // * Receive record with Data from CloudKit when local asset exists + // => Stored in database as bytes + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.disabled()) func receiveUpdatedData() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListAssetRecord = try syncEngine.private.database.record( + for: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue( + Data("new-image".utf8), + forKey: "coverImage", + at: now + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord] + ) + .notify() + } + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + } + } + + // * Receive record with CKAsset from CloudKit when local asset does not exist + // * Receive updated asset from CloudKit + // => Local database has freshest asset + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.disabled()) func receiveAssetThenReceiveUpdate() async throws { + do { + 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) + + let fileURL = URL(fileURLWithPath: UUID().uuidString) + try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setAsset( + CKAsset(fileURL: fileURL), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] + ) + .notify() + } + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let fileURL = URL(fileURLWithPath: UUID().uuidString) + try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + let remindersListAssetRecord = try syncEngine.private.database.record( + for: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setAsset( + CKAsset(fileURL: fileURL), + forKey: "coverImage", + at: now + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord] + ) + .notify() + } + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + } + } + + // * Client receives RemindersListAsset with image data + // * A moment later client receives the parent RemindersList + // => Both records (and the image data) should be synchronized + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.disabled()) func assetReceivedBeforeParentRecord() 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) + + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setValue( + Array("image".utf8), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue( + "1", + forKey: "remindersListID", + at: now + ) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListAssetRecord]) + .notify() + await remindersListModification.notify() + + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery(RemindersListAsset.all, database: userDatabase.database) { + """ + ┌─────────────────────────────┐ + │ RemindersListAsset( │ + │ remindersListID: 1, │ + │ coverImage: Data(5 bytes) │ + │ ) │ + └─────────────────────────────┘ + """ + } + + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/Resources/test-black.svg b/Tests/SQLiteDataTests/Resources/test-black.svg new file mode 100644 index 00000000..1f42b9bf --- /dev/null +++ b/Tests/SQLiteDataTests/Resources/test-black.svg @@ -0,0 +1,85 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Tests/SQLiteDataTests/Resources/test-red.svg b/Tests/SQLiteDataTests/Resources/test-red.svg new file mode 100644 index 00000000..dc16c928 --- /dev/null +++ b/Tests/SQLiteDataTests/Resources/test-red.svg @@ -0,0 +1,85 @@ + + + + + + + + + + \ No newline at end of file