diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2f399dee..25fda7cf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -27,7 +27,7 @@ body: required: false - label: If possible, I've reproduced the issue using the `main` branch of this package. required: false - - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/sharing-grdb/issues) or [discussion](https://github.com/pointfreeco/sharing-grdb/discussions). + - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/sqlite-data/issues) or [discussion](https://github.com/pointfreeco/sqlite-data/discussions). required: true - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index dfb8aece..57096a8f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,14 +2,14 @@ blank_issues_enabled: false contact_links: - name: Project Discussion - url: https://github.com/pointfreeco/sharing-grdb/discussions - about: SharingGRDB Q&A, ideas, and more + url: https://github.com/pointfreeco/sqlite-data/discussions + about: SQLiteData Q&A, ideas, and more - name: Documentation - url: https://pointfreeco.github.io/sharing-grdb/main/documentation/sharinggrdb/ - about: Read Sharing's documentation + url: https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedata + about: Read SQLiteData's documentation - name: Videos url: https://www.pointfree.co/ - about: Watch videos to get a behind-the-scenes look at how SharingGRDB was motivated and built + about: Watch videos to get a behind-the-scenes look at how SQLiteData was motivated and built - name: Slack url: https://www.pointfree.co/slack-invite about: Community chat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ddf84e..fc82c84d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,13 @@ jobs: config: ['debug'] scheme: ['Reminders', 'CaseStudies', 'SyncUps'] runs-on: macos-15 + continue-on-error: true steps: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: List devices available + run: xcrun simctl list --json devices available 'iPhone' - name: xcodebuild ${{ matrix.scheme }} run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw - name: Output test failures diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45dce330..030e709c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,14 +17,14 @@ jobs: env: INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} with: - text: sharing-grdb ${{ github.event.release.tag_name }} has been released. + text: sqlite-data ${{ github.event.release.tag_name }} has been released. blocks: | [ { "type": "header", "text": { "type": "plain_text", - "text": "sharing-grdb ${{ github.event.release.tag_name}}" + "text": "sqlite-data ${{ github.event.release.tag_name}}" } }, { @@ -56,14 +56,14 @@ jobs: env: INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} with: - text: sharing-grdb ${{ github.event.release.tag_name }} has been released. + text: sqlite-data ${{ github.event.release.tag_name }} has been released. blocks: | [ { "type": "header", "text": { "type": "plain_text", - "text": "sharing-grdb ${{ github.event.release.tag_name}}" + "text": "sqlite-data ${{ github.event.release.tag_name}}" } }, { diff --git a/.gitignore b/.gitignore index 48a51863..d6a7bb00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ xcuserdata/ DerivedData/ .swiftpm .netrc +*.orig *.sqlite *.xcresult diff --git a/.spi.yml b/.spi.yml index 7bfee293..1b1003ec 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,8 +2,6 @@ version: 1 builder: configs: - documentation_targets: - - SharingGRDBCore - - SharingGRDB - - StructuredQueriesGRDB - - StructuredQueriesGRDBCore + - SQLiteData custom_documentation_parameters: [--enable-experimental-overloaded-symbol-presentation] + platform: ios diff --git a/Examples/CaseStudies/Animations.swift b/Examples/CaseStudies/Animations.swift index d5380c34..36fc160a 100644 --- a/Examples/CaseStudies/Animations.swift +++ b/Examples/CaseStudies/Animations.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct AnimationsCaseStudy: SwiftUICaseStudy { @@ -60,7 +60,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/App.swift b/Examples/CaseStudies/App.swift index 6463b491..160275c3 100644 --- a/Examples/CaseStudies/App.swift +++ b/Examples/CaseStudies/App.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI @main diff --git a/Examples/CaseStudies/DynamicQuery.swift b/Examples/CaseStudies/DynamicQuery.swift index dd2d5f40..68d93ca9 100644 --- a/Examples/CaseStudies/DynamicQuery.swift +++ b/Examples/CaseStudies/DynamicQuery.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct DynamicQueryDemo: SwiftUICaseStudy { @@ -113,7 +113,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/ObservableModelDemo.swift b/Examples/CaseStudies/ObservableModelDemo.swift index a777fb64..cefc49f9 100644 --- a/Examples/CaseStudies/ObservableModelDemo.swift +++ b/Examples/CaseStudies/ObservableModelDemo.swift @@ -1,9 +1,9 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ObservableModelDemo: SwiftUICaseStudy { let readMe = """ - This demonstrates how to use the `fetchAll` and `fetchOne` tools in an @Observable model. \ + This demonstrates how to use the `@FetchAll` and `@FetchOne` tools in an @Observable model. \ In SwiftUI, the `@Query` macro only works when installed directly in a SwiftUI view, and \ cannot be used outside of views. @@ -100,7 +100,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/README.md b/Examples/CaseStudies/README.md index 2026a64f..4434a5b1 100644 --- a/Examples/CaseStudies/README.md +++ b/Examples/CaseStudies/README.md @@ -1,7 +1,7 @@ # Case Studies This project includes a number of digestible examples of how to solve common problems using -SharingGRDB, including: +SQLiteData, including: * [Animations](Animations.swift): Shows how to animate changes when the database changes and updates state in features. diff --git a/Examples/CaseStudies/SwiftDataTemplateDemo.swift b/Examples/CaseStudies/SwiftDataTemplateDemo.swift index 3d14347a..0663c38b 100644 --- a/Examples/CaseStudies/SwiftDataTemplateDemo.swift +++ b/Examples/CaseStudies/SwiftDataTemplateDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct SwiftDataTemplateView: SwiftUICaseStudy { @@ -71,7 +71,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "items" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/SwiftUIDemo.swift b/Examples/CaseStudies/SwiftUIDemo.swift index d4a12335..edd4d96d 100644 --- a/Examples/CaseStudies/SwiftUIDemo.swift +++ b/Examples/CaseStudies/SwiftUIDemo.swift @@ -1,11 +1,11 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct SwiftUIDemo: SwiftUICaseStudy { let readMe = """ - This demonstrates how to use the `fetchAll` and `fetchOne` queries directly in a SwiftUI view. \ - The tools listen for changes in the database so that when the table changes it automatically \ - updates state and re-renders the view. + This demonstrates how to use the `@FetchAll` and `@FetchOne` queries directly in a SwiftUI \ + view. The tools listen for changes in the database so that when the table changes it \ + automatically updates state and re-renders the view. You can also delete rows by swiping on a row and tapping the "Delete" button. """ @@ -69,7 +69,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/TransactionDemo.swift b/Examples/CaseStudies/TransactionDemo.swift index ddb629b7..bff3efd2 100644 --- a/Examples/CaseStudies/TransactionDemo.swift +++ b/Examples/CaseStudies/TransactionDemo.swift @@ -1,9 +1,9 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct TransactionDemo: SwiftUICaseStudy { let readMe = """ - This demonstrates how to use the `fetch` tool to perform multiple SQLite queries in a single \ + This demonstrates how to use the `@Fetch` tool to perform multiple SQLite queries in a single \ database transaction. If you need to fetch multiple pieces of data from the database that \ all tend to change together, then performing those queries in a single transaction can be \ more performant. @@ -85,7 +85,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CaseStudies/UIKitDemo.swift b/Examples/CaseStudies/UIKitDemo.swift index 88951175..6666e3c8 100644 --- a/Examples/CaseStudies/UIKitDemo.swift +++ b/Examples/CaseStudies/UIKitDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftNavigation import SwiftUI import UIKit @@ -126,7 +126,7 @@ extension DatabaseWriter where Self == DatabaseQueue { CREATE TABLE "facts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT NOT NULL - ) + ) STRICT """ ) .execute(db) diff --git a/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/Assets.xcassets/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/CloudKitDemo.entitlements b/Examples/CloudKitDemo/CloudKitDemo.entitlements new file mode 100644 index 00000000..d013bfc9 --- /dev/null +++ b/Examples/CloudKitDemo/CloudKitDemo.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.SQLiteData.CloudKitDemo + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift new file mode 100644 index 00000000..aef2e449 --- /dev/null +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -0,0 +1,63 @@ +import CloudKit +import SQLiteData +import SwiftUI + +@main +struct CloudKitDemoApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + init() { + try! prepareDependencies { + try $0.bootstrapDatabase() + } + } + + var body: some Scene { + WindowGroup { + NavigationStack { + CountersListView() + } + } + } +} + +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { return } + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift new file mode 100644 index 00000000..98b904e2 --- /dev/null +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -0,0 +1,109 @@ +import CloudKit +import SQLiteData +import SwiftUI +import SwiftUINavigation + +struct CountersListView: View { + @FetchAll var counters: [Counter] + @Dependency(\.defaultDatabase) var database + + var body: some View { + List { + if !counters.isEmpty { + Section { + ForEach(counters) { counter in + CounterRow(counter: counter) + .buttonStyle(.borderless) + } + .onDelete { indexSet in + deleteRows(at: indexSet) + } + } + } + } + .navigationTitle("Counters") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + withErrorReporting { + try database.write { db in + try Counter.insert { Counter.Draft() } + .execute(db) + } + } + } + } + } + } + + func deleteRows(at indexSet: IndexSet) { + withErrorReporting { + try database.write { db in + for index in indexSet { + try Counter.find(counters[index].id).delete() + .execute(db) + } + } + } + } +} + +struct CounterRow: View { + let counter: Counter + @State var sharedRecord: SharedRecord? + @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine + + var body: some View { + VStack { + HStack { + Text("\(counter.count)") + Button("-") { + decrementButtonTapped() + } + Button("+") { + incrementButtonTapped() + } + Spacer() + Button { + shareButtonTapped() + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } + } + + func shareButtonTapped() { + Task { + sharedRecord = try await syncEngine.share(record: counter) { share in + share[CKShare.SystemFieldKey.title] = "Join my counter!" + } + } + } + + func decrementButtonTapped() { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count -= 1 + } + .execute(db) + } + } + } + + func incrementButtonTapped() { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count += 1 + } + .execute(db) + } + } + } +} diff --git a/Examples/CloudKitDemo/Info.plist b/Examples/CloudKitDemo/Info.plist new file mode 100644 index 00000000..ca9a074a --- /dev/null +++ b/Examples/CloudKitDemo/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift new file mode 100644 index 00000000..0e73e575 --- /dev/null +++ b/Examples/CloudKitDemo/Schema.swift @@ -0,0 +1,46 @@ +import Foundation +import OSLog +import SQLiteData + +@Table +struct Counter: Identifiable { + let id: UUID + var count = 0 +} + +extension DependencyValues { + mutating func bootstrapDatabase() throws { + @Dependency(\.context) var context + let database = try SQLiteData.defaultDatabase() + logger.debug( + """ + App database + open "\(database.path)" + """ + ) + + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create tables") { db in + try #sql( + """ + CREATE TABLE "counters" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INT NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + } + try migrator.migrate(database) + defaultDatabase = database + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: Counter.self + ) + } +} + +private let logger = Logger(subsystem: "CloudKitDemo", category: "Database") diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index ac989a7a..2abda09e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; + CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2B2E71C472000974D3 /* SQLiteData */; }; + CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2D2E71C479000974D3 /* SQLiteData */; }; + CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2F2E71C480000974D3 /* SQLiteData */; }; CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */; }; CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; @@ -16,9 +20,6 @@ CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; - DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -47,6 +48,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CA2BDE272E71C42B000974D3 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/brandon/projects/sqlite-data"; sourceTree = ""; }; CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -57,6 +60,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CA2BDE332E71C578000974D3 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */; + }; CAD4819A2D584B510004799A /* Exceptions for "CaseStudies" folder in "CaseStudies" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -68,6 +78,7 @@ DCA44CFA2D5D9D1E008D4E76 /* Exceptions for "Reminders" folder in "Reminders" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Info.plist, README.md, ); target = CAF836D72D4735AB0047AEB5 /* Reminders */; @@ -75,6 +86,7 @@ DCA44CFB2D5D9D21008D4E76 /* Exceptions for "SyncUps" folder in "SyncUps" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Info.plist, README.md, ); target = DCBE89CB2D483FB90071F499 /* SyncUps */; @@ -82,6 +94,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CA2BDE332E71C578000974D3 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */, + ); + path = CloudKitDemo; + sourceTree = ""; + }; CA5E46972DEBFE410069E0F8 /* RemindersTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RemindersTests; @@ -124,6 +144,14 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + CA2BDD9A2E71C30B000974D3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA5E46932DEBFE410069E0F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -146,7 +174,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */, + CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */, CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -162,8 +190,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -174,7 +202,7 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */, + CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -192,6 +220,7 @@ CA5E46972DEBFE410069E0F8 /* RemindersTests */, DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, + CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -206,6 +235,7 @@ DCBE89CC2D483FB90071F499 /* SyncUps.app */, CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */, + CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */, ); name = Products; sourceTree = ""; @@ -213,6 +243,7 @@ CAF837022D4735C00047AEB5 /* Frameworks */ = { isa = PBXGroup; children = ( + CA2BDE272E71C42B000974D3 /* sqlite-data */, ); name = Frameworks; sourceTree = ""; @@ -220,6 +251,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA2BDDA72E71C30D000974D3 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */; + buildPhases = ( + CA2BDD992E71C30B000974D3 /* Sources */, + CA2BDD9A2E71C30B000974D3 /* Frameworks */, + CA2BDD9B2E71C30B000974D3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */, + ); + name = CloudKitDemo; + packageProductDependencies = ( + CA2BDE292E71C469000974D3 /* SQLiteData */, + ); + productName = CloudKitDemo; + productReference = CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */; + productType = "com.apple.product-type.application"; + }; CA5E46952DEBFE410069E0F8 /* RemindersTests */ = { isa = PBXNativeTarget; buildConfigurationList = CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */; @@ -288,7 +342,7 @@ name = CaseStudies; packageProductDependencies = ( CA2908C82D4AF70E003F165F /* UIKitNavigation */, - DCD9AC8A2E02176700FB20F8 /* SharingGRDB */, + CA2BDE2F2E71C480000974D3 /* SQLiteData */, ); productName = Examples; productReference = CAF836982D4735620047AEB5 /* CaseStudies.app */; @@ -336,7 +390,7 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, + CA2BDE2D2E71C479000974D3 /* SQLiteData */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -362,7 +416,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */, + CA2BDE2B2E71C472000974D3 /* SQLiteData */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -378,6 +432,9 @@ LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1620; TargetAttributes = { + CA2BDD9C2E71C30B000974D3 = { + CreatedOnToolsVersion = 16.4; + }; CA5E46952DEBFE410069E0F8 = { CreatedOnToolsVersion = 16.4; TestTargetID = CAF836D72D4735AB0047AEB5; @@ -415,7 +472,7 @@ DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */, + CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -428,11 +485,19 @@ CA5E46952DEBFE410069E0F8 /* RemindersTests */, DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, + CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + CA2BDD9B2E71C30B000974D3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA5E46942DEBFE410069E0F8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -478,6 +543,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + CA2BDD992E71C30B000974D3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA5E46922DEBFE410069E0F8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -541,6 +613,68 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + CA2BDDA52E71C30D000974D3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitDemo/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.CloudKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CA2BDDA62E71C30D000974D3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitDemo/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.CloudKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; CA5E469D2DEBFE420069E0F8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -823,11 +957,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -838,7 +975,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.RemindersDemo.Beta; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -850,11 +987,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -865,7 +1005,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.RemindersDemo.Beta; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -877,11 +1017,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SyncUps/SyncUps.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SyncUps/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SyncUps/Info.plist; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To transcribe meeting notes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -893,7 +1036,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUps; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.SyncUps; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -905,11 +1048,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SyncUps/SyncUps.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SyncUps/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SyncUps/Info.plist; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To transcribe meeting notes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -921,7 +1067,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUps; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.SyncUps; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -931,6 +1077,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CA2BDDA72E71C30D000974D3 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA2BDDA52E71C30D000974D3 /* Debug */, + CA2BDDA62E71C30D000974D3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -997,9 +1152,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { + CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../sharing-grdb"; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ @@ -1049,6 +1204,25 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CA2BDE292E71C469000974D3 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; + CA2BDE2B2E71C472000974D3 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; + productName = SQLiteData; + }; + CA2BDE2D2E71C479000974D3 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; + productName = SQLiteData; + }; + CA2BDE2F2E71C480000974D3 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; + productName = SQLiteData; + }; CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; @@ -1084,20 +1258,6 @@ package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; productName = CasePaths; }; - DCD9AC8A2E02176700FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - productName = SharingGRDB; - }; - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; - productName = SharingGRDB; - }; - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; - productName = SharingGRDB; - }; DCF267382D48437300B680BE /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7021d90a..744551e2 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5ded5ba49617fcf43253f921c393a9829acb4bd0620c1d273ad236940406de92", + "originHash" : "1a549785266ada7e3202edc79fe44d94e238bd6e6494c714e09a66c2d74bc59f", "pins" : [ { "identity" : "combine-schedulers", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", - "version" : "1.3.1" + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" } }, { @@ -69,8 +69,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", - "version" : "1.9.3" + "revision" : "a501eebe552fd23691c560adf474fca2169ad8aa", + "version" : "1.9.4" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" } }, { @@ -87,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "4e89284c1966538109dc783497405bc680e9bc96", - "version" : "2.4.0" + "revision" : "6b7f44d218e776bb7a5246efb940440d57c8b2cf", + "version" : "2.4.2" } }, { @@ -96,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "7d3509c7f4de78ad3eb3d804e036fb62e3585141", - "version" : "2.0.5" + "revision" : "52bc59a5c7c3f0420c6c31d643d55d64b3f8c013", + "version" : "2.0.7" } }, { @@ -105,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "bddb52233714512f63e0dfa8cd0ee8203103f3b1", - "version" : "2.7.1" + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" } }, { @@ -123,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "1b653aba57486afef66d8aafdbe83246249118fd", - "version" : "0.19.0" + "revision" : "f576582c138311e442c564bd7a893e950765e46a", + "version" : "0.19.1" } }, { @@ -136,15 +154,6 @@ "version" : "601.0.1" } }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Examples/README.md b/Examples/README.md index 4aa6fd4b..01accf0d 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -1,22 +1,32 @@ # Examples This directory holds many case studies and applications to demonstrate solving various problems -with [SharingGRDB](https://github.com/pointfreeco/sharing-grdb). Open the +with [SQLiteData](https://github.com/pointfreeco/sqlite-data). Open the `Examples.xcodeproj` to see all example projects in a single project. To work on each example app individually, select its scheme in Xcode. * **Case Studies** -
Demonstrates how to solve some common application problems in an isolated environment, in +
Demonstrates how to solve some common application problems in an isolated environment, in both SwiftUI and UIKit. Things like animations, dynamic queries, database transactions, and more. +* **CloudKitDemo** +
A simplified demo that shows how to synchronize a SQLite database to CloudKit and how to + share records with other iCloud users. See our dedicated articles on [CloudKit Synchronization] + and [CloudKit Sharing] for more information. + + [CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit + [CloudKit Sharing]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkitsharing + * **Reminders** -
A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to - model the reminders, lists and tags. It features many advanced queries, such as searching, - stats aggregation, and multi-table joins. +
A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to + model the reminders, lists and tags. It features many advanced queries, such as searching, stats + aggregation, and multi-table joins. It also features CloudKit synchronization and sharing. * **SyncUps** -
This application is a faithful reconstruction of one of Apple's more interesting sample +
This application is a faithful reconstruction of one of Apple's more interesting sample projects, called [Scrumdinger][scrumdinger], and uses SQLite to persist the data for meetings. + We have also added CloudKit synchronization so that all changes are automatically made available + on all of the user's devices. [scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 diff --git a/Examples/Reminders/Helpers.swift b/Examples/Reminders/Helpers.swift index 6b61b4e0..3cb6028f 100644 --- a/Examples/Reminders/Helpers.swift +++ b/Examples/Reminders/Helpers.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI extension Color { diff --git a/Examples/Reminders/Info.plist b/Examples/Reminders/Info.plist new file mode 100644 index 00000000..9ef96ef8 --- /dev/null +++ b/Examples/Reminders/Info.plist @@ -0,0 +1,12 @@ + + + + + CKSharingSupported + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/Reminders/README.md b/Examples/Reminders/README.md index 31e9c8d7..9ee145e4 100644 --- a/Examples/Reminders/README.md +++ b/Examples/Reminders/README.md @@ -10,4 +10,4 @@ comma-separated list of all of its tags. SQLite is an incredibly powerful langua not embrace abstractions that keep you from querying SQLite directly as SwiftData does. [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 -[tags-concat]: https://github.com/pointfreeco/sharing-grdb/blob/0391201992241f62e7bd10c8d1ece63b078c16ad/Examples/Reminders/RemindersListDetail.swift#L146-L147 +[tags-concat]: https://github.com/pointfreeco/sqlite-data/blob/0391201992241f62e7bd10c8d1ece63b078c16ad/Examples/Reminders/RemindersListDetail.swift#L146-L147 diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index c99afc6f..b7b70ed3 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -1,5 +1,5 @@ import IssueReporting -import SharingGRDB +import SQLiteData import SwiftUI struct ReminderFormView: View { @@ -91,11 +91,11 @@ struct ReminderFormView: View { } } Picker(selection: $reminder.priority) { - Text("None").tag(Priority?.none) + Text("None").tag(Reminder.Priority?.none) Divider() - Text("High").tag(Priority.high) - Text("Medium").tag(Priority.medium) - Text("Low").tag(Priority.low) + Text("High").tag(Reminder.Priority.high) + Text("Medium").tag(Reminder.Priority.medium) + Text("Low").tag(Reminder.Priority.low) } label: { HStack { Image(systemName: "exclamationmark.circle.fill") @@ -211,7 +211,7 @@ struct ReminderFormPreview: PreviewProvider { let remindersList = try RemindersList.all.fetchOne(db)! return ( remindersList, - try Reminder.where { $0.remindersListID == remindersList.id }.fetchOne(db)! + try Reminder.where { $0.remindersListID.eq(remindersList.id) }.fetchOne(db)! ) } } diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 0522c1e0..efe509ad 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ReminderRow: View { @@ -12,7 +12,6 @@ struct ReminderRow: View { let title: String? @State var editReminder: Reminder.Draft? - @State var isCompleted: Bool @Dependency(\.defaultDatabase) private var database @@ -34,14 +33,13 @@ struct ReminderRow: View { self.showCompleted = showCompleted self.tags = tags self.title = title - self.isCompleted = reminder.isCompleted } var body: some View { HStack { HStack(alignment: .firstTextBaseline) { Button(action: completeButtonTapped) { - Image(systemName: isCompleted ? "circle.inset.filled" : "circle") + Image(systemName: reminder.isCompleted ? "circle.inset.filled" : "circle") .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) @@ -59,7 +57,7 @@ struct ReminderRow: View { } } Spacer() - if !isCompleted { + if !reminder.isCompleted { HStack { if reminder.isFlagged { Image(systemName: "flag.fill") @@ -104,36 +102,15 @@ struct ReminderRow: View { .navigationTitle("Details") } } - .task(id: isCompleted) { - guard !showCompleted else { return } - guard - isCompleted, - isCompleted != reminder.isCompleted - else { return } - do { - try await Task.sleep(for: .seconds(2)) - toggleCompletion() - } catch {} - } } private func completeButtonTapped() { - if showCompleted { - toggleCompletion() - } else { - isCompleted.toggle() - } - } - - private func toggleCompletion() { withErrorReporting { try database.write { db in - isCompleted = - try Reminder + try Reminder .find(reminder.id) - .update { $0.isCompleted.toggle() } - .returning(\.isCompleted) - .fetchOne(db) ?? isCompleted + .update { $0.toggleStatus() } + .execute(db) } } } @@ -161,10 +138,10 @@ struct ReminderRow: View { HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) - .foregroundStyle(isCompleted ? .gray : remindersList.color) + .foregroundStyle(reminder.isCompleted ? .gray : remindersList.color) } highlight(title ?? reminder.title) - .foregroundStyle(isCompleted ? .gray : .primary) + .foregroundStyle(reminder.isCompleted ? .gray : .primary) } .font(.title3) } diff --git a/Examples/Reminders/Reminders.entitlements b/Examples/Reminders/Reminders.entitlements new file mode 100644 index 00000000..674bd5b6 --- /dev/null +++ b/Examples/Reminders/Reminders.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.SQLiteData.RemindersDemo.Beta + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index d5c55805..de1cefb8 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,15 +1,21 @@ -import SharingGRDB +import CloudKit +import Combine +import Dependencies +import SQLiteData +import SwiftData import SwiftUI +import UIKit @main struct RemindersApp: App { + @UIApplicationDelegateAdaptor var delegate: AppDelegate @Dependency(\.context) var context static let model = RemindersListsModel() init() { if context == .live { try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() } } } @@ -24,3 +30,53 @@ struct RemindersApp: App { } } } + +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 1f1ea51f..aee49d67 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,5 +1,7 @@ import CasePaths -import SharingGRDB +import CloudKit +import SQLiteData +import Sharing import SwiftUI import SwiftUINavigation @@ -7,13 +9,16 @@ import SwiftUINavigation @Observable class RemindersDetailModel: HashableObject { @ObservationIgnored @FetchAll var reminderRows: [Row] + @ObservationIgnored @FetchOne var coverImageData: Data? @ObservationIgnored @Shared var ordering: Ordering @ObservationIgnored @Shared var showCompleted: Bool let detailType: DetailType var isNewReminderSheetPresented = false + var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database + @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine init(detailType: DetailType) { self.detailType = detailType @@ -22,7 +27,15 @@ class RemindersDetailModel: HashableObject { wrappedValue: detailType == .completed, .appStorage("show_completed_list_\(detailType.id)") ) - _reminderRows = FetchAll(remindersQuery) + _reminderRows = FetchAll(remindersQuery, animation: .default) + if let remindersListID = detailType.remindersList?.id { + _coverImageData = FetchOne( + RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select(\.coverImage), + animation: .default + ) + } } func orderingButtonTapped(_ ordering: Ordering) async { @@ -59,6 +72,16 @@ class RemindersDetailModel: HashableObject { await updateQuery() } + func shareButtonTapped() async { + guard let remindersList = detailType.remindersList + else { return } + sharedRecord = await withErrorReporting { + try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = remindersList.title + } + } + } + private func updateQuery() async { await withErrorReporting { try await $reminderRows.load(remindersQuery, animation: .default) @@ -69,10 +92,16 @@ class RemindersDetailModel: HashableObject { Reminder .where { if !showCompleted { - !$0.isCompleted + $0.status.neq(Reminder.Status.completed) + } + } + .order { + if showCompleted { + $0.isCompleted + } else { + $0.status.eq(Reminder.Status.completed) } } - .order(by: \.isCompleted) .order { switch ordering { case .dueDate: $0.dueDate.asc(nulls: .last) @@ -152,15 +181,7 @@ struct RemindersDetailView: View { var body: some View { List { - VStack(alignment: .leading) { - GeometryReader { proxy in - Text(model.detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(model.detailType.color) - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowSeparator(.hidden) + header ForEach(model.reminderRows) { row in ReminderRow( color: model.detailType.color, @@ -193,6 +214,9 @@ struct RemindersDetailView: View { } } } + .sheet(item: $model.sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) @@ -219,37 +243,81 @@ struct RemindersDetailView: View { } } ToolbarItem(placement: .primaryAction) { - Menu { - Group { - Menu { - ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in - Button { - Task { await model.orderingButtonTapped(ordering) } - } label: { - Text(ordering.rawValue) - ordering.icon - } - } - } label: { - Text("Sort By") - Text(model.ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } + HStack(alignment: .firstTextBaseline) { + if model.detailType.is(\.remindersList) { Button { - Task { await model.showCompletedButtonTapped() } + Task { await model.shareButtonTapped() } } label: { - Text(model.showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") + Image(systemName: "square.and.arrow.up") } } - .tint(model.detailType.color) - } label: { - Image(systemName: "ellipsis.circle") + Menu { + Group { + Menu { + ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in + Button { + Task { await model.orderingButtonTapped(ordering) } + } label: { + Text(ordering.rawValue) + ordering.icon + } + } + } label: { + Text("Sort By") + Text(model.ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + Task { await model.showCompletedButtonTapped() } + } label: { + Text(model.showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") + } + } + .tint(model.detailType.color) + } label: { + Image(systemName: "ellipsis.circle") + } } } } .toolbarTitleDisplayMode(.inline) } + + @ViewBuilder + var header: some View { + if let coverImageData = model.coverImageData, let image = UIImage(data: coverImageData) { + ZStack { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(maxHeight: 200) + .clipped() + + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .padding() + .background(Color.black.opacity(0.6)) + .cornerRadius(10) + .padding() + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowInsets(EdgeInsets()) + } else { + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) + } + } } extension RemindersDetailModel.DetailType { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 7c9126d2..98d4a022 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,11 +1,15 @@ import IssueReporting -import SharingGRDB +import PhotosUI +import SQLiteData import SwiftUI struct RemindersListForm: View { @Dependency(\.defaultDatabase) private var database @State var remindersList: RemindersList.Draft + @State var coverImageData: Data? + @State var photosPickerItem: PhotosPickerItem? + @State private var isPhotoPickerPresented = false @Environment(\.dismiss) var dismiss init(remindersList: RemindersList.Draft) { @@ -27,15 +31,71 @@ struct RemindersListForm: View { .clipShape(.buttonBorder) } ColorPicker("Color", selection: $remindersList.color) + ZStack(alignment: .topTrailing) { + ZStack { + if let coverImageData, + let uiImage = UIImage(data: coverImageData) + { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(height: 150) + .clipped() + .cornerRadius(10) + } else { + Rectangle() + .fill(Color.secondary.opacity(0.1)) + .frame(height: 150) + .cornerRadius(10) + } + + Button("Select Cover Image") { + isPhotoPickerPresented = true + } + .padding() + .background(.ultraThinMaterial) + .clipShape(.capsule) + } + + if coverImageData != nil { + Button { + coverImageData = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Color.white) + .clipShape(Circle()) + } + .padding(8) + } + } + .buttonStyle(.plain) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button("Save") { - withErrorReporting { - try database.write { db in - try RemindersList.upsert { remindersList } + Task { [remindersList, coverImageData] in + await withErrorReporting { + try await database.write { db in + let remindersListID = + try RemindersList + .upsert { remindersList } + .returning(\.id) + .fetchOne(db) + guard let remindersListID + else { + reportIssue("No 'remindersListID'") + return + } + try RemindersListAsset.upsert { + RemindersListAsset.Draft( + remindersListID: remindersListID, + coverImage: coverImageData + ) + } .execute(db) + } } } dismiss() @@ -47,9 +107,54 @@ struct RemindersListForm: View { } } } + .photosPicker(isPresented: $isPhotoPickerPresented, selection: $photosPickerItem) + .onChange(of: photosPickerItem) { + Task { + await withErrorReporting { + if let photosPickerItem { + coverImageData = try await photosPickerItem.loadTransferable(type: Data.self) + .flatMap { resizedAndOptimizedImageData(from: $0) } + self.photosPickerItem = nil + } + } + } + } + .task { + guard let remindersListID = remindersList.id + else { return } + do { + coverImageData = try await database.read { db in + try RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select(\.coverImage) + .fetchOne(db) ?? nil + } + } catch is CancellationError { + } catch { + reportIssue(error) + } + } } } +func resizedAndOptimizedImageData(from data: Data, maxWidth: CGFloat = 1000) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + let originalSize = image.size + let scaleFactor = min(1, maxWidth / originalSize.width) + let newSize = CGSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1) + image.draw(in: CGRect(origin: .zero, size: newSize)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage?.jpegData(compressionQuality: 0.8) +} + struct RemindersListFormPreviews: PreviewProvider { static var previews: some View { let _ = try! prepareDependencies { diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 9efeeff2..c2778bf8 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -1,13 +1,16 @@ -import SharingGRDB +import CloudKit +import SQLiteData import SwiftUI struct RemindersListRow: View { let remindersCount: Int let remindersList: RemindersList + var share: CKShare? @State var editList: RemindersList? @Dependency(\.defaultDatabase) private var database + @Dependency(\.defaultSyncEngine) private var syncEngine var body: some View { HStack { @@ -17,7 +20,14 @@ struct RemindersListRow: View { .background( Color.white.clipShape(Circle()).padding(4) ) - Text(remindersList.title) + VStack(alignment: .leading, spacing: 4) { + Text(remindersList.title) + if let shareMessage { + Text(shareMessage) + .font(.footnote) + .foregroundStyle(Color.secondary) + } + } Spacer() Text("\(remindersCount)") .foregroundStyle(.gray) @@ -48,6 +58,26 @@ struct RemindersListRow: View { .presentationDetents([.medium]) } } + + var shareMessage: String? { + guard let share + else { return nil } + if share.owner == share.currentUserParticipant { + let participantNames = share.participants + .filter { $0 != share.currentUserParticipant } + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ") + if !participantNames.isEmpty { + return "Shared with \(participantNames)" + } else { + return "Shared" + } + } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { + return "Shared from \(ownerName)" + } else { + return nil + } + } } #Preview { diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index e6daf88c..56865fc2 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,4 +1,5 @@ -import SharingGRDB +import CloudKit +import SQLiteData import SwiftUI import SwiftUINavigation import TipKit @@ -12,8 +13,13 @@ class RemindersListsModel { .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } + .leftJoin(SyncMetadata.all) { $0.hasMetadata(in: $2) } .select { - ReminderListState.Columns(remindersCount: $1.id.count(), remindersList: $0) + ReminderListState.Columns( + remindersCount: $1.id.count(), + remindersList: $0, + share: $2.share + ) }, animation: .default ) @@ -35,7 +41,7 @@ class RemindersListsModel { Reminder.select { Stats.Columns( allCount: $0.count(filter: !$0.isCompleted), - flaggedCount: $0.count(filter: $0.isFlagged), + flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), scheduledCount: $0.count(filter: $0.isScheduled), todayCount: $0.count(filter: $0.isToday) ) @@ -72,6 +78,18 @@ class RemindersListsModel { ) } + func deleteTags(atOffsets offsets: IndexSet) { + withErrorReporting { + let tagTitles = offsets.map { tags[$0].title } + try database.write { db in + try Tag + .where { $0.title.in(tagTitles) } + .delete() + .execute(db) + } + } + } + func onAppear() { withErrorReporting { try Tips.configure() @@ -112,7 +130,7 @@ class RemindersListsModel { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = - rest + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } @@ -124,13 +142,13 @@ class RemindersListsModel { } #if DEBUG - func seedDatabaseButtonTapped() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabaseButtonTapped() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @CasePathable @@ -145,6 +163,8 @@ class RemindersListsModel { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList + @Column(as: CKShare?.SystemFieldsRepresentation.self) + var share: CKShare? } @Selection @@ -170,6 +190,7 @@ class RemindersListsModel { struct RemindersListsView: View { @Bindable var model: RemindersListsModel + @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { List { @@ -237,9 +258,11 @@ struct RemindersListsView: View { } label: { RemindersListRow( remindersCount: state.remindersCount, - remindersList: state.remindersList + remindersList: state.remindersList, + share: state.share ) } + .buttonStyle(.borderless) .foregroundStyle(.primary) } .onMove(perform: model.move(from:to:)) @@ -262,6 +285,9 @@ struct RemindersListsView: View { } .foregroundStyle(.primary) } + .onDelete { offsets in + model.deleteTags(atOffsets: offsets) + } } header: { Text("Tags") .font(.system(.title2, design: .rounded, weight: .bold)) @@ -279,7 +305,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .automatic) { Menu { Button { model.seedDatabaseButtonTapped() @@ -287,6 +313,20 @@ struct RemindersListsView: View { Text("Seed data") Image(systemName: "leaf") } + Button { + if syncEngine.isRunning { + syncEngine.stop() + } else { + Task { + await withErrorReporting { + try await syncEngine.start() + } + } + } + } label: { + Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") + Image(systemName: syncEngine.isRunning ? "stop" : "play") + } } label: { Image(systemName: "ellipsis.circle") } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 19dd5180..35a19fd5 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -2,8 +2,9 @@ import Dependencies import Foundation import IssueReporting import OSLog -import SharingGRDB +import SQLiteData import SwiftUI +import Synchronization @Table struct RemindersList: Hashable, Identifiable { @@ -20,16 +21,44 @@ struct RemindersList: Hashable, Identifiable { extension RemindersList.Draft: Identifiable {} @Table -struct Reminder: Codable, Equatable, Identifiable { +struct RemindersListAsset: Hashable, Identifiable { + @Column(primaryKey: true) + let remindersListID: RemindersList.ID + var coverImage: Data? + var id: RemindersList.ID { remindersListID } +} + +@Table +struct Reminder: Hashable, Identifiable { let id: UUID var dueDate: Date? - var isCompleted = false var isFlagged = false var notes = "" var position = 0 var priority: Priority? var remindersListID: RemindersList.ID + var status: Status = .incomplete var title = "" + var isCompleted: Bool { + status != .incomplete + } + enum Priority: Int, QueryBindable { + case low = 1 + case medium + case high + } + enum Status: Int, QueryBindable { + case completed = 1 + case completing = 2 + case incomplete = 0 + } +} +extension Updates { + mutating func toggleStatus() { + self.status = Case(self.status) + .when(Reminder.Status.incomplete, then: Reminder.Status.completing) + .else(Reminder.Status.incomplete) + } } extension Reminder.Draft: Identifiable {} @@ -41,12 +70,6 @@ struct Tag: Hashable, Identifiable { var id: String { title } } -enum Priority: Int, Codable, QueryBindable { - case low = 1 - case medium - case high -} - extension Reminder { static let incomplete = Self.where { !$0.isCompleted } static let withTags = group(by: \.id) @@ -55,6 +78,9 @@ extension Reminder { } extension Reminder.TableColumns { + var isCompleted: some QueryExpression { + status.neq(Reminder.Status.incomplete) + } var isPastDue: some QueryExpression { @Dependency(\.date.now) var now return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)") @@ -82,19 +108,34 @@ struct ReminderTag: Hashable, Identifiable { } @Table @Selection -struct ReminderText: StructuredQueriesSQLite.FTS5 { +struct ReminderText: FTS5 { let rowid: Int let title: String let notes: String let tags: String } +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} + func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context - let database: any DatabaseWriter var configuration = Configuration() configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in + try db.attachMetadatabase() + db.add(function: $handleReminderStatusUpdate) #if DEBUG db.trace(options: .profile) { if context == .live { @@ -105,16 +146,13 @@ func appDatabase() throws -> any DatabaseWriter { } #endif } - if context == .preview { - database = try DatabaseQueue(configuration: configuration) - } else { - let path = - context == .live - ? URL.documentsDirectory.appending(component: "db.sqlite").path() - : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } + let database = try SQLiteData.defaultDatabase(configuration: configuration) + logger.debug( + """ + App database: + open "\(database.path)" + """ + ) var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -125,9 +163,19 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "color" INTEGER NOT NULL DEFAULT \(raw: defaultListColor ?? 0), - "position" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL + "color" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT \(raw: defaultListColor ?? 0), + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "remindersListID" TEXT PRIMARY KEY NOT NULL + REFERENCES "remindersLists"("id") ON DELETE CASCADE, + "coverImage" BLOB ) STRICT """ ) @@ -137,13 +185,13 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT, - "position" INTEGER NOT NULL DEFAULT 0, + "isFlagged" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "notes" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, - "title" TEXT NOT NULL + "status" INTEGER NOT NULL DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ) @@ -182,61 +230,74 @@ func appDatabase() throws -> any DatabaseWriter { try migrator.migrate(database) try database.write { db in - try RemindersList.createTemporaryTrigger(after: .insert { new in - RemindersList - .find(new.id) - .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } - }) - .execute(db) - try Reminder.createTemporaryTrigger(after: .insert { new in - Reminder - .find(new.id) - .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } - }) + try RemindersList.createTemporaryTrigger( + after: .insert { new in + RemindersList + .find(new.id) + .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1 } } + } + ) .execute(db) - try RemindersList.createTemporaryTrigger(after: .delete { _ in - RemindersList.insert { - RemindersList.Draft( - color: RemindersList.defaultColor, - title: RemindersList.defaultTitle - ) + try Reminder.createTemporaryTrigger( + after: .insert { new in + Reminder + .find(new.id) + .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1 } } } - } when: { _ in - !RemindersList.exists() - }) + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .insert { new in - ReminderText.insert { - ReminderText.Columns( - rowid: new.rowid, - title: new.title, - notes: new.notes.replace("\n", " "), - tags: "" - ) + try RemindersList.createTemporaryTrigger( + after: .delete { _ in + RemindersList.insert { + RemindersList.Draft( + color: RemindersList.defaultColor, + title: RemindersList.defaultTitle + ) + } + } when: { _ in + !RemindersList.exists() } - }) + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .update { - ($0.title, $0.notes) - } forEachRow: { _, new in - ReminderText - .where { $0.rowid.eq(new.rowid) } - .update { - $0.title = new.title - $0.notes = new.notes.replace("\n", " ") + try Reminder.createTemporaryTrigger( + after: .insert { new in + ReminderText.insert { + ReminderText.Columns( + rowid: new.rowid, + title: new.title, + notes: new.notes.replace("\n", " "), + tags: "" + ) } - }) + } + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .delete { old in - ReminderText - .where { $0.rowid.eq(old.rowid) } - .delete() - }) + try Reminder.createTemporaryTrigger( + after: .update { + ($0.title, $0.notes) + } forEachRow: { _, new in + ReminderText + .where { $0.rowid.eq(new.rowid) } + .update { + $0.title = new.title + $0.notes = new.notes.replace("\n", " ") + } + } + ) + .execute(db) + + try Reminder.createTemporaryTrigger( + after: .delete { old in + ReminderText + .where { $0.rowid.eq(old.rowid) } + .delete() + } + ) .execute(db) func updateReminderTextTags( @@ -245,7 +306,8 @@ func appDatabase() throws -> any DatabaseWriter { ReminderText .where { $0.rowid.eq(Reminder.find(reminderID).select(\.rowid)) } .update { - $0.tags = ReminderTag + $0.tags = + ReminderTag .order(by: \.tagID) .where { $0.reminderID.eq(reminderID) } .join(Tag.all) { $0.tagID.eq($1.primaryKey) } @@ -253,17 +315,32 @@ func appDatabase() throws -> any DatabaseWriter { } } - try ReminderTag.createTemporaryTrigger(after: .insert { new in - updateReminderTextTags(for: new.reminderID) - }) + try ReminderTag.createTemporaryTrigger( + after: .insert { new in + updateReminderTextTags(for: new.reminderID) + } + ) .execute(db) - try ReminderTag.createTemporaryTrigger(after: .delete { old in - updateReminderTextTags(for: old.reminderID) - }) + try ReminderTag.createTemporaryTrigger( + after: .delete { old in + updateReminderTextTags(for: old.reminderID) + } + ) .execute(db) - if context == .preview { + try Reminder.createTemporaryTrigger( + after: .update { + $0.status + } forEachRow: { _, _ in + Values($handleReminderStatusUpdate()) + } when: { _, new in + new.status.eq(Reminder.Status.completing) + } + ) + .execute(db) + + if context != .live { try db.seedSampleData() } } @@ -271,6 +348,25 @@ func appDatabase() throws -> any DatabaseWriter { return database } +let reminderStatusMutex = Mutex?>(nil) +@DatabaseFunction +func handleReminderStatusUpdate() { + reminderStatusMutex.withLock { + $0?.cancel() + $0 = Task { + @Dependency(\.defaultDatabase) var database + @Dependency(\.continuousClock) var clock + try await clock.sleep(for: .seconds(5)) + try await database.write { db in + try Reminder + .where { $0.status.eq(Reminder.Status.completing) } + .update { $0.status = .completed } + .execute(db) + } + } + } +} + private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG @@ -320,8 +416,8 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[3], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, remindersListID: remindersListIDs[0], + status: .completed, title: "Take a walk" ) Reminder( @@ -341,17 +437,17 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[6], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, priority: .low, remindersListID: remindersListIDs[1], + status: .completed, title: "Get laundry" ) Reminder( id: reminderIDs[7], dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, priority: .high, remindersListID: remindersListIDs[1], + status: .incomplete, title: "Take out trash" ) Reminder( @@ -368,16 +464,16 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[9], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, priority: .medium, remindersListID: remindersListIDs[2], + status: .completed, title: "Send weekly emails" ) Reminder( id: reminderIDs[10], dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - isCompleted: false, remindersListID: remindersListIDs[2], + status: .incomplete, title: "Prepare for WWDC" ) let tagIDs = ["car", "kids", "someday", "optional", "social", "night", "adulting"] diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index e2b3e14e..3055a5d4 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -1,5 +1,5 @@ import IssueReporting -import SharingGRDB +import SQLiteData import SwiftUI @MainActor @@ -7,14 +7,17 @@ import SwiftUI class SearchRemindersModel { var showCompletedInSearchResults = false { didSet { - searchTask = Task { try await updateQuery(debounce: false) } + if oldValue != showCompletedInSearchResults { + searchTask = Task { try await updateQuery(debounce: false) } + } } } var searchText = "" { didSet { if oldValue != searchText { - if searchText.hasSuffix("\t") { + guard !searchText.hasSuffix("\t") + else { searchTokens.append(Token(kind: .near, rawValue: String(searchText.dropLast()))) searchText = "" return @@ -44,16 +47,13 @@ class SearchRemindersModel { } @ObservationIgnored @Dependency(\.continuousClock) private var clock - @ObservationIgnored @Dependency(\.defaultDatabase) private var database @ObservationIgnored @Fetch var searchResults = SearchRequest.Value() - @ObservationIgnored @FetchAll(Tag.none) var tags func showCompletedButtonTapped() async throws { showCompletedInSearchResults.toggle() - try await updateQuery() } func tagButtonTapped(_ tag: Tag) { diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 42b126f5..37ae1e0b 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct TagRow: View { diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index ca5c9284..c40b777a 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,15 +1,25 @@ -import SharingGRDB +import SQLiteData import SwiftUI +import SwiftUINavigation struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() @Binding var selectedTags: [Tag] + @State var editingTag: Tag.Draft? + @State var tagTitle = "" + @Dependency(\.defaultDatabase) var database @Environment(\.dismiss) var dismiss var body: some View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) + Section { + Button("New tag") { + tagTitle = "" + editingTag = Tag.Draft() + } + } if !tags.top.isEmpty { Section { ForEach(tags.top, id: \.id) { tag in @@ -18,6 +28,14 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } header: { Text("Top tags") @@ -31,10 +49,26 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } } } + .alert(item: $editingTag) { item in + Text(item.title == nil ? "New tag" : "Edit tag") + } actions: { item in + TextField("Tag name", text: $tagTitle) + Button("Save") { + saveButtonTapped() + } + } .toolbar { ToolbarItem { Button("Done") { dismiss() } @@ -43,6 +77,39 @@ struct TagsView: View { .navigationTitle(Text("Tags")) } + func deleteButtonTapped(tag: Tag) { + withErrorReporting { + try database.write { db in + try Tag.find(tag.title).delete().execute(db) + } + } + } + + func editButtonTapped(tag: Tag) { + tagTitle = tag.title + editingTag = Tag.Draft(tag) + } + + func saveButtonTapped() { + defer { tagTitle = "" } + let tag = Tag(title: tagTitle) + withErrorReporting { + try database.write { db in + if let existingTagTitle = editingTag?.title { + selectedTags.removeAll(where: { $0.title == existingTagTitle }) + try Tag + .update { $0.title = tagTitle } + .where { $0.title.eq(existingTagTitle) } + .execute(db) + } else { + try Tag.insert(or: .ignore) { tag } + .execute(db) + } + } + selectedTags.append(tag) + } + } + struct Tags: FetchKeyRequest { func fetch(_ db: Database) throws -> Value { let top = @@ -84,7 +151,7 @@ private struct TagView: View { } label: { HStack { if isSelected { - Image.init(systemName: "checkmark") + Image(systemName: "checkmark") } Text(tag.title) } diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 35986dd8..b21916c3 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -1,6 +1,6 @@ import CustomDump import Foundation -import SharingGRDB +import SQLiteData import SwiftUI import Testing @@ -8,12 +8,9 @@ import Testing @Suite( .dependency(\.continuousClock, ImmediateClock()), - .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)), + .dependency(\.date.now, Date(timeIntervalSince1970: 1_234_567_890)), .dependency(\.uuid, .incrementing), - .dependencies { - $0.defaultDatabase = try Reminders.appDatabase() - try $0.defaultDatabase.write { try $0.seedSampleData() } - }, + .dependencies { try $0.bootstrapDatabase() }, .snapshots(record: .failed) ) struct BaseTestSuite {} @@ -27,7 +24,7 @@ extension RemindersList: @retroactive CustomDumpReflectable { "id": id, "color": Color.HexRepresentation(queryOutput: color).hexValue ?? 0, "position": position, - "title": title + "title": title, ], displayStyle: .struct ) diff --git a/Examples/RemindersTests/Reminders.xctestplan b/Examples/RemindersTests/Reminders.xctestplan new file mode 100644 index 00000000..8f339faf --- /dev/null +++ b/Examples/RemindersTests/Reminders.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "DD3C4DC0-99BB-4D11-A533-FA3986877845", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:Examples.xcodeproj", + "identifier" : "CAF836D72D4735AB0047AEB5", + "name" : "Reminders" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Examples.xcodeproj", + "identifier" : "CA5E46952DEBFE410069E0F8", + "name" : "RemindersTests" + } + } + ], + "version" : 1 +} diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index c4f9cd3d..5bec4b80 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -22,12 +22,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000004), dueDate: Date(2009-02-11T23:31:30.000Z), - isCompleted: false, isFlagged: true, notes: "", position: 2, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Haircut" ), remindersList: RemindersList( @@ -44,12 +44,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000005), dueDate: Date(2009-02-13T23:31:30.000Z), - isCompleted: false, isFlagged: false, notes: "Ask about diet", position: 3, priority: .high, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Doctor appointment" ), remindersList: RemindersList( @@ -66,12 +66,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000007), dueDate: Date(2009-02-13T23:31:30.000Z), - isCompleted: false, isFlagged: false, notes: "", position: 5, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Buy concert tickets" ), remindersList: RemindersList( @@ -88,7 +88,6 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000003), dueDate: nil, - isCompleted: false, isFlagged: false, notes: """ Milk @@ -100,6 +99,7 @@ extension BaseTestSuite { position: 1, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Groceries" ), remindersList: RemindersList( diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 45c3560d..d0fbb73b 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -24,7 +24,8 @@ extension BaseTestSuite { color: 1218047999, position: 1, title: "Personal" - ) + ), + share: nil ), [1]: RemindersListsModel.ReminderListState( remindersCount: 2, @@ -33,7 +34,8 @@ extension BaseTestSuite { color: 3985191935, position: 2, title: "Family" - ) + ), + share: nil ), [2]: RemindersListsModel.ReminderListState( remindersCount: 2, @@ -42,7 +44,8 @@ extension BaseTestSuite { color: 2992493567, position: 3, title: "Business" - ) + ), + share: nil ) ] """ diff --git a/Examples/SyncUpTests/Internal.swift b/Examples/SyncUpTests/Internal.swift index 1939607e..4db25057 100644 --- a/Examples/SyncUpTests/Internal.swift +++ b/Examples/SyncUpTests/Internal.swift @@ -1,10 +1,10 @@ import Foundation -import SharingGRDB +import SQLiteData @testable import SyncUps extension Database { - func seedSyncUpFormTests() throws { + func seed() throws { try seed { SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") diff --git a/Examples/SyncUpTests/SyncUpFormTests.swift b/Examples/SyncUpTests/SyncUpFormTests.swift index a1aa462a..cc383b36 100644 --- a/Examples/SyncUpTests/SyncUpFormTests.swift +++ b/Examples/SyncUpTests/SyncUpFormTests.swift @@ -9,8 +9,10 @@ import Testing @Suite( .dependencies { - $0.defaultDatabase = try! SyncUps.appDatabase() - try! $0.defaultDatabase.write { try $0.seedSyncUpFormTests() } + try $0.bootstrapDatabase() + try $0.defaultDatabase.write { db in + try db.seed() + } $0.uuid = .incrementing } ) diff --git a/Examples/SyncUps/App.swift b/Examples/SyncUps/App.swift index a3b6a0fb..608046a4 100644 --- a/Examples/SyncUps/App.swift +++ b/Examples/SyncUps/App.swift @@ -1,5 +1,5 @@ import CasePaths -import SharingGRDB +import SQLiteData import SwiftUI @MainActor @@ -77,8 +77,6 @@ struct AppView: View { } #Preview("Happy path") { - let _ = try! prepareDependencies { - $0.defaultDatabase = try SyncUps.appDatabase() - } + let _ = try! prepareDependencies { try $0.bootstrapDatabase() } AppView(model: AppModel()) } diff --git a/Examples/SyncUps/Info.plist b/Examples/SyncUps/Info.plist new file mode 100644 index 00000000..ca9a074a --- /dev/null +++ b/Examples/SyncUps/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/SyncUps/README.md b/Examples/SyncUps/README.md index 192a72cb..17902dca 100644 --- a/Examples/SyncUps/README.md +++ b/Examples/SyncUps/README.md @@ -1,6 +1,6 @@ -# SyncUpsGRDB +# SyncUps -A version of [SyncUps][] that persists its model data using SharingGRDB. +A version of [SyncUps][] that persists its model data using SQLiteData. SyncUps is a rebuild of Apple's [Scrumdinger][] demo application, but with a focus on modern, best practices for SwiftUI development. diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 2d66f05d..dfd07fdd 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -1,5 +1,5 @@ import OSLog -import SharingGRDB +import SQLiteData import SwiftUI @Table @@ -75,119 +75,98 @@ extension Int { } } -func appDatabase() throws -> any DatabaseWriter { - @Dependency(\.context) var context - let database: any DatabaseWriter - var configuration = Configuration() - configuration.foreignKeysEnabled = true - configuration.prepareDatabase { db in - #if DEBUG - db.trace(options: .profile) { - if context == .preview { - print("\($0.expandedDescription)") - } else { - logger.debug("\($0.expandedDescription)") - } - } - #endif - } - if context == .preview { - database = try DatabaseQueue(configuration: configuration) - } else { - let path = - context == .live - ? URL.documentsDirectory.appending(component: "db.sqlite").path() - : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } - var migrator = DatabaseMigrator() - #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true - #endif - migrator.registerMigration("Create initial tables") { db in - try #sql( +extension DependencyValues { + mutating func bootstrapDatabase() throws { + @Dependency(\.context) var context + let database = try SQLiteData.defaultDatabase() + logger.debug( """ - CREATE TABLE "syncUps" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "seconds" INTEGER NOT NULL DEFAULT 300, - "theme" TEXT NOT NULL DEFAULT \(raw: Theme.bubblegum.rawValue), - "title" TEXT NOT NULL - ) + App database: + open "\(database.path)" """ ) - .execute(db) - try #sql( - """ - CREATE TABLE "attendees" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "name" TEXT NOT NULL, - "syncUpID" INTEGER NOT NULL, - - FOREIGN KEY("syncUpID") REFERENCES "syncUps"("id") ON DELETE CASCADE + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create initial tables") { db in + try #sql( + """ + CREATE TABLE "syncUps" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "seconds" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 300, + "theme" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT \(raw: Theme.bubblegum.rawValue), + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ ) - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "meetings" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "date" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP UNIQUE, - "syncUpID" INTEGER NOT NULL, - "transcript" TEXT NOT NULL, - - FOREIGN KEY("syncUpID") REFERENCES "syncUps"("id") ON DELETE CASCADE + .execute(db) + try #sql( + """ + CREATE TABLE "attendees" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "syncUpID" TEXT NOT NULL REFERENCES "syncUps"("id") ON DELETE CASCADE + ) STRICT + """ ) - """ - ) - .execute(db) - } - - try migrator.migrate(database) - - if context == .preview { - try database.write { db in - try db.seedSampleData() + .execute(db) + try #sql( + """ + CREATE TABLE "meetings" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "date" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT CURRENT_TIMESTAMP, + "syncUpID" TEXT NOT NULL REFERENCES "syncUps"("id") ON DELETE CASCADE, + "transcript" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) } + try migrator.migrate(database) + defaultDatabase = database + defaultSyncEngine = try SyncEngine( + for: database, + tables: SyncUp.self, + Attendee.self, + Meeting.self + ) } - - return database } private let logger = Logger(subsystem: "SyncUps", category: "Database") #if DEBUG -extension Database { - func seedSampleData() throws { - try seed { - SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") + extension Database { + func seedSampleData() throws { + try seed { + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") + + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: UUID(1)) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(2)) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(3)) + } - for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: UUID(1)) - } - for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(2)) - } - for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(3)) + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: UUID(1), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) } - - Meeting.Draft( - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: UUID(1), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ - deserunt mollit anim id est laborum. - """ - ) } } -} #endif diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 93d344af..50accb52 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation @@ -272,7 +272,7 @@ struct MeetingView: View { #Preview { let syncUp = try! prepareDependencies { - $0.defaultDatabase = try SyncUps.appDatabase() + try $0.bootstrapDatabase() return try $0.defaultDatabase.read { db in try SyncUp.limit(1).fetchOne(db)! } diff --git a/Examples/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUpForm.swift index 369b61b7..1fd2b915 100644 --- a/Examples/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUpForm.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation @@ -76,11 +76,14 @@ final class SyncUpFormModel: Identifiable { } withErrorReporting { try database.write { db in - let syncUpID = try SyncUp.upsert(syncUp).returning(\.id).fetchOne(db)! + let syncUpID = try SyncUp.upsert { syncUp }.returning(\.id).fetchOne(db)! try Attendee.where { $0.syncUpID == syncUpID }.delete().execute(db) - try Attendee - .insert(attendees.map { Attendee.Draft(name: $0.name, syncUpID: syncUpID) }) - .execute(db) + try Attendee.insert { + for attendee in attendees { + Attendee.Draft(name: attendee.name, syncUpID: syncUpID) + } + } + .execute(db) } } isDismissed = true diff --git a/Examples/SyncUps/SyncUps.entitlements b/Examples/SyncUps/SyncUps.entitlements new file mode 100644 index 00000000..c54cf631 --- /dev/null +++ b/Examples/SyncUps/SyncUps.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.SQLiteData.SyncUps + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Examples/SyncUps/SyncUpsApp.swift b/Examples/SyncUps/SyncUpsApp.swift index 4f8135a4..83abea24 100644 --- a/Examples/SyncUps/SyncUpsApp.swift +++ b/Examples/SyncUps/SyncUpsApp.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI @main @@ -8,7 +8,7 @@ struct SyncUpsApp: App { init() { if !isTesting { try! prepareDependencies { - $0.defaultDatabase = try SyncUps.appDatabase() + try $0.bootstrapDatabase() } } } diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index 175deae6..85d4bf9d 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation import TipKit @@ -32,13 +32,13 @@ final class SyncUpsListModel { } #if DEBUG - func seedDatabase() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabase() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @Selection @@ -69,30 +69,30 @@ struct SyncUpsList: View { Image(systemName: "plus") } } -#if DEBUG - ToolbarItem(placement: .automatic) { - Menu { - Button { - model.seedDatabase() + #if DEBUG + ToolbarItem(placement: .automatic) { + Menu { + Button { + model.seedDatabase() + } label: { + Text("Seed data") + Image(systemName: "leaf") + } } label: { - Text("Seed data") - Image(systemName: "leaf") + Image(systemName: "ellipsis.circle") } - } label: { - Image(systemName: "ellipsis.circle") - } - .popoverTip(seedDatabaseTip) - .task { - await withErrorReporting { - try Tips.configure() - try await model.$syncUps.load() - if model.syncUps.isEmpty { - seedDatabaseTip = SeedDatabaseTip() + .popoverTip(seedDatabaseTip) + .task { + await withErrorReporting { + try Tips.configure() + try await model.$syncUps.load() + if model.syncUps.isEmpty { + seedDatabaseTip = SeedDatabaseTip() + } } } } - } -#endif + #endif } .navigationTitle("Daily Sync-ups") .sheet(item: $model.addSyncUp) { syncUpFormModel in @@ -153,7 +153,7 @@ private struct SeedDatabaseTip: Tip { #Preview { let _ = try! prepareDependencies { - $0.defaultDatabase = try SyncUps.appDatabase() + try $0.bootstrapDatabase() } NavigationStack { SyncUpsList(model: SyncUpsListModel()) diff --git a/Makefile b/Makefile index 5d1b0122..f80e8f17 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ XCODEBUILD_FLAGS = \ XCODEBUILD_COMMAND = xcodebuild $(XCODEBUILD_ARGUMENT) $(XCODEBUILD_FLAGS) -# TODO: Prefer 'xcbeautify --quiet' when this is fixed: +# NB: Prefer 'xcbeautify --quiet' when this is fixed: # https://github.com/cpisciotta/xcbeautify/issues/339 ifneq ($(strip $(shell which xcbeautify)),) XCODEBUILD = set -o pipefail && $(XCODEBUILD_COMMAND) | xcbeautify @@ -44,7 +44,11 @@ xcodebuild: warm-simulator xcodebuild-raw: warm-simulator $(XCODEBUILD_COMMAND) -.PHONY: warm-simulator xcodebuild xcodebuild-raw +format: + swift format . --recursive --in-place + find README.md Sources -name '*.md' -exec sed -i '' -e 's/ *$$//g' {} \; + +.PHONY: format warm-simulator xcodebuild xcodebuild-raw define udid_for $(shell xcrun simctl list --json devices available '$(1)' | jq -r '[.devices|to_entries|sort_by(.key)|reverse|.[].value|select(length > 0)|.[0]][0].udid') diff --git a/Package.resolved b/Package.resolved index bdb098a4..19053c54 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6dc59eead60386f2f139d8a3ccedd49d62321fc3156858a7ed2c64d5afe19528", + "originHash" : "32a0b9db128d9e5f29bd4495238ec304eafe64f2e75d45e792189c983fbdc49c", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "1b653aba57486afef66d8aafdbe83246249118fd", - "version" : "0.19.0" + "revision" : "f576582c138311e442c564bd7a893e950765e46a", + "version" : "0.19.1" } }, { diff --git a/Package.swift b/Package.swift index 37d333e8..73e0aeeb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription let package = Package( - name: "sharing-grdb", + name: "sqlite-data", platforms: [ .iOS(.v13), .macOS(.v10_15), @@ -12,92 +12,71 @@ let package = Package( ], products: [ .library( - name: "SharingGRDB", - targets: ["SharingGRDB"] + name: "SQLiteData", + targets: ["SQLiteData"] ), .library( - name: "SharingGRDBCore", - targets: ["SharingGRDBCore"] - ), - .library( - name: "SharingGRDBTestSupport", - targets: ["SharingGRDBTestSupport"] - ), - .library( - name: "StructuredQueriesGRDB", - targets: ["StructuredQueriesGRDB"] - ), - .library( - name: "StructuredQueriesGRDBCore", - targets: ["StructuredQueriesGRDBCore"] + name: "SQLiteDataTestSupport", + targets: ["SQLiteDataTestSupport"] ), ], + traits: [ + .trait( + name: "SQLiteDataTagged", + description: "Introduce SQLiteData conformances to the swift-tagged package." + ) + ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), - .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.19.0"), - ], - targets: [ - .target( - name: "SharingGRDB", - dependencies: [ - "SharingGRDBCore", - "StructuredQueriesGRDB", + .package( + url: "https://github.com/pointfreeco/swift-structured-queries", + from: "0.19.1", + traits: [ + .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] ), + .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), + ], + targets: [ .target( - name: "SharingGRDBCore", + name: "SQLiteData", dependencies: [ - "StructuredQueriesGRDBCore", + .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Sharing", package: "swift-sharing"), - ] - ), - .testTarget( - name: "SharingGRDBTests", - dependencies: [ - "SharingGRDB", - "SharingGRDBTestSupport", - .product(name: "DependenciesTestSupport", package: "swift-dependencies"), - .product(name: "StructuredQueries", package: "swift-structured-queries"), + .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), + .product( + name: "Tagged", + package: "swift-tagged", + condition: .when(traits: ["SQLiteDataTagged"]) + ), ] ), .target( - name: "SharingGRDBTestSupport", + name: "SQLiteDataTestSupport", dependencies: [ - "SharingGRDB", + "SQLiteData", .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), ] ), - .target( - name: "StructuredQueriesGRDBCore", - dependencies: [ - .product(name: "GRDB", package: "GRDB.swift"), - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLiteCore", package: "swift-structured-queries"), - ] - ), - .target( - name: "StructuredQueriesGRDB", - dependencies: [ - "StructuredQueriesGRDBCore", - .product(name: "StructuredQueries", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), - ] - ), .testTarget( - name: "StructuredQueriesGRDBTests", + name: "SQLiteDataTests", dependencies: [ - "StructuredQueriesGRDB", + "SQLiteData", + "SQLiteDataTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), @@ -106,7 +85,7 @@ let package = Package( ) let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("MemberImportVisibility") // .unsafeFlags([ // "-Xfrontend", // "-warn-long-function-bodies=50", diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 00000000..7f09a24d --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,88 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "sqlite-data", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v7), + ], + products: [ + .library( + name: "SQLiteData", + targets: ["SQLiteData"] + ), + .library( + name: "SQLiteDataTestSupport", + targets: ["SQLiteDataTestSupport"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.19.1"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), + ], + targets: [ + .target( + name: "SQLiteData", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Sharing", package: "swift-sharing"), + .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), + ] + ), + .target( + name: "SQLiteDataTestSupport", + dependencies: [ + "SQLiteData", + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), + ] + ), + .testTarget( + name: "SQLiteDataTests", + dependencies: [ + "SQLiteData", + "SQLiteDataTestSupport", + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + ], + swiftLanguageModes: [.v6] +) + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("MemberImportVisibility") + // .unsafeFlags([ + // "-Xfrontend", + // "-warn-long-function-bodies=50", + // "-Xfrontend", + // "-warn-long-expression-type-checking=50", + // ]) +] + +for index in package.targets.indices { + package.targets[index].swiftSettings = swiftSettings +} + +#if !os(Windows) + // Add the documentation compiler plugin if possible + package.dependencies.append( + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ) +#endif diff --git a/README.md b/README.md index d1a05455..f7a9e89e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ -> [!IMPORTANT] -> We are currently running a [public beta] to preview our upcoming CloudKit synchronization tools. Get all the details [here](https://www.pointfree.co/blog/posts/181-a-swiftdata-alternative-with-sqlite-cloudkit-public-beta) and let us know if you have any feedback! +# SQLiteData -[public beta]: https://github.com/pointfreeco/sharing-grdb/pull/112 +A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting +CloudKit synchronization. -# SharingGRDB - -A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL. - -[![CI](https://github.com/pointfreeco/sharing-grdb/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sharing-grdb/actions/workflows/ci.yml) +[![CI](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml) [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsqlite-data%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/sqlite-data) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsqlite-data%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/sqlite-data) * [Learn more](#Learn-more) * [Overview](#Overview) @@ -38,25 +34,26 @@ library, [subscribe today](https://www.pointfree.co/pricing). ## Overview -SharingGRDB is a [fast](#performance), lightweight replacement for SwiftData that deploys all the -way back to the iOS 13 generation of targets. To populate data from the database you can use -the `@FetchAll` property wrapper, which is similar to SwiftData's `@Query` macro: +SQLiteData is a [fast](#performance), lightweight replacement for SwiftData, including CloudKit +synchronization (and even CloudKit sharing) that deploys all the way back to the iOS 13 generation +of targets. To populate data from the database you can use `@Table` and @FetchAll`, which are +similar to SwiftData's `@Model` and `@Query`: - +
SharingGRDBSQLiteData SwiftData
- + ```swift @FetchAll var items: [Item] @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -93,22 +90,22 @@ class Item { Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SharingGRDB is powered directly by SQLite using [Sharing][], [StructuredQueries][], and -[GRDB][], and is usable from UIKit, `@Observable` models, and more. +but SQLiteData is powered directly by SQLite and is usable from UIKit, `@Observable` models, and +more. -For more information on SharingGRDB's querying capabilities, see +For more information on SQLiteData's querying capabilities, see [Fetching model data][fetching-article]. ## Quick start -Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide–at +Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide–at runtime–the default database it should use. This is typically done as early as possible in your app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage in SwiftData: - + @@ -120,7 +117,7 @@ struct MyApp: App { init() { prepareDependencies { let db = try! DatabaseQueue( - // Create/migrate a database + // Create/migrate a database // connection ) $0.defaultDatabase = db @@ -136,11 +133,11 @@ struct MyApp: App { ```swift @main struct MyApp: App { - let container = { + let container = { // Create/configure a container try! ModelContainer(/* ... */) }() - + var body: some Scene { WindowGroup { ContentView() @@ -158,13 +155,13 @@ struct MyApp: App { > For more information on preparing a SQLite database, see > [Preparing a SQLite database][preparing-db-article]. -This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like +This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like [`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs], which are similar to SwiftData's `@Query` macro, but more powerful:
SharingGRDBSQLiteData SwiftData
- + @@ -182,8 +179,11 @@ var items +@FetchAll(Item.order(by: \.isInStock)) +var items + @FetchOne(Item.count()) -var inStockItemsCount = 0 +var itemsCount = 0 ``` @@ -202,6 +202,9 @@ var items: [Item] }) var items: [Item] +// No @Query equivalent of ordering +// by boolean column. + // No @Query equivalent of counting // entries in database without loading // all entries. @@ -216,16 +219,16 @@ a model context, via a property wrapper:
SharingGRDBSQLiteData SwiftData
- +
SharingGRDBSQLiteData SwiftData
```swift -@Dependency(\.defaultDatabase) +@Dependency(\.defaultDatabase) var database - + let newItem = Item(/* ... */) try database.write { db in try Item.insert { newItem } @@ -237,9 +240,9 @@ try database.write { db in ```swift -@Environment(\.modelContext) +@Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -251,30 +254,55 @@ try modelContext.save()
> [!NOTE] -> For more information on how SharingGRDB compares to SwiftData, see +> For more information on how SQLiteData compares to SwiftData, see > [Comparison with SwiftData][comparison-swiftdata-article]. -This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read +Further, if you want to synchronize the local database to CloudKit so that it is available on +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: + +```swift +@main +struct MyApp: App { + init() { + prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = SyncEngine( + for: $0.defaultDatabase, + tables: Item.self + ) + } + } + // ... +} +``` + +> [!NOTE] +> For more information on synchronizing the database to CloudKit and sharing records with iCloud +> users, see [CloudKit Synchronization]. + +This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: * [Fetching model data][fetching-article] * [Observing changes to model data][observing-article] * [Preparing a SQLite database][preparing-db-article] * [Dynamic queries][dynamic-queries-article] + * [CloudKit Synchronization] * [Comparison with SwiftData][comparison-swiftdata-article] -[observing-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/observing -[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/dynamicqueries -[articles]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore#Essentials -[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/comparisonwithswiftdata -[fetching-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetching -[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/preparingdatabase -[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchall -[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchone +[observing-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/observing +[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/dynamicqueries +[articles]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata#Essentials +[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/comparisonwithswiftdata +[fetching-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetching +[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/preparingdatabase +[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit +[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchall +[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchone ## Performance -SharingGRDB leverages high-performance decoding from [StructuredQueries][] to turn fetched data into +SQLiteData leverages high-performance decoding from [StructuredQueries][] to turn fetched data into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly. See the following benchmarks against @@ -282,20 +310,22 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SharingGRDB (0.2.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +┌──────────────────────────────────────────────────────────────────┐ +│ SQLiteData (1.0.0) 0 0.172 8.511 │ +└──────────────────────────────────────────────────────────────────┘ + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required -SQLite is one of the -[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of +SQLite is one of the +[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of software in the history of software. Knowledge of SQLite is a great skill for any app developer to have, and this library does not want to conceal it from you. So, we feel that to best wield this library you should be familiar with the basics of SQLite, including schema design and normalization, @@ -306,7 +336,6 @@ for data and keep your views up-to-date when data in the database changes, and y [StructuredQueries][] to build queries, either using its type-safe, discoverable [query building APIs][], or using its `#sql` macro for writing [safe SQL strings][]. -[Sharing]: https://github.com/pointfreeco/swift-sharing [StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries [GRDB]: https://github.com/groue/GRDB.swift [query building APIs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore @@ -315,18 +344,30 @@ for data and keep your views up-to-date when data in the database changes, and y ## Demos This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with -Sharing. Check out [this](./Examples) directory to see them all, including: +SQLiteData. Check out [this](./Examples) directory to see them all, including: + +* [**Case Studies**](./Examples/CaseStudies) +
Demonstrates how to solve some common application problems in an isolated environment, in + both SwiftUI and UIKit. Things like animations, dynamic queries, database transactions, and more. + +* [**CloudKitDemo**](./Examples/CloudKitDemo) +
A simplified demo that shows how to synchronize a SQLite database to CloudKit and how to + share records with other iCloud users. See our dedicated articles on [CloudKit Synchronization] + and [CloudKit Sharing] for more information. + + [CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit + [CloudKit Sharing]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkitsharing - * [Case Studies](./Examples/CaseStudies): A number of case studies demonstrating the built-in - features of the library. +* [**Reminders**](./Examples/Reminders) +
A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to + model the reminders, lists and tags. It features many advanced queries, such as searching, stats + aggregation, and multi-table joins. It also features CloudKit synchronization and sharing. - * [SyncUps](./Examples/SyncUps): We also rebuilt Apple's [Scrumdinger][] demo application using - modern, best practices for SwiftUI development, including using this library to query and - persist state using SQLite. - - * [Reminders](./Examples/Reminders): A rebuild of Apple's [Reminders][reminders-app-store] app - that uses a SQLite database to model the reminders, lists and tags. It features many advanced - queries, such as searching, and stats aggregation. +* [**SyncUps**](./Examples/SyncUps) +
This application is a faithful reconstruction of one of Apple's more interesting sample + projects, called [Scrumdinger][scrumdinger], and uses SQLite to persist the data for meetings. + We have also added CloudKit synchronization so that all changes are automatically made available + on all of the user's devices. [Scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 @@ -335,51 +376,30 @@ Sharing. Check out [this](./Examples) directory to see them all, including: The documentation for releases and `main` are available here: - * [`main`](https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/) - * [0.x.x](https://swiftpackageindex.com/pointfreeco/sharing-grdb/~/documentation/sharinggrdbcore/) + * [`main`](https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/) + * [0.x.x](https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedata/) ## Installation -You can add SharingGRDB to an Xcode project by adding it to your project as a package… - -> https://github.com/pointfreeco/sharing-grdb - -…and adding the `SharingGRDB` product to your target. - -> [!TIP] -> SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the library's -> functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, and tools -> for driving StructuredQueries using GRDB. This is the module that most library users should depend -> on. -> -> If you are a library author that wishes to extend SharingGRDB with additional functionality, you -> may want to depend on a different module: -> -> * `SharingGRDBCore`: This product includes everything in `SharingGRDB` _except_ the macros -> (`@Table`, `#sql`, _etc._). This module can be imported to extend SharingGRDB with additional -> functionality without forcing the heavyweight dependency of SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the -> `@Fetch` family of property wrappers. It can be imported if you want to extend -> StructuredQueries' GRDB driver but do not need access to observation tools provided by -> Sharing. -> * `StructuredQueriesGRDBCore`: This product includes everything in `StructuredQueriesGRDB` -> _except_ the macros. This module can be imported to extend StructuredQueries' GRDB driver with -> additional functionality without forcing the heavyweight dependency of SwiftSyntax on your -> users. - -If you want to use SharingGRDB in a [SwiftPM](https://swift.org/package-manager/) project, it's as +You can add SQLiteData to an Xcode project by adding it to your project as a package… + +> https://github.com/pointfreeco/sqlite-data + +…and adding the `SQLiteData` product to your target. + +If you want to use SQLiteData in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to your `Package.swift`: ``` swift dependencies: [ - .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.6.0") + .package(url: "https://github.com/pointfreeco/sqlite-data", from: "1.0.0") ] ``` And then adding the following product to any target that needs access to the library: ```swift -.product(name: "SharingGRDB", package: "sharing-grdb"), +.product(name: "SQLiteData", package: "sqlite-data"), ``` ## Community @@ -389,7 +409,7 @@ problem, there are a number of places you can discuss with fellow [Point-Free](http://www.pointfree.co) enthusiasts: * For long-form discussions, we recommend the - [discussions](http://github.com/pointfreeco/sharing-grdb/discussions) tab of this repo. + [discussions](http://github.com/pointfreeco/sqlite-data/discussions) tab of this repo. * For casual chat, we recommend the [Point-Free Community Slack](http://www.pointfree.co/slack-invite). diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift new file mode 100644 index 00000000..6a34feb6 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -0,0 +1,358 @@ +#if canImport(CloudKit) + import CloudKit + import CryptoKit + import CustomDump + import StructuredQueriesCore + + extension _CKRecord where Self == CKRecord { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation + } + + extension _CKRecord where Self == CKShare { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation + } + + extension Optional where Wrapped: CKRecord { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation? + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? + } + + public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record + + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding + } + + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } + + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } + + public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record + + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encode(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding + } + + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } + + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } + + extension CKRecord: _CKRecord {} + + public protocol _CKRecord {} + + extension CKDatabase.Scope { + public struct RawValueRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKDatabase.Scope + public var queryBinding: QueryBinding { + queryOutput.rawValue.queryBinding + } + public init(queryOutput: CKDatabase.Scope) { + self.queryOutput = queryOutput + } + public init?(queryBinding: QueryBinding) { + guard case .int(let rawValue) = queryBinding else { return nil } + try? self.init(rawValue: Int(rawValue)) + } + public init(decoder: inout some QueryDecoder) throws { + try self.init(rawValue: Int(decoder: &decoder)) + } + private init(rawValue: Int) throws { + guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { + throw DecodingError() + } + self.init(queryOutput: queryOutput) + } + private struct DecodingError: Error {} + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecordKeyValueSetting { + subscript(at key: String) -> Int64 { + get { + self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1 + } + set { + self["\(CKRecord.userModificationTimeKey)_\(key)"] = max(self[at: key], newValue) + } + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension URL { + init(hash data: some DataProtocol) { + @Dependency(\.dataManager) var dataManager + let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined() + self = dataManager.temporaryDirectory.appendingPathComponent(hash) + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecord { + @TaskLocal static var fooo = false + + @discardableResult + package func setValue( + _ newValue: some CKRecordValueProtocol & Equatable, + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[key] != newValue + else { return false } + encryptedValues[key] = newValue + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + + @discardableResult + package func setValue( + _ newValue: [UInt8], + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + @Dependency(\.dataManager) var dataManager + + guard encryptedValues[at: key] <= userModificationTime + else { + return false + } + + let asset = CKAsset(fileURL: URL(hash: newValue)) + guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL + else { return false } + withErrorReporting(.sqliteDataCloudKitFailure) { + try dataManager.save(Data(newValue), to: fileURL) + } + self[key] = asset + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + + @discardableResult + package func removeValue( + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + guard encryptedValues[at: key] <= userModificationTime + else { + return false + } + if encryptedValues[key] != nil { + encryptedValues[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } else if self[key] != nil { + self[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + return false + } + + func update(with row: T, userModificationTime: Int64) { + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let column = column as! any WritableTableColumnExpression + let value = Value(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { + case .blob(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .bool(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .double(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .date(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .int(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .null: + removeValue(forKey: column.name, at: userModificationTime) + case .text(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .uint(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .uuid(let value): + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationTime + ) + case .invalid(let error): + reportIssue(error) + } + } + open(column) + } + } + + func update( + with other: CKRecord, + row: T, + columnNames: inout [String], + parentForeignKey: ForeignKey? + ) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + self.userModificationTime = other.userModificationTime + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let key = column.name + let column = column as! any WritableTableColumnExpression + let didSet: Bool + if let value = other[key] as? CKAsset { + didSet = setValue(value, forKey: key, at: other[at: key]) + } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil { + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) + } else { + didSet = false + } + /// The row value has been modified more recently than the last known record. + var isRowValueModified: Bool { + switch Value(queryOutput: row[keyPath: column.keyPath]).queryBinding { + case .blob(let value): + return (other[key] as? CKAsset)?.fileURL != URL(hash: value) + case .bool(let value): + return other.encryptedValues[key] != value + case .double(let value): + return other.encryptedValues[key] != value + case .date(let value): + return other.encryptedValues[key] != value + case .int(let value): + return other.encryptedValues[key] != value + case .null: + return other.encryptedValues[key] != nil + case .text(let value): + return other.encryptedValues[key] != value + case .uint(let value): + return other.encryptedValues[key] != value + case .uuid(let value): + return other.encryptedValues[key] != value.uuidString.lowercased() + case .invalid(let error): + reportIssue(error) + return false + } + } + if didSet || isRowValueModified { + columnNames.removeAll(where: { $0 == key }) + if didSet, let parentForeignKey, key == parentForeignKey.from { + self.parent = other.parent + } + } + } + open(column) + } + } + + package var userModificationTime: Int64 { + get { encryptedValues[Self.userModificationTimeKey] as? Int64 ?? -1 } + set { + encryptedValues[Self.userModificationTimeKey] = Swift.max(userModificationTime, newValue) + } + } + + package static let userModificationTimeKey = + "\(String.sqliteDataCloudKitSchemaName)_userModificationTime" + } + + extension __CKRecordObjCValue { + var queryFragment: QueryFragment { + if let value = self as? Int64 { + return value.queryFragment + } else if let value = self as? Double { + return value.queryFragment + } else if let value = self as? String { + return value.queryFragment + } else if let value = self as? Data { + return value.queryFragment + } else if let value = self as? Date { + return value.queryFragment + } else { + return "\(.invalid(Unbindable()))" + } + } + } + + private struct Unbindable: Error {} + + extension CKRecord { + package var _recordChangeTag: String? { + get { self[#function] } + set { self[#function] = newValue } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift new file mode 100644 index 00000000..1e2d25ec --- /dev/null +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -0,0 +1,309 @@ +#if canImport(CloudKit) + import CloudKit + import Dependencies + import SwiftUI + + #if canImport(UIKit) + import UIKit + #endif + + /// A shared record that can be used to present a ``CloudSharingView``. + /// + /// See for more information., + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + public struct SharedRecord: Hashable, Identifiable, Sendable { + let container: any CloudContainer + public let share: CKShare + + public var id: CKRecord.ID { share.recordID } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.container === rhs.container && lhs.share == rhs.share + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(container)) + hasher.combine(share) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + private struct SharingError: LocalizedError { + enum Reason { + case recordMetadataNotFound + case recordNotRoot([ForeignKey]) + case recordTableNotSynchronized + case recordTablePrivate + } + + let recordTableName: String + let recordPrimaryKey: String + let reason: Reason + let debugDescription: String + + var errorDescription: String? { + "The record could not be shared." + } + } + + /// Shares a record in CloudKit. + /// + /// This method will thrown an error if: + /// + /// * The table the `record` belongs to is not synchronized to CloudKit. + /// * The `record` has any foreign keys. Only root records are shareable in CloudKit. + /// * The table the `record` belongs to is a "private" table as determined by the + /// [`SyncEngine` initializer](). + /// * The `record` is being shared before it has been synchronized to CloudKit. + /// * Any of the CloudKit APIs invoked throw an error. + /// + /// The value returned from this method can be used to present a ``CloudSharingView`` which + /// allows the user to send a share URL to another user. + /// + /// - Parameters: + /// - record: The record to be shared on CloudKit. + /// - configure: A trailing closure that can customize the `CKShare` sent to CloudKit. See + /// [Apple's documentation](https://developer.apple.com/documentation/cloudkit/ckshare/systemfieldkey) + /// for more info on what can be configured. + public func share( + record: T, + configure: @Sendable (CKShare) -> Void + ) async throws -> SharedRecord + where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { + guard tablesByName[T.tableName] != nil + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTableNotSynchronized, + debugDescription: """ + Table is not shareable: table type not passed to 'tables' parameter of \ + 'SyncEngine.init'. + """ + ) + } + if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordNotRoot(foreignKeys), + debugDescription: """ + Only root records are shareable, but parent record(s) detected via foreign key(s). + """ + ) + } + guard !privateTables.contains(where: { T.self == $0 }) + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTablePrivate, + debugDescription: """ + Private tables are not shareable: table type passed to 'privateTables' parameter of \ + 'SyncEngine.init'. + """ + ) + } + let recordName = record.recordName + let metadata = + try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .select { ($0.recordType, $0.recordName, $0.lastKnownServerRecord) } + .fetchOne(db) + } ?? nil + guard let (recordType, recordName, lastKnownServerRecord) = metadata + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordMetadataNotFound, + debugDescription: """ + No sync metadata found for record. Has the record been saved to the database? + """ + ) + } + + let rootRecord = + lastKnownServerRecord + ?? CKRecord( + recordType: recordType, + recordID: CKRecord.ID(recordName: recordName, zoneID: defaultZone.zoneID) + ) + + var existingShare: CKShare? { + get async throws { + guard let shareRecordID = rootRecord.share?.recordID + else { return nil } + do { + return try await container.database(for: rootRecord.recordID) + .record(for: shareRecordID) as? CKShare + } catch let error as CKError where error.code == .unknownItem { + return nil + } + } + } + + let sharedRecord = + try await existingShare + ?? CKShare( + rootRecord: rootRecord, + shareID: CKRecord.ID( + recordName: "share-\(recordName)", + zoneID: rootRecord.recordID.zoneID + ) + ) + + configure(sharedRecord) + _ = try await container.privateCloudDatabase.modifyRecords( + saving: [sharedRecord, rootRecord], + deleting: [] + ) + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .update { $0.share = sharedRecord } + .execute(db) + } + + return SharedRecord(container: container, share: sharedRecord) + } + + public func unshare(record: T) async throws + where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { + let share = try await metadatabase.read { [recordName = record.recordName] db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .select(\.share) + .fetchOne(db) + ?? nil + } + guard let share + else { + reportIssue( + """ + No share found associated with record. + """) + return + } + + let result = try await syncEngines.private?.database.modifyRecords( + saving: [], + deleting: [share.recordID] + ) + try result?.deleteResults.values.forEach { _ = try $0.get() } + } + + /// Accepts a shared record. + /// + /// This method should be invoked from various delegate methods on the scene delegate of the + /// app. See for more info. + public func acceptShare(metadata: CKShare.Metadata) async throws { + try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) + } + } + + #if canImport(UIKit) && !os(watchOS) + /// A view that presents standard screens for adding and removing people from a CloudKit share \ + /// record. + /// + /// See for more info. + @available(iOS 17, macOS 14, tvOS 17, *) + public struct CloudSharingView: UIViewControllerRepresentable { + let sharedRecord: SharedRecord + let availablePermissions: UICloudSharingController.PermissionOptions + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + let syncEngine: SyncEngine + public init( + sharedRecord: SharedRecord, + availablePermissions: UICloudSharingController.PermissionOptions = [], + didFinish: @escaping (Result) -> Void = { _ in }, + didStopSharing: @escaping () -> Void = {}, + syncEngine: SyncEngine = { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + return defaultSyncEngine + }() + ) { + self.sharedRecord = sharedRecord + self.didFinish = didFinish + self.didStopSharing = didStopSharing + self.availablePermissions = availablePermissions + self.syncEngine = syncEngine + } + + public func makeCoordinator() -> _CloudSharingDelegate { + _CloudSharingDelegate( + share: sharedRecord.share, + didFinish: didFinish, + didStopSharing: didStopSharing, + syncEngine: syncEngine + ) + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + let controller = UICloudSharingController( + share: sharedRecord.share, + container: sharedRecord.container.rawValue + ) + controller.delegate = context.coordinator + controller.availablePermissions = availablePermissions + return controller + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } + } + + @available(iOS 17, macOS 14, tvOS 17, *) + public final class _CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { + let share: CKShare + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + let syncEngine: SyncEngine + init( + share: CKShare, + didFinish: @escaping (Result) -> Void, + didStopSharing: @escaping () -> Void, + syncEngine: SyncEngine + ) { + self.share = share + self.didFinish = didFinish + self.didStopSharing = didStopSharing + self.syncEngine = syncEngine + } + + public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { + share[CKShare.SystemFieldKey.thumbnailImageData] as? Data + } + + public func itemTitle(for csc: UICloudSharingController) -> String? { + share[CKShare.SystemFieldKey.title] as? String + } + + public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { + didFinish(.success(())) + } + + public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await syncEngine.deleteShare(shareRecordID: share.recordID) + } + } + didStopSharing() + } + + public func cloudSharingController( + _ csc: UICloudSharingController, + failedToSaveShareWithError error: any Error + ) { + didFinish(.failure(error)) + } + } + #endif +#endif diff --git a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift new file mode 100644 index 00000000..93b34516 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift @@ -0,0 +1,57 @@ +#if canImport(CloudKit) + import CloudKit + import Dependencies + import GRDB + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension DependencyValues { + /// The default sync engine used by the application. + /// + /// Configure this as early as possible in your app's lifetime, like the app entry point in + /// SwiftUI, using `prepareDependencies`: + /// + /// ```swift + /// import SQLiteData + /// import SwiftUI + /// + /// ```swift + /// @main + /// struct MyApp: App { + /// init() { + /// prepareDependencies { + /// $0.defaultDatabase = try! appDatabase() + /// $0.defaultSyncEngine = SyncEngine( + /// for: $0.defaultDatabase, + /// tables: Item.self + /// ) + /// } + /// } + /// // ... + /// } + /// ``` + /// + /// > Note: You can only prepare the default sync engine a single time in the lifetime of + /// > your app. Attempting to do so more than once will produce a runtime warning. + /// + /// Once configured, access the default sync engine anywhere using `@Dependency`: + /// + /// ```swift + /// @Dependency(\.defaultSyncEngine) var syncEngine + /// + /// syncEngine.acceptShare(metadata: metadata) + /// ``` + /// + /// See for more info. + public var defaultSyncEngine: SyncEngine { + get { self[SyncEngine.self] } + set { self[SyncEngine.self] = newValue } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine: TestDependencyKey { + public static var testValue: SyncEngine { + try! SyncEngine(for: DatabaseQueue()) + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift new file mode 100644 index 00000000..259aa14c --- /dev/null +++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift @@ -0,0 +1,58 @@ +import Foundation + +/// A type that can be represented by a string identifier. +/// +/// A requirement of tables synchronized to CloudKit using a ``SyncEngine``. You should generally +/// identify tables using Foundation's `UUID` type. +public protocol IdentifierStringConvertible { + init?(rawIdentifier: String) + var rawIdentifier: String { get } +} + +extension IdentifierStringConvertible where Self: CustomStringConvertible { + public var rawIdentifier: String { description } +} + +extension IdentifierStringConvertible where Self: LosslessStringConvertible { + public init?(rawIdentifier: String) { + self.init(rawIdentifier) + } +} + +extension Bool: IdentifierStringConvertible {} +extension Character: IdentifierStringConvertible {} +extension Double: IdentifierStringConvertible {} +extension Float: IdentifierStringConvertible {} +#if !(arch(i386) || arch(x86_64)) + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension Float16: IdentifierStringConvertible {} +#endif +#if !(os(Windows) || os(Android) || ($Embedded && !os(Linux) && !(os(macOS) || os(iOS) || os(watchOS) || os(tvOS)))) && (arch(i386) || arch(x86_64)) + extension Float80: IdentifierStringConvertible {} +#endif +extension Int: IdentifierStringConvertible {} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *) +extension Int128: IdentifierStringConvertible {} +extension Int16: IdentifierStringConvertible {} +extension Int32: IdentifierStringConvertible {} +extension Int64: IdentifierStringConvertible {} +extension Int8: IdentifierStringConvertible {} +extension String: IdentifierStringConvertible {} +extension Substring: IdentifierStringConvertible {} +extension UInt: IdentifierStringConvertible {} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *) +extension UInt128: IdentifierStringConvertible {} +extension UInt16: IdentifierStringConvertible {} +extension UInt32: IdentifierStringConvertible {} +extension UInt64: IdentifierStringConvertible {} +extension UInt8: IdentifierStringConvertible {} +extension Unicode.Scalar: IdentifierStringConvertible {} + +extension UUID: IdentifierStringConvertible { + public init?(rawIdentifier: String) { + self.init(uuidString: rawIdentifier) + } + public var rawIdentifier: String { + description.lowercased() + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift new file mode 100644 index 00000000..4829102f --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift @@ -0,0 +1,90 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { + associatedtype Database: CloudDatabase + + func accountStatus() async throws -> CKAccountStatus + var containerIdentifier: String? { get } + var rawValue: CKContainer { get } + var privateCloudDatabase: Database { get } + func accept(_ metadata: ShareMetadata) async throws -> CKShare + static func createContainer(identifier containerIdentifier: String) -> Self + var sharedCloudDatabase: Database { get } + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws + -> ShareMetadata + } + + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rootRecord: CKRecord? + package var share: CKShare + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rootRecord = rawValue.rootRecord + self.share = rawValue.share + self.rawValue = rawValue + } + package init( + containerIdentifier: String, + hierarchicalRootRecordID: CKRecord.ID?, + rootRecord: CKRecord?, + share: CKShare + ) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rootRecord = rootRecord + self.share = share + self.rawValue = nil + } + } + + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + extension CloudContainer { + package func database(for recordID: CKRecord.ID) -> any CloudDatabase { + recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + } + } + + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + extension CKContainer: CloudContainer { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let metadata = metadata.rawValue + else { + fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + } + return try await self.accept(metadata) + } + + package static func createContainer(identifier containerIdentifier: String) -> Self { + Self(identifier: containerIdentifier) + } + + package var rawValue: CKContainer { + self + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool = false + ) async throws -> ShareMetadata { + try await withUnsafeThrowingContinuation { continuation in + let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) + operation.shouldFetchRootRecord = true + operation.perShareMetadataResultBlock = { url, result in + continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) + } + add(operation) + } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift new file mode 100644 index 00000000..94a0dc61 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift @@ -0,0 +1,123 @@ +#if canImport(CloudKit) + import CloudKit + + package protocol CloudDatabase: AnyObject, Hashable, Sendable { + var databaseScope: CKDatabase.Scope { get } + + func record(for recordID: CKRecord.ID) async throws -> CKRecord + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) async throws -> [CKRecord.ID: Result] + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] + ) async throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) + } + + extension CloudDatabase { + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID] + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + try await modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + package func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID: Result] { + try await records(for: ids, desiredKeys: nil) + } + } + + extension CKDatabase: CloudDatabase {} + + final class AnyCloudDatabase: CloudDatabase { + let rawValue: any CloudDatabase + init(_ rawValue: any CloudDatabase) { + self.rawValue = rawValue + } + + var databaseScope: CKDatabase.Scope { + rawValue.databaseScope + } + + func record(for recordID: CKRecord.ID) async throws -> CKRecord { + try await rawValue.record(for: recordID) + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) async throws -> [CKRecord.ID: Result] { + try await rawValue.records(for: ids) + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + try await rawValue.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: savePolicy, + atomically: atomically + ) + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] + ) async throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) { + try await rawValue.modifyRecordZones( + saving: recordZonesToSave, deleting: recordZoneIDsToDelete) + } + + static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool { + lhs.rawValue === rhs.rawValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(rawValue)) + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift new file mode 100644 index 00000000..5d166e16 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift @@ -0,0 +1,26 @@ +#if canImport(CloudKit) + import CloudKit + import Foundation + + @DatabaseFunction("sqlitedata_icloud_currentTime") + func currentTime() -> Int64 { + @Dependency(\.currentTime.now) var now + return now + } + + @DatabaseFunction( + "sqlitedata_icloud_hasPermission", + as: ((CKShare?.SystemFieldsRepresentation) -> Bool).self, + isDeterministic: true + ) + func hasPermission(_ share: CKShare?) -> Bool { + guard let share else { return true } + return share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite + } + + @DatabaseFunction("sqlitedata_icloud_syncEngineIsSynchronizingChanges") + func syncEngineIsSynchronizingChanges() -> Bool { + _isSynchronizingChanges + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift new file mode 100644 index 00000000..038f7976 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift @@ -0,0 +1,26 @@ +import Dependencies +import Foundation + +package struct CurrentTimeGenerator: DependencyKey, Sendable { + private var generate: @Sendable () -> Int64 + package var now: Int64 { + get { self.generate() } + set { self.generate = { newValue } } + } + package func callAsFunction() -> Int64 { + self.generate() + } + package static var liveValue: CurrentTimeGenerator { + Self { Int64(clock_gettime_nsec_np(CLOCK_REALTIME)) } + } + package static var testValue: CurrentTimeGenerator { + Self { Int64(clock_gettime_nsec_np(CLOCK_REALTIME)) } + } +} + +extension DependencyValues { + package var currentTime: CurrentTimeGenerator { + get { self[CurrentTimeGenerator.self] } + set { self[CurrentTimeGenerator.self] = newValue } + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift new file mode 100644 index 00000000..9fc612d8 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift @@ -0,0 +1,16 @@ +#if canImport(UIKit) + import UIKit + + private enum DefaultNotificationCenterKey: DependencyKey { + static let liveValue = NotificationCenter.default + static var testValue: NotificationCenter { + NotificationCenter() + } + } + extension DependencyValues { + package var defaultNotificationCenter: NotificationCenter { + get { self[DefaultNotificationCenterKey.self] } + set { self[DefaultNotificationCenterKey.self] = newValue } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift new file mode 100644 index 00000000..977dc785 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift @@ -0,0 +1,14 @@ +#if canImport(CloudKit) + import Foundation + import StructuredQueriesCore + + @Selection + package struct ForeignKey { + let table: String + let from: String + let to: String + let onUpdate: ForeignKeyAction + let onDelete: ForeignKeyAction + let isNotNull: Bool + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift b/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift new file mode 100644 index 00000000..ef273a73 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift @@ -0,0 +1,20 @@ +import Foundation + +final class IsolatedWeakVar: @unchecked Sendable { + let lock = NSLock() + weak var _value: T? + + init() {} + + var value: T? { + lock.lock() + defer { lock.unlock() } + return _value + } + func set(_ value: T) { + precondition(_value == nil) + lock.lock() + defer { lock.unlock() } + _value = value + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/Logging.swift b/Sources/SQLiteData/CloudKit/Internal/Logging.swift new file mode 100644 index 00000000..0e54dd96 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/Logging.swift @@ -0,0 +1,247 @@ +#if canImport(CloudKit) + import CloudKit + import os + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Logger { + func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { + let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:" + switch event { + case .stateUpdate: + debug("\(prefix) stateUpdate") + case .accountChange(let changeType): + switch changeType { + case .signIn(let currentUser): + debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + debug("unknown") + } + case .fetchedDatabaseChanges(_, let deletions): + let deletions = + deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(deletions.count)): " + + deletions + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + debug( + """ + \(prefix) fetchedDatabaseChanges + \(deletions) + """ + ) + case .fetchedRecordZoneChanges(let modifications, let deletions): + let deletionsByRecordType = Dictionary( + grouping: deletions, + by: \.recordType + ) + let recordTypeDeletions = deletionsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let deletions = + deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(deletions.count)): \(recordTypeDeletions)" + + let modificationsByRecordType = Dictionary( + grouping: modifications, + by: \.recordType + ) + let recordTypeModifications = modificationsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let modifications = + modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(modifications.count)): \(recordTypeModifications)" + + debug( + """ + \(prefix) fetchedRecordZoneChanges + \(modifications) + \(deletions) + """ + ) + case .sentDatabaseChanges( + let savedZones, + let failedZoneSaves, + let deletedZoneIDs, + let failedZoneDeletes + ): + let savedZoneNames = + savedZones + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + let savedZones = + savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(savedZones.count)): \(savedZoneNames)" + + let deletedZoneNames = + deletedZoneIDs + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let deletedZones = + deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(deletedZoneIDs.count)): \(deletedZoneNames)" + + let failedZoneSaveNames = + failedZoneSaves + .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + let failedZoneSaves = + failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(failedZoneSaves.count)): \(failedZoneSaveNames)" + + let failedZoneDeleteNames = failedZoneDeletes + .keys + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneDeletes = + failedZoneDeletes.isEmpty + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(failedZoneDeletes.count)): \(failedZoneDeleteNames)" + + debug( + """ + \(prefix) sentDatabaseChanges + \(savedZones) + \(deletedZones) + \(failedZoneSaves) + \(failedZoneDeletes) + """ + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + let savedRecordsByRecordType = Dictionary( + grouping: savedRecords, + by: \.recordType + ) + let savedRecords = savedRecordsByRecordType.keys + .sorted() + .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } + .joined(separator: ", ") + + let failedRecordSavesByZoneName = Dictionary( + grouping: failedRecordSaves, + by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } + ) + let failedRecordSaves = failedRecordSavesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + debug( + """ + \(prefix) sentRecordZoneChanges + \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(deletedRecordIDs.count))") + \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") + \(failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(failedRecordDeletes.count))") + """ + ) + case .willFetchChanges: + debug("\(prefix) willFetchChanges") + case .willFetchRecordZoneChanges(let zoneID): + debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") + case .didFetchRecordZoneChanges(let zoneID, let error): + let errorType = error.map { + switch $0.code { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + @unknown default: "unknown" + } + } + let error = errorType.map { "\n ❌ \($0)" } ?? "" + debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error) + """ + ) + case .didFetchChanges: + debug("\(prefix) didFetchChanges") + case .willSendChanges(let context): + debug("\(prefix) willSendChanges: \(context.reason.description)") + case .didSendChanges(let context): + debug("\(prefix) didSendChanges: \(context.reason.description)") + @unknown default: + warning("\(prefix) ⚠️ unknown event: \(event.description)") + } + } + } + + extension CKDatabase.Scope { + var label: String { + switch self { + case .public: "public" + case .private: "private" + case .shared: "shared" + @unknown default: "unknown" + } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift new file mode 100644 index 00000000..cba0d65d --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -0,0 +1,158 @@ +#if canImport(CloudKit) + import Foundation + import os + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func defaultMetadatabase( + logger: Logger, + url: URL + ) throws -> any DatabaseWriter { + var configuration = Configuration() + configuration.prepareDatabase { [logger] db in + db.trace { + logger.trace("\($0.expandedDescription)") + } + } + logger.debug( + """ + Metadatabase connection: + open "\(url.path(percentEncoded: false))" + """ + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + + @Dependency(\.context) var context + guard !url.isInMemory || context != .live + else { + struct InMemoryDatabase: Error {} + throw InMemoryDatabase() + } + + let metadatabase: any DatabaseWriter = + if url.isInMemory { + try DatabaseQueue( + path: url.absoluteString, + configuration: configuration + ) + } else { + try DatabasePool( + path: url.path(percentEncoded: false), + configuration: configuration + ) + } + try migrate(metadatabase: metadatabase) + return metadatabase + } + + func migrate(metadatabase: some DatabaseWriter) throws { + var migrator = DatabaseMigrator() + migrator.registerMigration("Create Metadata Tables") { db in + try #sql( + """ + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + "recordPrimaryKey" TEXT NOT NULL, + "recordType" TEXT NOT NULL, + "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + "parentRecordPrimaryKey" TEXT, + "parentRecordType" TEXT, + "parentRecordName" TEXT AS ("parentRecordPrimaryKey" || ':' || "parentRecordType"), + "lastKnownServerRecord" BLOB, + "_lastKnownServerRecordAllFields" BLOB, + "share" BLOB, + "hasLastKnownServerRecord" INTEGER NOT NULL AS ("lastKnownServerRecord" IS NOT NULL), + "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), + "userModificationTime" INTEGER NOT NULL DEFAULT (\($currentTime())), + "_isDeleted" INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY ("recordPrimaryKey", "recordType"), + UNIQUE ("recordName") + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneID" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("ownerName", "zoneName") + """ + ) + try #sql( + """ + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") + """ + ) + .execute(db) + try #sql( + """ + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") + """ + ) + .execute(db) + try #sql( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_hasLastKnownServerRecord" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("hasLastKnownServerRecord") + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + "tableName" TEXT NOT NULL PRIMARY KEY, + "schema" TEXT NOT NULL, + "tableInfo" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + "scope" TEXT NOT NULL PRIMARY KEY, + "data" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + "recordName" TEXT NOT NULL, + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + PRIMARY KEY ("recordName", "zoneName", "ownerName") + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + "pendingRecordZoneChange" BLOB NOT NULL + ) STRICT + """ + ) + .execute(db) + } + #if DEBUG + try metadatabase.read { db in + let hasSchemaChanges = try migrator.hasSchemaChanges(db) + assert( + !hasSchemaChanges, + """ + A previously run migration has been removed or edited. \ + Metadatabase migrations must not be modified after release. + """ + ) + } + #endif + try migrator.migrate(metadatabase) + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift new file mode 100644 index 00000000..faea3f1b --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -0,0 +1,139 @@ +import CloudKit +import CustomDump + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { + package let _accountStatus: LockIsolated + package let containerIdentifier: String? + package let privateCloudDatabase: MockCloudDatabase + package let sharedCloudDatabase: MockCloudDatabase + + package init( + accountStatus: CKAccountStatus = .available, + containerIdentifier: String?, + privateCloudDatabase: MockCloudDatabase, + sharedCloudDatabase: MockCloudDatabase + ) { + self._accountStatus = LockIsolated(accountStatus) + self.containerIdentifier = containerIdentifier + self.privateCloudDatabase = privateCloudDatabase + self.sharedCloudDatabase = sharedCloudDatabase + + guard let containerIdentifier else { return } + @Dependency(\.mockCloudContainers) var mockCloudContainers + mockCloudContainers.withValue { storage in + storage[containerIdentifier] = self + } + } + + package func accountStatus() -> CKAccountStatus { + _accountStatus.withValue(\.self) + } + + package var rawValue: CKContainer { + fatalError("This should never be called in tests.") + } + + package func accountStatus() async throws -> CKAccountStatus { + _accountStatus.withValue { $0 } + } + + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool + ) async throws -> ShareMetadata { + let database = + share.recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + + let rootRecord: CKRecord? = database.storage.withValue { + $0[share.recordID.zoneID]?.values.first { record in + record.share?.recordID == share.recordID + } + } + + return ShareMetadata( + containerIdentifier: containerIdentifier!, + hierarchicalRootRecordID: rootRecord?.recordID, + rootRecord: shouldFetchRootRecord ? rootRecord : nil, + share: share + ) + } + + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let rootRecord = metadata.rootRecord + else { + fatalError("Must provide root record in mock shares during tests.") + } + + let (saveResults, _) = try sharedCloudDatabase.modifyRecords( + saving: [metadata.share, rootRecord] + ) + try saveResults.values.forEach { _ = try $0.get() } + return metadata.share + } + + package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer + { + @Dependency(\.mockCloudContainers) var mockCloudContainers + return mockCloudContainers.withValue { storage in + let container: MockCloudContainer + if let existingContainer = storage[containerIdentifier] { + return existingContainer + } else { + container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: containerIdentifier, + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + container.privateCloudDatabase.set(container: container) + container.sharedCloudDatabase.set(container: container) + } + storage[containerIdentifier] = container + return container + } + } + + package static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { + lhs === rhs + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("privateCloudDatabase", privateCloudDatabase), + ("sharedCloudDatabase", sharedCloudDatabase), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private enum MockCloudContainersKey: DependencyKey { + static var liveValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } + static var testValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } +} + +extension DependencyValues { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { + get { + self[MockCloudContainersKey.self] + } + set { + self[MockCloudContainersKey.self] = newValue + } + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift new file mode 100644 index 00000000..59099e60 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -0,0 +1,322 @@ +import CloudKit +import CustomDump +import IssueReporting + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudDatabase: CloudDatabase { + package let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:]) + let assets = LockIsolated<[AssetID: Data]>([:]) + package let databaseScope: CKDatabase.Scope + let _container = IsolatedWeakVar() + + let dataManager = Dependency(\.dataManager) + + struct AssetID: Hashable { + let recordID: CKRecord.ID + let key: String + } + + package init(databaseScope: CKDatabase.Scope) { + self.databaseScope = databaseScope + } + + package func set(container: MockCloudContainer) { + _container.set(container) + } + + package var container: MockCloudContainer { + _container.value! + } + + package func record(for recordID: CKRecord.ID) throws -> CKRecord { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + guard let zone = storage[recordID.zoneID] + else { throw CKError(.zoneNotFound) } + guard let record = zone[recordID] + else { throw CKError(.unknownItem) } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + + try assets.withValue { assets in + for key in record.allKeys() { + guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] + else { continue } + let url = URL(filePath: UUID().uuidString.lowercased()) + try dataManager.wrappedValue.save(assetData, to: url) + record[key] = CKAsset(fileURL: url) + } + } + + return record + } + + package func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) throws -> [CKRecord.ID: Result] { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + var results: [CKRecord.ID: Result] = [:] + for id in ids { + results[id] = Result { try record(for: id) } + } + return results + } + + package func modifyRecords( + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, + atomically: Bool = true + ) throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecord.ID: Result] = [:] + var deleteResults: [CKRecord.ID: Result] = [:] + + switch savePolicy { + case .ifServerRecordUnchanged: + for recordToSave in recordsToSave { + if let share = recordToSave as? CKShare { + let isSavingRootRecord = recordsToSave.contains(where: { + $0.share?.recordID == share.recordID + }) + let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil + guard shareWasPreviouslySaved || isSavingRootRecord + else { + reportIssue( + """ + An added share is being saved without its rootRecord being saved in the same \ + operation. + """ + ) + saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) + continue + } + } + + guard storage[recordToSave.recordID.zoneID] != nil + else { + saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) + continue + } + + let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] + + func saveRecordToDatabase() { + let hasReferenceViolation = + recordToSave.parent.map { parent in + storage[parent.recordID.zoneID]?[parent.recordID] == nil + && !recordsToSave.contains { $0.recordID == parent.recordID } + } + ?? false + guard !hasReferenceViolation + else { + saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) + return + } + + func root(of record: CKRecord) -> CKRecord { + guard let parent = record.parent + else { return record } + return (storage[parent.recordID.zoneID]?[parent.recordID]).map(root) ?? record + } + func share(for rootRecord: CKRecord) -> CKShare? { + for (_, record) in storage[rootRecord.recordID.zoneID] ?? [:] { + guard record.recordID == rootRecord.share?.recordID + else { continue } + return record as? CKShare + } + return nil + } + let rootRecord = root(of: recordToSave) + let share = share(for: rootRecord) + let isSavingShare = recordsToSave.contains { $0.recordID == share?.recordID } + if !isSavingShare, + !(recordToSave is CKShare), + let share, + !(share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite) + { + saveResults[recordToSave.recordID] = .failure(CKError(.permissionFailure)) + return + } + + guard let copy = recordToSave.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + copy._recordChangeTag = UUID().uuidString + + assets.withValue { assets in + for key in copy.allKeys() { + guard let assetURL = (copy[key] as? CKAsset)?.fileURL + else { continue } + assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue + .load(assetURL) + } + } + + // TODO: This should merge copy's values to more accurately reflect reality + storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy + saveResults[recordToSave.recordID] = .success(copy) + } + + switch (existingRecord, recordToSave._recordChangeTag) { + case (.some(let existingRecord), .some(let recordToSaveChangeTag)): + // We are trying to save a record with a change tag that also already exists in the + // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine + // that the server record has changed since it was last synced. + if existingRecord._recordChangeTag == recordToSaveChangeTag { + precondition(existingRecord._recordChangeTag != nil) + saveRecordToDatabase() + } else { + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRecordChanged, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + } + break + case (.some(let existingRecord), .none): + // We are trying to save a record that does not have a change tag yet also already + // exists in the DB. This means the user has created a new CKRecord from scratch, + // giving it a new identity, rather than leveraging an existing CKRecord. + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRejectedRequest, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + case (.none, .some): + // We are trying to save a record with a change tag but it does not exist in the DB. + // This means the record was deleted by another device. + saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) + case (.none, .none): + // We are trying to save a record with no change tag and no existing record in the DB. + // This means it's a brand new record. + saveRecordToDatabase() + } + } + case .allKeys, .changedKeys: + fatalError() + @unknown default: + fatalError() + } + for recordIDToDelete in recordIDsToDelete { + guard storage[recordIDToDelete.zoneID] != nil + else { + deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + let hasReferenceViolation = !Set( + storage[recordIDToDelete.zoneID]?.values + .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } + ?? [] + ) + .subtracting(recordIDsToDelete) + .isEmpty + + guard !hasReferenceViolation + else { + deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) + continue + } + storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil + deleteResults[recordIDToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecordZone.ID: Result] = [:] + var deleteResults: [CKRecordZone.ID: Result] = [:] + + for recordZoneToSave in recordZonesToSave { + storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:] + saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) + } + + for recordZoneIDsToDelete in recordZoneIDsToDelete { + guard storage[recordZoneIDsToDelete] != nil + else { + deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + storage[recordZoneIDsToDelete] = nil + deleteResults[recordZoneIDsToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { + lhs === rhs + } + + package nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension MockCloudDatabase: CustomDumpReflectable { + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "databaseScope": databaseScope, + "storage": storage + .value + .flatMap { _, value in value.values } + .sorted { + ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + }, + ], + displayStyle: .struct + ) + } +} + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { + switch accountStatus { + case .couldNotDetermine, .restricted, .noAccount: + return CKError(.notAuthenticated) + case .temporarilyUnavailable: + return CKError(.accountTemporarilyUnavailable) + case .available: + fatalError() + @unknown default: + fatalError() + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift new file mode 100644 index 00000000..1eca4a4d --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -0,0 +1,429 @@ +import CloudKit +import CustomDump +import OrderedCollections + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngine: SyncEngineProtocol { + package let database: MockCloudDatabase + package let parentSyncEngine: SyncEngine + private let _state: LockIsolated + private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) + private let _acceptedShareMetadata = LockIsolated>([]) + + package init( + database: MockCloudDatabase, + parentSyncEngine: SyncEngine, + state: MockSyncEngineState + ) { + self.database = database + self.parentSyncEngine = parentSyncEngine + self._state = LockIsolated(state) + } + + package var scope: CKDatabase.Scope { + database.databaseScope + } + + package var state: MockSyncEngineState { + _state.withValue(\.self) + } + + package func acceptShare(metadata: ShareMetadata) { + _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } + } + + package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + let records: [CKRecord] + let zoneIDs: [CKRecordZone.ID] + switch options.scope { + case .all: + zoneIDs = Array(database.storage.keys) + case .allExcluding(let excludedZoneIDs): + zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs)) + case .zoneIDs(let includedZoneIDs): + zoneIDs = includedZoneIDs + @unknown default: + fatalError() + } + records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += database.storage.withValue { + ($0[zoneID]?.values).map { Array($0) } ?? [] + } + } + await parentSyncEngine.handleEvent( + .fetchedRecordZoneChanges(modifications: records, deletions: []), + syncEngine: self + ) + } + + package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { + guard + !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges + .isEmpty + else { return } + try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + } + + package func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + var recordsToSave: [CKRecord] = [] + var recordIDsSkipped: [CKRecord.ID] = [] + var recordIDsToDelete: [CKRecord.ID] = [] + for pendingChange in pendingChanges { + switch pendingChange { + case .saveRecord(let recordID): + guard let record = await recordProvider(recordID) + else { + recordIDsSkipped.append(recordID) + continue + } + recordsToSave.append(record) + case .deleteRecord(let recordID): + recordIDsToDelete.append(recordID) + @unknown default: + fatalError() + } + } + + state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) + + return CKSyncEngine.RecordZoneChangeBatch( + recordsToSave: recordsToSave, + recordIDsToDelete: recordIDsToDelete + ) + } + + package func assertFetchChangesScopes( + _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _fetchChangesScopes.withValue { + expectNoDifference( + scopes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertAcceptedShareMetadata( + _ sharedMetadata: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _acceptedShareMetadata.withValue { + expectNoDifference( + sharedMetadata, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func cancelOperations() async { + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { + private let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] + ) + private let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + + package init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } + + package func assertPendingRecordZoneChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingRecordZoneChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertPendingDatabaseChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingDatabaseChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { + _pendingRecordZoneChanges.withValue { Array($0) } + } + + package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { + _pendingDatabaseChanges.withValue { Array($0) } + } + + package func removePendingChanges() { + _pendingDatabaseChanges.withValue { $0.removeAll() } + _pendingRecordZoneChanges.withValue { $0.removeAll() } + } + + package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.append(contentsOf: pendingRecordZoneChanges) + } + } + + package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.subtract(pendingRecordZoneChanges) + } + } + + package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.append(contentsOf: pendingDatabaseChanges) + } + } + + package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.subtract(pendingDatabaseChanges) + } + } + + package var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ( + "pendingRecordZoneChanges", + _pendingRecordZoneChanges.withValue(\.self) + .sorted(by: comparePendingRecordZoneChange) + as Any + ), + ( + "pendingDatabaseChanges", + _pendingDatabaseChanges.withValue(\.self) + .sorted(by: comparePendingDatabaseChange) as Any + ), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingRecordZoneChange( + _ lhs: CKSyncEngine.PendingRecordZoneChange, + _ rhs: CKSyncEngine.PendingRecordZoneChange +) -> Bool { + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)), + (.deleteRecord(let lhs), .deleteRecord(let rhs)): + lhs.recordName < rhs.recordName + case (.deleteRecord, .saveRecord): + true + case (.saveRecord, .deleteRecord): + false + default: + false + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingDatabaseChange( + _ lhs: CKSyncEngine.PendingDatabaseChange, + _ rhs: CKSyncEngine.PendingDatabaseChange +) -> Bool { + switch (lhs, rhs) { + case (.saveZone(let lhs), .saveZone(let rhs)): + lhs.zoneID.zoneName < rhs.zoneID.zoneName + case (.deleteZone(let lhs), .deleteZone(let rhs)): + lhs.zoneName < rhs.zoneName + case (.deleteZone, .saveZone): + true + case (.saveZone, .deleteZone): + false + default: + false + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + package func processPendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), + scope: CKDatabase.Scope, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + let syncEngine = syncEngine(for: scope) + guard !syncEngine.state.pendingRecordZoneChanges.isEmpty + else { + reportIssue( + "Processing empty set of record zone changes.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + guard try await container.accountStatus() == .available + else { + reportIssue( + """ + User must be logged in to process pending changes. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + + let batch = await nextRecordZoneChangeBatch( + reason: .scheduled, + options: options, + syncEngine: { + switch scope { + case .private: + self.private + case .shared: + self.shared + case .public: + fatalError("Public database not supported in tests.") + @unknown default: + fatalError("Unknown database scope not supported in tests.") + } + }() + ) + guard let batch + else { return } + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( + saving: batch.recordsToSave, + deleting: batch.recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + + var savedRecords: [CKRecord] = [] + var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var failedRecordDeletes: [CKRecord.ID: CKError] = [:] + for (recordID, result) in saveResults { + switch result { + case .success(let record): + savedRecords.append(record) + case .failure(let error as CKError): + guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID }) + else { fatalError("\(recordID.debugDescription) not found in pending changes") } + failedRecordSaves.append((record: record, error: error)) + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + for (recordID, result) in deleteResults { + switch result { + case .success: + deletedRecordIDs.append(recordID) + case .failure(let error as CKError): + failedRecordDeletes[recordID] = error + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + syncEngine.state.remove( + pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } + ) + syncEngine.state.remove( + pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } + ) + + await syncEngine.parentSyncEngine + .handleEvent( + .sentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes + ), + syncEngine: syncEngine + ) + } + + package var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine + } + package var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine + } + + package func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { + switch scope { + case .public: + fatalError("Public database not supported in sync engines.") + case .private: + `private` + case .shared: + shared + @unknown default: + fatalError("Unknown database scope not supported in sync engines.") + } + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift new file mode 100644 index 00000000..fad64e0d --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift @@ -0,0 +1,72 @@ +#if canImport(CloudKit) + import CloudKit + + @Table("sqlitedata_icloud_pendingRecordZoneChanges") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct PendingRecordZoneChange { + @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) + package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PendingRecordZoneChange { + package init(_ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange) { + self.pendingRecordZoneChange = pendingRecordZoneChange + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingRecordZoneChange { + package struct DataRepresentation: QueryBindable, QueryRepresentable { + package var queryOutput: CKSyncEngine.PendingRecordZoneChange + + package init(queryOutput: CKSyncEngine.PendingRecordZoneChange) { + self.queryOutput = queryOutput + } + + package var queryBinding: StructuredQueriesCore.QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + switch queryOutput { + case .saveRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("saveRecord", forKey: "changeType") + case .deleteRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("deleteRecord", forKey: "changeType") + @unknown default: + return .invalid(BindingError()) + } + return archiver.encodedData.queryBinding + } + + package init?(queryBinding: StructuredQueriesCore.QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: Data(decoder: &decoder)) + } + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let recordID = CKRecord.ID(coder: coder) else { + throw DecodingError() + } + let changeType = coder.decodeObject(of: NSString.self, forKey: "changeType") as? String + switch changeType { + case "saveRecord": + self.init(queryOutput: .saveRecord(recordID)) + case "deleteRecord": + self.init(queryOutput: .deleteRecord(recordID)) + default: + throw DecodingError() + } + } + } + + private struct DecodingError: Error {} + private struct BindingError: Error {} + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift new file mode 100644 index 00000000..d384415a --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift @@ -0,0 +1,64 @@ +@Table +struct PragmaDatabaseList { + static var tableAlias: String? { "databases" } + static var tableFragment: QueryFragment { "pragma_database_list()" } + + @Column("seq") let sequence: Int + let name: String + let file: String +} + +@Table +struct PragmaForeignKeyList { + static var tableAlias: String? { "\(Base.tableName)ForeignKeys" } + static var tableFragment: QueryFragment { + "pragma_foreign_key_list(\(quote: Base.tableName, delimiter: .text))" + } + + let id: Int + @Column("seq") let sequence: Int + let table: String + let from: String + let to: String + @Column("on_update") let onUpdate: ForeignKeyAction + @Column("on_delete") let onDelete: ForeignKeyAction + let match: String +} + +package enum ForeignKeyAction: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" +} + +@Table +struct PragmaIndexList { + static var tableAlias: String? { "\(Base.tableName)Indices" } + static var tableFragment: QueryFragment { + "pragma_index_list(\(quote: Base.tableName, delimiter: .text))" + } + + @Column("seq") let sequence: Int + let name: String + @Column("unique") let isUnique: Bool + let origin: String + @Column("partial") let isPartial: Bool +} + +@Table +struct PragmaTableInfo { + static var tableAlias: String? { "\(Base.tableName)TableInfo" } + static var schemaName: String? { Base.schemaName } + static var tableFragment: QueryFragment { + "pragma_table_info(\(quote: Base.tableName, delimiter: .text))" + } + + @Column("cid") let columnID: Int + let name: String + let type: String + @Column("notnull") let isNotNull: Bool + @Column("dflt_value") let defaultValue: String? + @Column("pk") let isPrimaryKey: Bool +} diff --git a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift new file mode 100644 index 00000000..5e9e8a93 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift @@ -0,0 +1,24 @@ +import CustomDump + +@Table("sqlitedata_icloud_recordTypes") +package struct RecordType: Hashable { + @Column(primaryKey: true) + package let tableName: String + package let schema: String + @Column(as: Set.JSONRepresentation.self) + package let tableInfo: Set +} + +extension RecordType: CustomDumpReflectable { + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("tableName", tableName as Any), + ("schema", schema), + ("tableInfo", tableInfo.sorted(by: { $0.name < $1.name })), + ], + displayStyle: .struct + ) + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift new file mode 100644 index 00000000..ed08fc28 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift @@ -0,0 +1,12 @@ +@Table("sqlite_schema") +package struct SQLiteSchema { + package let type: ObjectType + package let name: String + @Column("tbl_name") + package let tableName: String + package let sql: String? + + package enum ObjectType: String, QueryBindable { + case table, index, view, trigger + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift b/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift new file mode 100644 index 00000000..2b76b33b --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift @@ -0,0 +1,13 @@ +#if canImport(CloudKit) + import CloudKit + import StructuredQueriesCore + + @Table("sqlitedata_icloud_stateSerialization") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct StateSerialization { + @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) + package var scope: CKDatabase.Scope + @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) + package var data: CKSyncEngine.State.Serialization + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift new file mode 100644 index 00000000..230aac02 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift @@ -0,0 +1,103 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + package enum Event: CustomStringConvertible, Sendable { + case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization) + case accountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) + case fetchedDatabaseChanges( + modifications: [CKRecordZone.ID], + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] + ) + case fetchedRecordZoneChanges( + modifications: [CKRecord], + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] + ) + case sentDatabaseChanges( + savedZones: [CKRecordZone], + failedZoneSaves: [(zone: CKRecordZone, error: CKError)], + deletedZoneIDs: [CKRecordZone.ID], + failedZoneDeletes: [CKRecordZone.ID: CKError] + ) + case sentRecordZoneChanges( + savedRecords: [CKRecord], + failedRecordSaves: [(record: CKRecord, error: CKError)], + deletedRecordIDs: [CKRecord.ID], + failedRecordDeletes: [CKRecord.ID: CKError] + ) + case willFetchChanges + case willFetchRecordZoneChanges(zoneID: CKRecordZone.ID) + case didFetchChanges + case didFetchRecordZoneChanges(zoneID: CKRecordZone.ID, error: CKError?) + case willSendChanges(context: CKSyncEngine.SendChangesContext) + case didSendChanges(context: CKSyncEngine.SendChangesContext) + + init?(_ event: CKSyncEngine.Event) { + switch event { + case .stateUpdate(let event): + self = .stateUpdate(stateSerialization: event.stateSerialization) + case .accountChange(let event): + self = .accountChange(changeType: event.changeType) + case .fetchedDatabaseChanges(let event): + self = .fetchedDatabaseChanges( + modifications: event.modifications.map(\.zoneID), + deletions: event.deletions.map { (zoneID: $0.zoneID, reason: $0.reason) } + ) + case .fetchedRecordZoneChanges(let event): + self = .fetchedRecordZoneChanges( + modifications: event.modifications.map(\.record), + deletions: event.deletions.map { + (recordID: $0.recordID, recordType: $0.recordType) + } + ) + case .sentDatabaseChanges(let event): + self = .sentDatabaseChanges( + savedZones: event.savedZones, + failedZoneSaves: event.failedZoneSaves.map { (zone: $0.zone, error: $0.error) }, + deletedZoneIDs: event.deletedZoneIDs, + failedZoneDeletes: event.failedZoneDeletes + ) + case .sentRecordZoneChanges(let event): + self = .sentRecordZoneChanges( + savedRecords: event.savedRecords, + failedRecordSaves: event.failedRecordSaves.map { (record: $0.record, error: $0.error) }, + deletedRecordIDs: event.deletedRecordIDs, + failedRecordDeletes: event.failedRecordDeletes + ) + case .willFetchChanges: + self = .willFetchChanges + case .willFetchRecordZoneChanges(let event): + self = .willFetchRecordZoneChanges(zoneID: event.zoneID) + case .didFetchChanges: + self = .didFetchChanges + case .didFetchRecordZoneChanges(let event): + self = .didFetchRecordZoneChanges(zoneID: event.zoneID, error: event.error) + case .willSendChanges(let event): + self = .willSendChanges(context: event.context) + case .didSendChanges(let event): + self = .didSendChanges(context: event.context) + @unknown default: + return nil + } + } + + package var description: String { + switch self { + case .stateUpdate: "stateUpdate" + case .accountChange: "accountChange" + case .fetchedDatabaseChanges: "fetchedDatabaseChanges" + case .fetchedRecordZoneChanges: "fetchedRecordZoneChanges" + case .sentDatabaseChanges: "sentDatabaseChanges" + case .sentRecordZoneChanges: "sentRecordZoneChanges" + case .willFetchChanges: "willFetchChanges" + case .willFetchRecordZoneChanges: "willFetchRecordZoneChanges" + case .didFetchRecordZoneChanges: "didFetchRecordZoneChanges" + case .didFetchChanges: "didFetchChanges" + case .willSendChanges: "willSendChanges" + case .didSendChanges: "didSendChanges" + } + } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift new file mode 100644 index 00000000..d462e198 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift @@ -0,0 +1,18 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine: SyncEngineProtocol { + package func recordZoneChangeBatch( + pendingChanges: [PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> RecordZoneChangeBatch? { + await CKSyncEngine + .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.State: CKSyncEngineStateProtocol { + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift new file mode 100644 index 00000000..6dd52194 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -0,0 +1,40 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol SyncEngineDelegate: AnyObject, Sendable { + func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async + func nextRecordZoneChangeBatch( + reason: CKSyncEngine.SyncReason, + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol SyncEngineProtocol: AnyObject, Sendable { + associatedtype State: CKSyncEngineStateProtocol + associatedtype Database: CloudDatabase + + var database: Database { get } + var state: State { get } + + func cancelOperations() async + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws + func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? + func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol CKSyncEngineStateProtocol: Sendable { + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } + var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift new file mode 100644 index 00000000..1b416666 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift @@ -0,0 +1,10 @@ +import StructuredQueriesCore + +@Selection +package struct TableInfo: Codable, Hashable, QueryDecodable, QueryRepresentable { + let defaultValue: String? + let isPrimaryKey: Bool + let name: String + let isNotNull: Bool + let type: String +} diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift new file mode 100644 index 00000000..16077e14 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -0,0 +1,534 @@ +#if canImport(CloudKit) + import CloudKit + import Foundation + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable { + static func metadataTriggers( + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> [TemporaryTrigger] { + [ + afterInsert(parentForeignKey: parentForeignKey, defaultZone: defaultZone), + afterUpdate(parentForeignKey: parentForeignKey, defaultZone: defaultZone), + afterDeleteFromUser(parentForeignKey: parentForeignKey, defaultZone: defaultZone), + afterDeleteFromSyncEngine, + afterPrimaryKeyChange(parentForeignKey: parentForeignKey, defaultZone: defaultZone), + ] + } + + fileprivate static func afterPrimaryKeyChange( + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_primary_key_change_on_\(tableName)", + ifNotExists: true, + after: .update(of: \.primaryKey) { old, new in + checkWritePermissions( + alias: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + SyncMetadata + .where { + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .update { $0._isDeleted = true } + } when: { old, new in + old.primaryKey.neq(new.primaryKey) + } + ) + } + + fileprivate static func afterInsert( + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + ifNotExists: true, + after: .insert { new in + checkWritePermissions( + alias: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + SyncMetadata.insert( + new: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + } + ) + } + + fileprivate static func afterUpdate( + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", + ifNotExists: true, + after: .update { _, new in + checkWritePermissions( + alias: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + SyncMetadata.insert( + new: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + SyncMetadata.update( + new: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + } + ) + } + + fileprivate static func afterDeleteFromUser( + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> TemporaryTrigger< + Self + > { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", + ifNotExists: true, + after: .delete { old in + checkWritePermissions( + alias: old, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + SyncMetadata + .where { + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .update { $0._isDeleted = true } + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } + ) + } + + fileprivate static var afterDeleteFromSyncEngine: TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_sync_engine", + ifNotExists: true, + after: .delete { old in + SyncMetadata + .where { + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .delete() + } when: { _ in + SyncEngine.isSynchronizingChanges() + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + fileprivate static func insert( + new: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType, zoneName, ownerName) = parentFields( + alias: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + let defaultZoneName = #sql( + "\(quote: defaultZone.zoneID.zoneName, delimiter: .text)", + as: String.self + ) + let defaultOwnerName = #sql( + "\(quote: defaultZone.zoneID.ownerName, delimiter: .text)", + as: String.self + ) + return insert { + ( + $0.recordPrimaryKey, + $0.recordType, + $0.zoneName, + $0.ownerName, + $0.parentRecordPrimaryKey, + $0.parentRecordType + ) + } select: { + Values( + #sql("\(new.primaryKey)"), + T.tableName, + zoneName ?? defaultZoneName, + ownerName ?? defaultOwnerName, + parentRecordPrimaryKey, + parentRecordType + ) + } onConflictDoUpdate: { _ in + } + } + + fileprivate static func update( + new: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType, zoneName, ownerName) = parentFields( + alias: new, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + return Self.where { + $0.recordPrimaryKey.eq(#sql("\(new.primaryKey)")) + && $0.recordType.eq(T.tableName) + } + .update { + $0.zoneName = zoneName ?? $0.zoneName + $0.ownerName = ownerName ?? $0.ownerName + $0.parentRecordPrimaryKey = parentRecordPrimaryKey + $0.parentRecordType = parentRecordType + $0.userModificationTime = $currentTime() + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + static func callbackTriggers(for syncEngine: SyncEngine) -> [TemporaryTrigger] { + [ + afterInsertTrigger(for: syncEngine), + afterZoneUpdateTrigger(), + afterUpdateTrigger(for: syncEngine), + afterSoftDeleteTrigger(for: syncEngine), + ] + } + + fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger + { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .insert { new in + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + zoneName: new.zoneName, + ownerName: new.ownerName, + oldZoneName: new.zoneName, + oldOwnerName: new.ownerName, + descendantRecordNames: #bind(nil) + ) + ) + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } + ) + } + + fileprivate static func afterZoneUpdateTrigger() -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_zone_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { + ($0.zoneName, $0.ownerName) + } forEachRow: { old, new in + let selfAndDescendantRecordNames = descendantRecordNames( + recordName: new.recordName, + includeSelf: true + ) { + $0.select(\.recordName) + } + SyncMetadata + .where { + $0.recordName.in(selfAndDescendantRecordNames) + } + .update { + $0.zoneName = new.zoneName + $0.ownerName = new.ownerName + $0.lastKnownServerRecord = nil + $0._lastKnownServerRecordAllFields = nil + } + } when: { old, new in + new.zoneName.neq(old.zoneName) || new.ownerName.neq(old.ownerName) + } + ) + } + + fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger + { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { old, new in + let zoneChanged = new.zoneName.neq(old.zoneName) || new.ownerName.neq(old.ownerName) + let descendantRecordNamesJSON = descendantRecordNames( + recordName: new.recordName, + includeSelf: false + ) { + $0.select { $0.recordName.jsonGroupArray() } + } + + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + zoneName: new.zoneName, + ownerName: new.ownerName, + oldZoneName: old.zoneName, + oldOwnerName: old.ownerName, + descendantRecordNames: Case().when(zoneChanged, then: descendantRecordNamesJSON) + ) + ) + } when: { old, new in + old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() + } + ) + } + + fileprivate static func afterSoftDeleteTrigger( + for syncEngine: SyncEngine + ) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update(of: \._isDeleted) { _, new in + Values( + syncEngine.$didDelete( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share + ) + ) + } when: { old, new in + !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func parentFields( + alias: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> ( + parentRecordPrimaryKey: SQLQueryExpression?, + parentRecordType: SQLQueryExpression?, + zoneName: SQLQueryExpression, + ownerName: SQLQueryExpression + ) { + return + parentForeignKey + .map { foreignKey in + let parentRecordPrimaryKey = #sql( + #"\#(type(of: alias).QueryValue.self).\#(quote: foreignKey.from)"#, + as: String.self + ) + let parentRecordType = #sql("\(bind: foreignKey.table)", as: String.self) + let parentMetadata = SyncMetadata.where { + $0.recordPrimaryKey.eq(parentRecordPrimaryKey) + && $0.recordType.eq(parentRecordType) + } + return ( + parentRecordPrimaryKey, + parentRecordType, + #sql("coalesce(\($currentZoneName()), (\(parentMetadata.select(\.zoneName))))"), + #sql("coalesce(\($currentOwnerName()), (\(parentMetadata.select(\.ownerName))))") + ) + } + ?? ( + nil, + nil, + SQLQueryExpression($currentZoneName()), + SQLQueryExpression($currentOwnerName()) + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func validate( + recordName: some QueryExpression + ) -> some StructuredQueriesCore.Statement { + #sql( + """ + SELECT RAISE(ABORT, \(quote: SyncEngine.invalidRecordNameError, delimiter: .text)) + WHERE NOT \(recordName.isValidCloudKitRecordName) + """, + as: Never.self + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func checkWritePermissions( + alias: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey?, + defaultZone: CKRecordZone + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType, _, _) = parentFields( + alias: alias, + parentForeignKey: parentForeignKey, + defaultZone: defaultZone + ) + + return With { + SyncMetadata + .where { + $0.recordPrimaryKey.is(parentRecordPrimaryKey) + && $0.recordType.is(parentRecordType) + } + .select { RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) } + .union( + all: true, + SyncMetadata + .select { + RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) + } + .join(RootShare.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + RootShare + .select { _ in + #sql( + "RAISE(ABORT, \(quote: SyncEngine.writePermissionError, delimiter: .text))", + as: Never.self + ) + } + .where { + !SyncEngine.isSynchronizingChanges() + && $0.parentRecordName.is(nil) + && !$hasPermission($0.share) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func descendantRecordNames( + recordName: some QueryExpression, + includeSelf: Bool, + select: (Where) -> Select + ) -> some Statement { + With { + SyncMetadata + .where { $0.recordName.eq(recordName) } + .select { + DescendantMetadata.Columns(recordName: $0.recordName, parentRecordName: #bind(nil)) + } + .union( + all: true, + SyncMetadata + .select { + DescendantMetadata.Columns( + recordName: $0.recordName, + parentRecordName: $0.parentRecordName + ) + } + .join(DescendantMetadata.all) { $0.parentRecordName.eq($1.recordName) } + ) + } query: { + select( + DescendantMetadata.where { + if !includeSelf { + $0.recordName.neq(recordName) + } + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func rootServerRecord( + recordName: some QueryExpression + ) -> some QueryExpression { + With { + SyncMetadata + .where { $0.recordName.eq(recordName) } + .select { AncestorMetadata.Columns($0) } + .union( + all: true, + SyncMetadata + .select { AncestorMetadata.Columns($0) } + .join(AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + AncestorMetadata + .select(\.lastKnownServerRecord) + .where { $0.parentRecordName.is(nil) } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func parentLastKnownServerRecord( + parentRecordPrimaryKey: some QueryExpression, + parentRecordType: some QueryExpression + ) -> some QueryExpression { + SyncMetadata + .select(\.lastKnownServerRecord) + .where { + $0.recordPrimaryKey.is(parentRecordPrimaryKey) + && $0.recordType.is(parentRecordType) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension AncestorMetadata.Columns { + init(_ metadata: SyncMetadata.TableColumns) { + self.init( + recordName: metadata.recordName, + parentRecordName: metadata.parentRecordName, + lastKnownServerRecord: metadata.lastKnownServerRecord + ) + } + } + + extension QueryExpression { + fileprivate var isValidCloudKitRecordName: some QueryExpression { + substr(1, 1).neq("_") && octetLength().lte(255) && octetLength().eq(length()) + } + } + + @Table @Selection + private struct DescendantMetadata { + let recordName: String + let parentRecordName: String? + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Table @Selection + private struct AncestorMetadata { + let recordName: String + let parentRecordName: String? + @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Table @Selection + struct RecordWithRoot { + let parentRecordName: String? + let recordName: String + @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + let rootRecordName: String + @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let rootLastKnownServerRecord: CKRecord? + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Table @Selection + private struct RootShare { + let parentRecordName: String? + @Column(as: CKShare?.SystemFieldsRepresentation.self) + let share: CKShare? + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift new file mode 100644 index 00000000..f24a3e04 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift @@ -0,0 +1,51 @@ +#if canImport(CloudKit) + import CloudKit + import StructuredQueriesCore + + @Table("sqlitedata_icloud_unsyncedRecordIDs") + package struct UnsyncedRecordID: Equatable { + package let recordName: String + package let zoneName: String + package let ownerName: String + } + + extension UnsyncedRecordID { + package init(recordID: CKRecord.ID) { + recordName = recordID.recordName + zoneName = recordID.zoneID.zoneName + ownerName = recordID.zoneID.ownerName + } + package static func find(_ recordID: CKRecord.ID) -> Where { + Self.where { + $0.recordName.eq(recordID.recordName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) + } + } + package static func findAll(_ recordIDs: some Collection) -> Where< + UnsyncedRecordID + > { + let condition: QueryFragment = recordIDs.map { + "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" + } + .joined(separator: ", ") + return Self.where { + #sql("(\($0.recordName), \($0.zoneName), \($0.ownerName)) IN (\(condition))") + } + } + } + + extension CKRecord.ID { + convenience init(unsyncedRecordID: UnsyncedRecordID) { + self.init( + recordName: unsyncedRecordID.recordName, + zoneID: + CKRecordZone + .ID( + zoneName: unsyncedRecordID.zoneName, + ownerName: unsyncedRecordID.ownerName + ) + ) + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift b/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift new file mode 100644 index 00000000..64a0774e --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/_SendableMetatype.swift @@ -0,0 +1,5 @@ +#if swift(>=6.2) + public typealias _SendableMetatype = SendableMetatype +#else + public typealias _SendableMetatype = Any +#endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift new file mode 100644 index 00000000..4f9a5d29 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -0,0 +1,2135 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import Dependencies + import GRDB + import OrderedCollections + import OSLog + import Observation + import StructuredQueriesCore + import SwiftData + + #if canImport(UIKit) + import UIKit + #endif + + /// An object that manages the synchronization of local and remote SQLite data. + /// + /// See for more information. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public final class SyncEngine: Observable, Sendable { + package let userDatabase: UserDatabase + package let logger: Logger + package let metadatabase: any DatabaseWriter + package let tables: [any (PrimaryKeyedTable & _SendableMetatype).Type] + package let privateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] + let tablesByName: [String: any (PrimaryKeyedTable & _SendableMetatype).Type] + private let tablesByOrder: [String: Int] + let foreignKeysByTableName: [String: [ForeignKey]] + package let syncEngines = LockIsolated(SyncEngines()) + package let defaultZone: CKRecordZone + let defaultSyncEngines: + @Sendable (any DatabaseReader, SyncEngine) + -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) + package let container: any CloudContainer + let dataManager = Dependency(\.dataManager) + private let observationRegistrar = ObservationRegistrar() + private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil) + + /// The error message used when a write occurs to a record for which the current user + /// does not have permission. + /// + /// This error is thrown from any database write to a row for which the current user does + /// not have permissions to write, as determined by its `CKShare` (if applicable). To catch + /// this error try casting it to `DatabaseError` and checking its message: + /// + /// ```swift + /// do { + /// try await database.write { db in + /// Reminder.find(id) + /// .update { $0.title = "Personal" } + /// .execute(db) + /// } + /// } catch let error as DatabaseError where error.message == SyncEngine.writePermissionError { + /// // User does not have permission to write to this record. + /// } + /// ``` + public static let writePermissionError = + "co.pointfree.SQLiteData.CloudKit.write-permission-error" + public static let invalidRecordNameError = + "co.pointfree.SQLiteData.CloudKit.invalid-record-name-error" + + /// Initialize a sync engine. + /// + /// - Parameters: + /// - database: The database to synchronize to CloudKit. + /// - tables: A list of tables that you want to synchronize _and_ that you want to be + /// shareable with other users on CloudKit. + /// - privateTables: A list of tables that you want to synchronize to CloudKit but that + /// you do not want to be shareable with other users. + /// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted + /// the container will be determined from the entitlements of your app. + /// - defaultZone: The zone for all records to be stored in. + /// - startImmediately: Determines if the sync engine starts right away or requires an + /// explicit call to ``stop()``. By default this argument is `true`. + /// - logger: The logger used to log events in the sync engine. By default a `.disabled` + /// logger is used, which means logs are not printed. + public convenience init< + each T1: PrimaryKeyedTable & _SendableMetatype, + each T2: PrimaryKeyedTable & _SendableMetatype + >( + for database: any DatabaseWriter, + tables: repeat (each T1).Type, + privateTables: repeat (each T2).Type, + containerIdentifier: String? = nil, + defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), + startImmediately: Bool = DependencyValues._current.context == .live, + logger: Logger = isTesting + ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") + ) throws + where + repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible + { + let containerIdentifier = + containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + var allTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = [] + var allPrivateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = [] + for table in repeat each tables { + allTables.append(table) + } + for privateTable in repeat each privateTables { + allPrivateTables.append(privateTable) + } + let userDatabase = UserDatabase(database: database) + + guard !isTesting + else { + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + try self.init( + container: MockCloudContainer( + containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests", + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ), + defaultZone: defaultZone, + defaultSyncEngines: { _, syncEngine in + ( + private: MockSyncEngine( + database: privateDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ), + shared: MockSyncEngine( + database: sharedDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ) + ) + }, + userDatabase: userDatabase, + logger: logger, + tables: allTables, + privateTables: allPrivateTables + ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } + return + } + + guard let containerIdentifier else { + throw SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + + let container = CKContainer(identifier: containerIdentifier) + try self.init( + container: container, + defaultZone: defaultZone, + defaultSyncEngines: { metadatabase, syncEngine in + ( + private: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: try? metadatabase.read { db in + try StateSerialization + .find(#bind(.private)) + .select(\.data) + .fetchOne(db) + }, + delegate: syncEngine + ) + ), + shared: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.sharedCloudDatabase, + stateSerialization: try? metadatabase.read { db in + try StateSerialization + .find(#bind(.shared)) + .select(\.data) + .fetchOne(db) + }, + delegate: syncEngine + ) + ) + ) + }, + userDatabase: userDatabase, + logger: logger, + tables: allTables, + privateTables: allPrivateTables + ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } + } + + package init( + container: any CloudContainer, + defaultZone: CKRecordZone, + defaultSyncEngines: + @escaping @Sendable ( + any DatabaseReader, + SyncEngine + ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), + userDatabase: UserDatabase, + logger: Logger, + tables: [any (PrimaryKeyedTable & _SendableMetatype).Type], + privateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = [] + ) throws { + let allTables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) + .map(\.type) + self.tables = allTables + self.privateTables = privateTables + + let foreignKeysByTableName = Dictionary( + uniqueKeysWithValues: try userDatabase.read { db in + try allTables.map { table -> (String, [ForeignKey]) in + func open(_: T.Type) throws -> (String, [ForeignKey]) { + ( + table.tableName, + try PragmaForeignKeyList + .join(PragmaTableInfo.all) { $0.from.eq($1.name) } + .select { + ForeignKey.Columns( + table: $0.table, + from: $0.from, + to: $0.to, + onUpdate: $0.onUpdate, + onDelete: $0.onDelete, + isNotNull: $1.isNotNull + ) + } + .fetchAll(db) + ) + } + return try open(table) + } + } + ) + self.container = container + self.defaultZone = defaultZone + self.defaultSyncEngines = defaultSyncEngines + self.userDatabase = userDatabase + self.logger = logger + self.metadatabase = try defaultMetadatabase( + logger: logger, + url: try URL.metadatabase( + databasePath: userDatabase.path, + containerIdentifier: container.containerIdentifier + ) + ) + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) + self.foreignKeysByTableName = foreignKeysByTableName + tablesByOrder = try SQLiteData.tablesByOrder( + userDatabase: userDatabase, + tables: allTables, + tablesByName: tablesByName + ) + #if os(iOS) + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter + notificationsObserver.withValue { + $0 = defaultNotificationCenter.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil + ) { [syncEngines] _ in + Task { @MainActor in + let taskIdentifier = UIApplication.shared.beginBackgroundTask() + defer { UIApplication.shared.endBackgroundTask(taskIdentifier) } + let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue { + ($0.private, $0.shared) + } + try await privateSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions()) + try await sharedSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions()) + } + } + } + #endif + try validateSchema() + } + + deinit { + notificationsObserver.withValue { + guard let observer = $0 + else { return } + NotificationCenter.default.removeObserver(observer) + } + } + + nonisolated package func setUpSyncEngine() throws { + try userDatabase.write { db in + try setUpSyncEngine(writableDB: db) + } + } + + nonisolated package func setUpSyncEngine(writableDB db: Database) throws { + let attachedMetadatabasePath: String? = + try PragmaDatabaseList + .where { $0.name.eq(String.sqliteDataCloudKitSchemaName) } + .select(\.file) + .fetchOne(db) + if let attachedMetadatabasePath { + let attachedMetadatabaseName = URL(filePath: metadatabase.path).lastPathComponent + let metadatabaseName = URL(filePath: attachedMetadatabasePath).lastPathComponent + if attachedMetadatabaseName != metadatabaseName { + throw SchemaError( + reason: .metadatabaseMismatch( + attachedPath: attachedMetadatabasePath, + syncEngineConfiguredPath: metadatabase.path + ), + debugDescription: """ + Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ + 'SyncEngine.init'. Are different CloudKit container identifiers being provided? + """ + ) + } + + } else { + try #sql( + """ + ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(db) + } + db.add(function: $currentTime) + db.add(function: $syncEngineIsSynchronizingChanges) + db.add(function: $didUpdate) + db.add(function: $didDelete) + db.add(function: $hasPermission) + db.add(function: $currentZoneName) + db.add(function: $currentOwnerName) + + for trigger in SyncMetadata.callbackTriggers(for: self) { + try trigger.execute(db) + } + + for table in tables { + try table.createTriggers( + foreignKeysByTableName: foreignKeysByTableName, + tablesByName: tablesByName, + defaultZone: defaultZone, + db: db + ) + } + } + + /// Starts the sync engine if it is stopped. + /// + /// When a sync engine is started it will upload all data stored locally that has not yet + /// been synchronized to CloudKit, and will download all changes from CloudKit since the + /// last time it synchronized. + /// + /// > Note: By default, sync engines start syncing when initialized. + public func start() async throws { + try await start().value + } + + /// Stops the sync engine if it is running. + /// + /// All edits made after stopping the sync engine will not be synchronized to CloudKit. + /// You must start the sync engine again using ``start()`` to synchronize the changes. + public func stop() { + guard isRunning else { return } + observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { + syncEngines.withValue { + $0 = SyncEngines() + } + } + } + + /// Determines if the sync engine is currently running or not. + public var isRunning: Bool { + observationRegistrar.access(self, keyPath: \.isRunning) + return syncEngines.withValue { + $0.isRunning + } + } + + private func start() throws -> Task { + guard !isRunning else { return Task {} } + observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { + syncEngines.withValue { + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + $0 = SyncEngines( + private: privateSyncEngine, + shared: sharedSyncEngine + ) + } + } + + let previousRecordTypes = try metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + let currentRecordTypes = try userDatabase.read { db in + let namesAndSchemas = + try SQLiteSchema + .where { + $0.type.eq(#bind(.table)) + && $0.tableName.in(tables.map { $0.tableName }) + } + .fetchAll(db) + return try namesAndSchemas.compactMap { schema -> RecordType? in + guard let sql = schema.sql, let table = tablesByName[schema.name] + else { return nil } + func open(_: T.Type) throws -> RecordType { + try RecordType( + tableName: schema.name, + schema: sql, + tableInfo: Set( + PragmaTableInfo + .select { + TableInfo.Columns( + defaultValue: $0.defaultValue, + isPrimaryKey: $0.isPrimaryKey, + name: $0.name, + isNotNull: $0.isNotNull, + type: $0.type + ) + } + .fetchAll(db) + ) + ) + } + return try open(table) + } + } + let previousRecordTypeByTableName = Dictionary( + uniqueKeysWithValues: previousRecordTypes.map { + ($0.tableName, $0) + } + ) + let currentRecordTypeByTableName = Dictionary( + uniqueKeysWithValues: currentRecordTypes.map { + ($0.tableName, $0) + } + ) + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + guard try await container.accountStatus() == .available + else { return } + try await uploadRecordsToCloudKit( + previousRecordTypeByTableName: previousRecordTypeByTableName, + currentRecordTypeByTableName: currentRecordTypeByTableName + ) + try await updateLocalFromSchemaChange( + previousRecordTypeByTableName: previousRecordTypeByTableName, + currentRecordTypeByTableName: currentRecordTypeByTableName + ) + try await cacheUserTables(recordTypes: currentRecordTypes) + } + } + } + + private func cacheUserTables(recordTypes: [RecordType]) async throws { + try await userDatabase.write { db in + try RecordType + .upsert { recordTypes.map { RecordType.Draft($0) } } + .execute(db) + } + } + + private func uploadRecordsToCloudKit( + previousRecordTypeByTableName: [String: RecordType], + currentRecordTypeByTableName: [String: RecordType] + ) async throws { + try await enqueueLocallyPendingChanges() + try await userDatabase.write { db in + try PendingRecordZoneChange.delete().execute(db) + + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in + previousRecordTypeByTableName[tableName] == nil + } + + try $_isSynchronizingChanges.withValue(false) { + for tableName in newTableNames { + try self.uploadRecordsToCloudKit(tableName: tableName, db: db) + } + } + } + } + + private func enqueueLocallyPendingChanges() async throws { + let pendingRecordZoneChanges = try await metadatabase.read { db in + try PendingRecordZoneChange + .select(\.pendingRecordZoneChange) + .fetchAll(db) + } + let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) { + switch $0 { + case .deleteRecord(let recordID), .saveRecord(let recordID): + recordID.zoneID.ownerName == CKCurrentUserDefaultName + @unknown default: + false + } + } + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + } + } + + private func enqueueUnknownRecordsForCloudKit() async throws { + try await userDatabase.write { db in + try $_isSynchronizingChanges.withValue(false) { + try SyncMetadata + .where { !$0.hasLastKnownServerRecord } + .update { $0.recordPrimaryKey = $0.recordPrimaryKey } + .execute(db) + } + } + } + + private func uploadRecordsToCloudKit(table: T.Type, db: Database) throws { + try T.update { $0.primaryKey = $0.primaryKey }.execute(db) + } + + private func uploadRecordsToCloudKit(tableName: String, db: Database) throws { + guard let table = self.tablesByName[tableName] + else { return } + func open(_: T.Type) throws { + try uploadRecordsToCloudKit(table: T.self, db: db) + } + try open(table) + } + + private func updateLocalFromSchemaChange( + previousRecordTypeByTableName: [String: RecordType], + currentRecordTypeByTableName: [String: RecordType] + ) async throws { + let tablesWithChangedSchemas = currentRecordTypeByTableName.filter { tableName, recordType in + previousRecordTypeByTableName[tableName]?.schema != recordType.schema + } + + for (tableName, currentRecordType) in tablesWithChangedSchemas { + guard let table = tablesByName[tableName] + else { continue } + func open(_: T.Type) async throws { + let previousRecordType = previousRecordTypeByTableName[tableName] + let changedColumns = currentRecordType.tableInfo.subtracting( + previousRecordType?.tableInfo ?? [] + ) + .map(\.name) + let lastKnownServerRecords = try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordType.eq(tableName) } + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + for case .some(let lastKnownServerRecord) in lastKnownServerRecords { + let query = try await updateQuery( + for: T.self, + record: lastKnownServerRecord, + columnNames: T.TableColumns.writableColumns.map(\.name), + changedColumnNames: changedColumns + ) + try await userDatabase.write { db in + try #sql(query).execute(db) + } + } + } + try await open(table) + } + } + + package func tearDownSyncEngine() throws { + try userDatabase.write { db in + for table in tables.reversed() { + try table.dropTriggers(defaultZone: defaultZone, db: db) + } + for trigger in SyncMetadata.callbackTriggers(for: self).reversed() { + try trigger.drop().execute(db) + } + } + try metadatabase.erase() + try migrate(metadatabase: metadatabase) + } + + func deleteLocalData() async throws { + stop() + try tearDownSyncEngine() + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + for table in tables { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try T.delete().execute(db) + } + } + open(table) + } + try setUpSyncEngine(writableDB: db) + } + } + try await start() + } + + @DatabaseFunction( + "sqlitedata_icloud_didUpdate", + as: (( + String, + String, + String, + String, + String, + [String]?.JSONRepresentation + ) -> Void).self + ) + func didUpdate( + recordName: String, + zoneName: String, + ownerName: String, + oldZoneName: String, + oldOwnerName: String, + descendantRecordNames: [String]? + ) { + var oldChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var newChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + + let oldZoneID = CKRecordZone.ID(zoneName: oldZoneName, ownerName: oldOwnerName) + let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + + if oldZoneID != zoneID { + oldChanges.append(.deleteRecord(CKRecord.ID(recordName: recordName, zoneID: oldZoneID))) + for descendantRecordName in descendantRecordNames ?? [] { + oldChanges.append( + .deleteRecord(CKRecord.ID(recordName: descendantRecordName, zoneID: oldZoneID)) + ) + } + newChanges.append(.saveRecord(CKRecord.ID(recordName: recordName, zoneID: zoneID))) + for descendantRecordName in descendantRecordNames ?? [] { + newChanges.append( + .saveRecord(CKRecord.ID(recordName: descendantRecordName, zoneID: zoneID)) + ) + } + } else { + newChanges.append( + .saveRecord(CKRecord.ID(recordName: recordName, zoneID: zoneID)) + ) + } + + guard isRunning else { + // TODO: Perform this work in a trigger instead of a task. + Task { [changes = oldChanges + newChanges] in + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { + for change in changes { + PendingRecordZoneChange(change) + } + } + .execute(db) + } + } + } + return + } + let oldSyncEngine = self.syncEngines.withValue { + oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) + syncEngine?.state.add(pendingRecordZoneChanges: newChanges) + } + + @DatabaseFunction( + "sqlitedata_icloud_didDelete", + as: ((String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation) + -> Void).self + ) + func didDelete(recordName: String, record: CKRecord?, share: CKShare?) { + let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID + var changes: [CKSyncEngine.PendingRecordZoneChange] = [ + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) + ) + ] + if let share { + changes.append(.deleteRecord(share.recordID)) + } + guard isRunning else { + Task { [changes] in + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { changes.map { PendingRecordZoneChange($0) } } + .execute(db) + } + } + } + return + } + + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add(pendingRecordZoneChanges: changes) + } + + package func acceptShare(metadata: ShareMetadata) async throws { + guard let rootRecordID = metadata.hierarchicalRootRecordID + else { + reportIssue("Attempting to share without root record information.") + return + } + let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) + _ = try await container.accept(metadata) + try await syncEngines.shared?.fetchChanges( + CKSyncEngine.FetchChangesOptions( + scope: .zoneIDs([rootRecordID.zoneID]), + operationGroup: nil + ) + ) + } + + /// A query expression that can be used in SQL queries to determine if the ``SyncEngine`` + /// is currently writing changes to the database. + /// + /// See for more info. + public static func isSynchronizingChanges() -> some QueryExpression { + $syncEngineIsSynchronizingChanges() + } + } + + extension PrimaryKeyedTable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func createTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + tablesByName: [String: any PrimaryKeyedTable.Type], + defaultZone: CKRecordZone, + db: Database + ) throws { + let parentForeignKey = + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first + : nil + + for trigger in metadataTriggers(parentForeignKey: parentForeignKey, defaultZone: defaultZone) + { + try trigger.execute(db) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func dropTriggers(defaultZone: CKRecordZone, db: Database) throws { + for trigger in metadataTriggers(parentForeignKey: nil, defaultZone: defaultZone).reversed() { + try trigger.drop().execute(db) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { + public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + guard let event = Event(event) + else { + reportIssue("Unrecognized event received: \(event)") + return + } + await handleEvent(event, syncEngine: syncEngine) + } + + package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { + logger.log(event, syncEngine: syncEngine) + + switch event { + case .accountChange(let changeType): + await handleAccountChange(changeType: changeType, syncEngine: syncEngine) + case .stateUpdate(let stateSerialization): + await handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + case .fetchedDatabaseChanges(let modifications, let deletions): + await handleFetchedDatabaseChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let modifications, let deletions): + await handleFetchedRecordZoneChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + await handleSentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes, + syncEngine: syncEngine + ) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + .didFetchChanges, .willSendChanges, .didSendChanges: + break + @unknown default: + break + } + } + + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await nextRecordZoneChangeBatch( + reason: context.reason, + options: context.options, + syncEngine: syncEngine + ) + } + + package func nextRecordZoneChangeBatch( + reason: CKSyncEngine.SyncReason = .scheduled, + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + var changes = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine) + guard !changes.isEmpty + else { return nil } + + changes.sort { lhs, rhs in + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)): + guard + let lhsRecordType = lhs.tableName, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.tableName, + let rhsIndex = tablesByOrder[rhsRecordType] + else { return true } + return lhsIndex < rhsIndex + case (.deleteRecord(let lhs), .deleteRecord(let rhs)): + guard + let lhsRecordType = lhs.tableName, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.tableName, + let rhsIndex = tablesByOrder[rhsRecordType] + else { return true } + return lhsIndex > rhsIndex + case (.saveRecord, .deleteRecord): + return false + case (.deleteRecord, .saveRecord): + return true + default: + return true + } + } + + #if DEBUG + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) + } + #endif + + let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + #if DEBUG + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } + } + } + #endif + + guard + let (metadata, allFields) = await withErrorReporting( + .sqliteDataCloudKitFailure, + catching: { + try await metadatabase.read { db in + try SyncMetadata + .find(recordID) + .select { ($0, $0._lastKnownServerRecordAllFields) } + .fetchOne(db) + } + } + ) + ?? nil + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + guard let table = tablesByName[metadata.recordType] + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingTable = recordID + return nil + } + func open(_: T.Type) async -> CKRecord? { + let row = + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.read { db in + try T + .where { + #sql("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)") + } + .fetchOne(db) + } + } + ?? nil + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingRecord = recordID + return nil + } + + let record = + allFields + ?? CKRecord( + recordType: metadata.recordType, + recordID: recordID + ) + if let parentRecordName = metadata.parentRecordName, + let parentRecordType = metadata.parentRecordType, + !privateTables.contains(where: { $0.tableName == parentRecordType }) + { + record.parent = CKRecord.Reference( + recordID: CKRecord.ID( + recordName: parentRecordName, + zoneID: recordID.zoneID + ), + action: .none + ) + } else { + record.parent = nil + } + + record.update( + with: T(queryOutput: row), + userModificationTime: metadata.userModificationTime + ) + await refreshLastKnownServerRecord(record) + sentRecord = recordID + return record + } + return await open(table) + } + return batch + } + + private func pendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> [CKSyncEngine.PendingRecordZoneChange] { + var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) + guard !changes.isEmpty + else { return [] } + + let deletedRecordIDs: [CKRecord.ID] = changes.compactMap { + switch $0 { + case .saveRecord(_): + return nil + case .deleteRecord(let recordID): + return recordID + @unknown default: + return nil + } + } + + let (sharesToDelete, recordsWithRoot): + ([CKShare?], [(lastKnownServerRecord: CKRecord?, rootLastKnownServerRecord: CKRecord?)]) = + await withErrorReporting(.sqliteDataCloudKitFailure) { + guard !deletedRecordIDs.isEmpty + else { return ([], []) } + + return try await metadatabase.read { db in + let sharesToDelete = + try SyncMetadata + .findAll(deletedRecordIDs) + .where(\.isShared) + .select(\.share) + .fetchAll(db) + + let recordsWithRoot = + try With { + SyncMetadata + .findAll(deletedRecordIDs) + .where { $0.parentRecordName.is(nil) } + .select { + RecordWithRoot.Columns( + parentRecordName: $0.parentRecordName, + recordName: $0.recordName, + lastKnownServerRecord: $0.lastKnownServerRecord, + rootRecordName: $0.recordName, + rootLastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) } + .select { metadata, tree in + RecordWithRoot.Columns( + parentRecordName: metadata.parentRecordName, + recordName: metadata.recordName, + lastKnownServerRecord: metadata.lastKnownServerRecord, + rootRecordName: tree.rootRecordName, + rootLastKnownServerRecord: tree.lastKnownServerRecord + ) + } + ) + } query: { + RecordWithRoot + .select { ($0.lastKnownServerRecord, $0.rootLastKnownServerRecord) } + } + .fetchAll(db) + + return (sharesToDelete, recordsWithRoot) + } + } + ?? ([], []) + + let shareRecordIDsToDelete = sharesToDelete.compactMap(\.?.recordID) + + for recordWithRoot in recordsWithRoot { + guard + let lastKnownServerRecord = recordWithRoot.lastKnownServerRecord, + let rootLastKnownServerRecord = recordWithRoot.rootLastKnownServerRecord + else { continue } + guard let rootShareRecordID = rootLastKnownServerRecord.share?.recordID + else { continue } + guard shareRecordIDsToDelete.contains(rootShareRecordID) + else { continue } + changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID) }) + syncEngine.state.remove( + pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)] + ) + } + + await withErrorReporting(.sqliteDataCloudKitFailure) { + guard !deletedRecordIDs.isEmpty + else { return } + try await userDatabase.write { db in + try SyncMetadata + .findAll(deletedRecordIDs) + .delete() + .execute(db) + } + } + + return changes + } + + package func handleAccountChange( + changeType: CKSyncEngine.Event.AccountChange.ChangeType, + syncEngine: any SyncEngineProtocol + ) async { + guard syncEngine === syncEngines.private + else { return } + + switch changeType { + case .signIn: + syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + await withErrorReporting { + try await enqueueUnknownRecordsForCloudKit() + } + case .signOut, .switchAccounts: + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteLocalData() + } + @unknown default: + break + } + } + + package func handleStateUpdate( + stateSerialization: CKSyncEngine.State.Serialization, + syncEngine: any SyncEngineProtocol + ) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try StateSerialization.upsert { + StateSerialization.Draft( + scope: syncEngine.database.databaseScope, + data: stateSerialization + ) + } + .execute(db) + } + } + } + + package func handleFetchedDatabaseChanges( + modifications: [CKRecordZone.ID], + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)], + syncEngine: any SyncEngineProtocol + ) async { + let defaultZoneDeleted = + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + var defaultZoneDeleted = false + for (zoneID, reason) in deletions { + switch reason { + case .deleted, .purged: + try deleteRecords(in: zoneID, db: db) + if zoneID == self.defaultZone.zoneID { + defaultZoneDeleted = true + } + case .encryptedDataReset: + try uploadRecords(in: zoneID, db: db) + @unknown default: + reportIssue("Unknown deletion reason: \(reason)") + } + } + return defaultZoneDeleted + } + } + ?? false + if defaultZoneDeleted { + syncEngine.state.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)]) + } + @Sendable + func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws { + let recordTypes = Dictionary( + grouping: + try SyncMetadata + .where { $0.zoneName.eq(zoneID.zoneName) && $0.ownerName.eq(zoneID.ownerName) } + .select { ($0.recordType, $0.recordPrimaryKey) } + .fetchAll(db), + by: \.0 + ) + .mapValues { + $0.map(\.1) + } + for (recordType, primaryKeys) in recordTypes { + guard let table = tablesByName[recordType] + else { continue } + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try T.where { #sql("\($0.primaryKey)").in(primaryKeys) }.delete().execute(db) + } + } + open(table) + } + } + @Sendable + func uploadRecords(in zoneID: CKRecordZone.ID, db: Database) throws { + let recordTypes = Set( + try SyncMetadata + .where(\.hasLastKnownServerRecord) + .select(\.lastKnownServerRecord) + .fetchAll(db) + .compactMap { $0?.recordID.zoneID == zoneID ? $0?.recordType : nil } + ) + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + for recordType in recordTypes { + guard let table = tablesByName[recordType] + else { continue } + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + pendingRecordZoneChanges.append( + contentsOf: try T.select(\._recordName).fetchAll(db).map { + .saveRecord(CKRecord.ID(recordName: $0, zoneID: zoneID)) + } + ) + } + } + open(table) + } + syncEngine.state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + } + } + + package func handleFetchedRecordZoneChanges( + modifications: [CKRecord] = [], + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], + syncEngine: any SyncEngineProtocol + ) async { + let deletedRecordIDsByRecordType = OrderedDictionary( + grouping: deletions.sorted { lhs, rhs in + guard + let lhsIndex = tablesByOrder[lhs.recordType], + let rhsIndex = tablesByOrder[rhs.recordType] + else { return true } + return lhsIndex > rhsIndex + }, + by: \.recordType + ) + .mapValues { $0.map(\.recordID) } + for (recordType, recordIDs) in deletedRecordIDsByRecordType { + if let table = tablesByName[recordType] { + func open(_: T.Type) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try T + .where { + #sql("\($0.primaryKey)").in( + SyncMetadata.findAll(recordIDs) + .select(\.recordPrimaryKey) + ) + } + .delete() + .execute(db) + + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) + } + } + } + await open(table) + } else if recordType == CKRecord.SystemType.share { + for shareRecordID in recordIDs { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteShare(shareRecordID: shareRecordID) + } + } + } else { + // NB: Deleting a record from a table we do not currently recognize. + } + } + + let unsyncedRecords = + await withErrorReporting(.sqliteDataCloudKitFailure) { + var unsyncedRecordIDs = try await userDatabase.write { db in + Set( + try UnsyncedRecordID.all + .fetchAll(db) + .map(CKRecord.ID.init(unsyncedRecordID:)) + ) + } + let modificationRecordIDs = Set(modifications.map(\.recordID)) + let unsyncedRecordIDsToDelete = modificationRecordIDs.intersection(unsyncedRecordIDs) + unsyncedRecordIDs.subtract(modificationRecordIDs) + if !unsyncedRecordIDsToDelete.isEmpty { + try await userDatabase.write { db in + try UnsyncedRecordID + .findAll(unsyncedRecordIDsToDelete) + .delete() + .execute(db) + } + } + let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) + var unsyncedRecords: [CKRecord] = [] + for (recordID, result) in results { + switch result { + case .success(let record): + unsyncedRecords.append(record) + case .failure(let error as CKError) where error.code == .unknownItem: + try await userDatabase.write { db in + try UnsyncedRecordID.find(recordID).delete().execute(db) + } + case .failure: + continue + } + } + return unsyncedRecords + } + ?? [CKRecord]() + + let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in + guard + let lhsRecordType = lhs.recordID.tableName, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.recordID.tableName, + let rhsIndex = tablesByOrder[rhsRecordType] + else { return true } + return lhsIndex < rhsIndex + } + + enum ShareOrReference { + case share(CKShare) + case reference(CKShare.Reference) + } + let shares: [ShareOrReference] = + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + var shares: [ShareOrReference] = [] + for record in modifications { + if let share = record as? CKShare { + shares.append(.share(share)) + } else { + upsertFromServerRecord(record, db: db) + if let shareReference = record.share { + shares.append(.reference(shareReference)) + } + } + } + return shares + } + } + ?? [] + + await withTaskGroup(of: Void.self) { group in + for share in shares { + group.addTask { + switch share { + case .share(let share): + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await self.cacheShare(share) + } + case .reference(let shareReference): + guard + let record = try? await syncEngine.database.record(for: shareReference.recordID), + let share = record as? CKShare + else { return } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await self.cacheShare(share) + } + } + } + } + } + } + + package func handleSentRecordZoneChanges( + savedRecords: [CKRecord] = [], + failedRecordSaves: [(record: CKRecord, error: CKError)] = [], + deletedRecordIDs: [CKRecord.ID] = [], + failedRecordDeletes: [CKRecord.ID: CKError] = [:], + syncEngine: any SyncEngineProtocol + ) async { + for savedRecord in savedRecords { + await refreshLastKnownServerRecord(savedRecord) + } + + var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] + defer { + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + for (failedRecord, error) in failedRecordSaves { + func clearServerRecord() async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try SyncMetadata + .find(failedRecord.recordID) + .update { $0.setLastKnownServerRecord(nil) } + .execute(db) + } + } + } + + switch error.code { + case .serverRecordChanged: + guard let serverRecord = error.serverRecord else { continue } + await upsertFromServerRecord(serverRecord) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + + case .zoneNotFound: + let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) + newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + await clearServerRecord() + + case .unknownItem: + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + await clearServerRecord() + + case .serverRejectedRequest: + await clearServerRecord() + + case .referenceViolation: + guard + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey, + let table = tablesByName[failedRecord.recordType], + foreignKeysByTableName[table.tableName]?.count == 1, + let foreignKey = foreignKeysByTableName[table.tableName]?.first + else { + continue + } + func open(_: T.Type) async throws { + try await userDatabase.write { db in + try $_isSynchronizingChanges.withValue(false) { + switch foreignKey.onDelete { + case .cascade: + try T + .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + .execute(db) + case .restrict: + preconditionFailure( + "'RESTRICT' foreign key actions not supported for parent relationships." + ) + case .setDefault: + guard + let recordType = try RecordType.find(table.tableName).fetchOne(db), + let columnInfo = recordType.tableInfo.first(where: { + $0.name == foreignKey.from + }) + else { return } + let defaultValue = columnInfo.defaultValue ?? "NULL" + try #sql( + """ + UPDATE \(T.self) + SET \(quote: foreignKey.from, delimiter: .identifier) = (\(raw: defaultValue)) + WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) + """ + ) + .execute(db) + break + case .setNull: + try #sql( + """ + UPDATE \(T.self) + SET \(quote: foreignKey.from, delimiter: .identifier) = NULL + WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) + """ + ) + .execute(db) + case .noAction: + preconditionFailure( + "'NO ACTION' foreign key actions not supported for parent relationships." + ) + } + } + } + } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) + } + + case .permissionFailure: + guard + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey, + let table = tablesByName[failedRecord.recordType] + else { continue } + func open(_: T.Type) async throws { + do { + let serverRecord = try await container.sharedCloudDatabase.record( + for: failedRecord.recordID + ) + await upsertFromServerRecord(serverRecord, force: true) + } catch let error as CKError where error.code == .unknownItem { + try await userDatabase.write { db in + try T + .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + .execute(db) + } + } + } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) + } + + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, + .notAuthenticated, .operationCancelled, .batchRequestFailed, + .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, + .invalidArguments, .resultsTruncated, .assetFileNotFound, + .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, + .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants, + .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification, + .serverResponseLost, .assetNotAvailable, .accountTemporarilyUnavailable: + continue + @unknown default: + continue + } + } + + let enqueuedUnsyncedRecordID = + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + var enqueuedUnsyncedRecordID = false + for (failedRecordID, error) in failedRecordDeletes { + guard + error.code == .referenceViolation + else { continue } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: failedRecordID) + } + .execute(db) + syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + enqueuedUnsyncedRecordID = true + } + return enqueuedUnsyncedRecordID + } + } + ?? false + if enqueuedUnsyncedRecordID { + await handleFetchedRecordZoneChanges(syncEngine: syncEngine) + } + } + + private func cacheShare(_ share: CKShare) async throws { + let metadata = try await container.shareMetadata(for: share, shouldFetchRootRecord: false) + guard let rootRecordID = metadata.hierarchicalRootRecordID + else { return } + try await userDatabase.write { db in + try SyncMetadata + .find(rootRecordID) + .update { $0.share = share } + .execute(db) + } + } + + func deleteShare(shareRecordID: CKRecord.ID) async throws { + try await userDatabase.write { db in + let shareAndRecordNameAndZone = + try SyncMetadata + .where(\.isShared) + .select { ($0.share, $0.recordName, $0.zoneName, $0.ownerName) } + .fetchAll(db) + .first(where: { share, _, _, _ in share?.recordID == shareRecordID }) ?? nil + guard let (_, recordName, zoneName, ownerName) = shareAndRecordNameAndZone + else { return } + try SyncMetadata + .find( + CKRecord.ID( + recordName: recordName, + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + ) + ) + .update { $0.share = nil } + .execute(db) + } + } + + private func upsertFromServerRecord( + _ serverRecord: CKRecord, + force: Bool = false + ) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + upsertFromServerRecord(serverRecord, force: force, db: db) + } + } + } + + private func upsertFromServerRecord( + _ serverRecord: CKRecord, + force: Bool = false, + db: Database + ) { + withErrorReporting(.sqliteDataCloudKitFailure) { + guard let recordPrimaryKey = serverRecord.recordID.recordPrimaryKey + else { return } + + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: recordPrimaryKey, + recordType: serverRecord.recordType, + zoneName: serverRecord.recordID.zoneID.zoneName, + ownerName: serverRecord.recordID.zoneID.ownerName, + parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, + parentRecordType: serverRecord.parent?.recordID.tableName, + lastKnownServerRecord: serverRecord, + _lastKnownServerRecordAllFields: serverRecord, + share: nil, + userModificationTime: serverRecord.userModificationTime + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + if tablesByName[serverRecord.recordType] == nil { + $0.setLastKnownServerRecord(serverRecord) + } else { + $0.zoneName = serverRecord.recordID.zoneID.zoneName + $0.ownerName = serverRecord.recordID.zoneID.ownerName + } + } + .execute(db) + + guard + let metadata = try SyncMetadata.find(serverRecord.recordID).fetchOne(db), + let table = tablesByName[serverRecord.recordType] + else { + return + } + + serverRecord.userModificationTime = metadata.userModificationTime + + func open(_: T.Type) throws { + var columnNames: [String] = T.TableColumns.writableColumns.map(\.name) + if !force, + let allFields = metadata._lastKnownServerRecordAllFields, + let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) + { + serverRecord.update( + with: allFields, + row: T(queryOutput: row), + columnNames: &columnNames, + parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 + ? foreignKeysByTableName[T.tableName]?.first + : nil + ) + } + + do { + try $_currentZoneID.withValue(serverRecord.recordID.zoneID) { + try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) + } + try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) + try SyncMetadata + .find(serverRecord.recordID) + .update { $0.setLastKnownServerRecord(serverRecord) } + .execute(db) + } catch { + guard + let error = error as? DatabaseError, + error.resultCode == .SQLITE_CONSTRAINT, + error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY + else { + throw error + } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: serverRecord.recordID) + } + .execute(db) + } + } + try open(table) + } + } + + private func refreshLastKnownServerRecord(_ record: CKRecord) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await metadatabase.write { db in + let metadata = try SyncMetadata.find(record.recordID).fetchOne(db) + func updateLastKnownServerRecord() throws { + try SyncMetadata + .find(record.recordID) + .update { $0.setLastKnownServerRecord(record) } + .execute(db) + } + + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { + try updateLastKnownServerRecord() + } + } else { + try updateLastKnownServerRecord() + } + } + } + } + + private func updateQuery( + for _: T.Type, + record: CKRecord, + columnNames: some Collection, + changedColumnNames: some Collection + ) async throws -> QueryFragment { + let nonPrimaryKeyChangedColumns = + changedColumnNames + .filter { $0 != T.columns.primaryKey.name } + guard + !nonPrimaryKeyChangedColumns.isEmpty + else { + return "" + } + var record = record + let recordHasAsset = nonPrimaryKeyChangedColumns.contains { columnName in + record[columnName] is CKAsset + } + if recordHasAsset { + record = try await container.database(for: record.recordID).record(for: record.recordID) + } + + var query: QueryFragment = "INSERT INTO \(T.self) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) } + if data == nil { + reportIssue("Asset data not found on disk") + } + return data?.queryFragment ?? "NULL" + } else { + return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") + query.append( + nonPrimaryKeyChangedColumns + .map { columnName in + if let asset = record[columnName] as? CKAsset { + let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) } + if data == nil { + reportIssue("Asset data not found on disk") + } + return "\(quote: columnName) = \(data?.queryFragment ?? "NULL")" + } else { + return + "\(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL")" + } + } + .joined(separator: ",") + ) + return query + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingRecordZoneChange { + var id: CKRecord.ID? { + switch self { + case .saveRecord(let id): + return id + case .deleteRecord(let id): + return id + @unknown default: + return nil + } + } + } + + extension CKRecord.ID { + var tableName: String? { + guard + let i = recordName.utf8.lastIndex(of: UTF8.CodeUnit(ascii: ":")), + let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex) + else { return nil } + let recordTypeBytes = recordName.utf8[j...] + guard !recordTypeBytes.isEmpty else { return nil } + return String(Substring(recordTypeBytes)) + } + + var recordPrimaryKey: String? { + guard + let i = recordName.utf8.lastIndex(of: UTF8.CodeUnit(ascii: ":")) + else { return nil } + let recordPrimaryKeyBytes = recordName.utf8[.. URL { + guard let databaseURL = URL(string: databasePath) + else { + struct InvalidDatabasePath: Error {} + throw InvalidDatabasePath() + } + guard !databaseURL.isInMemory + else { + return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")! + } + return + databaseURL + .deletingLastPathComponent() + .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") + .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") + } + + package var isInMemory: Bool { + path.isEmpty + || path.hasPrefix(":memory:") + || absoluteString.hasPrefix(":memory:") + || URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "mode" && $0.value == "memory" }) + == true + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct SyncEngines { + private let rawValue: (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)? + init() { + rawValue = nil + } + init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { + rawValue = (`private`, shared) + } + var isRunning: Bool { + rawValue != nil + } + package var `private`: (any SyncEngineProtocol)? { + guard let `private` = rawValue?.private + else { + reportIssue("Private sync engine has not been set.") + return nil + } + return `private` + } + package var `shared`: (any SyncEngineProtocol)? { + guard let `shared` = rawValue?.shared + else { + reportIssue("Shared sync engine has not been set.") + return nil + } + return `shared` + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Database { + /// Attaches the metadatabase to an existing database connection. + /// + /// Invoke this method when preparing your database connection in order to allow querying the + /// ``SyncMetadata`` table (see for more info): + /// + /// ```swift + /// func appDatabase() -> any DatabaseWriter { + /// var configuration = Configuration() + /// configuration.prepareDatabase = { db in + /// db.attachMetadatabase() + /// … + /// } + /// } + /// ``` + /// + /// By default this method will use the container identifier assigned in your app's + /// entitlements. If you wish to use a different container identifier then you can provide + /// the `containerIdentifier` argument. + /// + /// See for more information on preparing your database. + /// + /// - Parameter containerIdentifier: The identifier of the CloudKit container used to + /// synchronize data. Defaults to the value set in the app's entitlements. + public func attachMetadatabase(containerIdentifier: String? = nil) throws { + let containerIdentifier = + containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + guard let containerIdentifier else { + throw SyncEngine.SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + + let databasePath = try PragmaDatabaseList.select(\.file).fetchOne(self) + guard let databasePath else { + struct PathError: Error {} + throw SyncEngine.SchemaError( + reason: .unknown, + debugDescription: """ + Expected to load a database path from the connection, but failed to do so. + """ + ) + } + let url = try URL.metadatabase( + databasePath: databasePath, + containerIdentifier: containerIdentifier + ) + let path = url.path(percentEncoded: false) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + _ = try DatabasePool(path: path).write { db in + try #sql("SELECT 1").execute(db) + } + try #sql( + """ + ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(self) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + package struct SchemaError: LocalizedError { + package enum Reason { + case cycleDetected + case inMemoryDatabase + case invalidForeignKey(ForeignKey) + case invalidForeignKeyAction(ForeignKey) + case invalidTableName(String) + case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) + case noCloudKitContainer + case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) + case unknown + case uniquenessConstraint + } + package let reason: Reason + package let debugDescription: String + + package var errorDescription: String? { + "Could not synchronize data with iCloud." + } + } + + fileprivate func validateSchema() throws { + let tableNames = Set(tables.map { $0.tableName }) + for tableName in tableNames { + if tableName.contains(":") { + throw SyncEngine.SchemaError( + reason: .invalidTableName(tableName), + debugDescription: "Table name contains invalid character ':'" + ) + } + } + try userDatabase.read { db in + for (tableName, foreignKeys) in foreignKeysByTableName { + let invalidForeignKey = foreignKeys.first(where: { tablesByName[$0.table] == nil }) + if let invalidForeignKey { + throw SyncEngine.SchemaError( + reason: .invalidForeignKey(invalidForeignKey), + debugDescription: """ + Foreign key \(tableName.debugDescription).\(invalidForeignKey.from.debugDescription) \ + references table \(invalidForeignKey.table.debugDescription) that is not \ + synchronized. Update 'SyncEngine.init' to synchronize \ + \(invalidForeignKey.table.debugDescription). + """ + ) + } + + if foreignKeys.count == 1, + let foreignKey = foreignKeys.first, + [.restrict, .noAction].contains(foreignKey.onDelete) + { + throw SyncEngine.SchemaError( + reason: .invalidForeignKeyAction(foreignKey), + debugDescription: """ + Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action \ + not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. + """ + ) + } + } + + for table in tables { + func open(_: T.Type) throws { + let columnsWithUniqueConstraints = try PragmaIndexList + .where { $0.isUnique && $0.origin != "pk" } + .select(\.name) + .fetchAll(db) + if !columnsWithUniqueConstraints.isEmpty { + throw SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: """ + Uniqueness constraints are not supported for synchronized tables. + """ + ) + } + } + try open(table) + } + } + } + } + + private struct HashablePrimaryKeyedTableType: Hashable { + let type: any (PrimaryKeyedTable & _SendableMetatype).Type + init(_ type: any (PrimaryKeyedTable & _SendableMetatype).Type) { + self.type = type + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(type)) + } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func tablesByOrder( + userDatabase: UserDatabase, + tables: [any (PrimaryKeyedTable & _SendableMetatype).Type], + tablesByName: [String: any (PrimaryKeyedTable & _SendableMetatype).Type] + ) throws -> [String: Int] { + let tableDependencies = try userDatabase.read { db in + var dependencies: + [HashablePrimaryKeyedTableType: [any (PrimaryKeyedTable & _SendableMetatype).Type]] = [:] + for table in tables { + func open(_: T.Type) throws -> [String] { + try PragmaForeignKeyList.select(\.table) + .fetchAll(db) + } + let toTables = try open(table) + for toTable in toTables { + guard let toTableType = tablesByName[toTable] + else { continue } + dependencies[HashablePrimaryKeyedTableType(table), default: []].append(toTableType) + } + } + return dependencies + } + + var visited = Set() + var marked = Set() + var result: [String: Int] = [:] + for table in tableDependencies.keys { + try visit(table: table) + } + return result + + func visit(table: HashablePrimaryKeyedTableType) throws { + guard !visited.contains(table) + else { return } + guard !marked.contains(table) + else { + throw SyncEngine.SchemaError( + reason: .cycleDetected, + debugDescription: """ + Cycles are not currently permitted in schemas, e.g. a table that references itself. + """ + ) + } + + marked.insert(table) + for dependency in tableDependencies[table] ?? [] { + try visit(table: HashablePrimaryKeyedTableType(dependency)) + } + marked.remove(table) + visited.insert(table) + result[table.type.tableName] = result.count + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Updates { + mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { + self.zoneName = lastKnownServerRecord?.recordID.zoneID.zoneName ?? self.zoneName + self.ownerName = lastKnownServerRecord?.recordID.zoneID.ownerName ?? self.ownerName + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = lastKnownServerRecord + if let lastKnownServerRecord { + self.userModificationTime = lastKnownServerRecord.userModificationTime + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func upsert( + _: T.Type, + record: CKRecord, + columnNames: some Collection + ) -> QueryFragment { + let allColumnNames = T.TableColumns.writableColumns.map(\.name) + let hasNonPrimaryKeyColumns = columnNames.contains(where: { $0 != T.columns.primaryKey.name }) + var query: QueryFragment = "INSERT INTO \(T.self) (" + query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + query.append( + allColumnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + @Dependency(\.dataManager) var dataManager + return (try? asset.fileURL.map { try dataManager.load($0) })? + .queryFragment ?? "NULL" + } else { + return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO ") + if hasNonPrimaryKeyColumns { + query.append("UPDATE SET ") + query.append( + columnNames + .filter { columnName in columnName != T.columns.primaryKey.name } + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ", ") + ) + } else { + query.append("NOTHING") + } + return query + } + + @TaskLocal package var _isSynchronizingChanges = false + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @TaskLocal package var _currentZoneID: CKRecordZone.ID? + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @DatabaseFunction("sqlitedata_icloud_currentZoneName") + func currentZoneName() -> String? { + _currentZoneID?.zoneName + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @DatabaseFunction("sqlitedata_icloud_currentOwnerName") + func currentOwnerName() -> String? { + _currentZoneID?.ownerName + } +#endif diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift new file mode 100644 index 00000000..44672565 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -0,0 +1,194 @@ +#if canImport(CloudKit) + import CloudKit + + /// A table that tracks metadata related to synchronized data. + /// + /// Each row of this table represents a synchronized record across all tables synchronized with + /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your + /// application is the number of rows this one single table holds. However, this table is held + /// in a database separate from your app's database. + /// + /// See for more info. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Table("sqlitedata_icloud_metadata") + public struct SyncMetadata: Hashable, Sendable { + /// The unique identifier of the record synchronized. + public var recordPrimaryKey: String + + /// The type of the record synchronized, _i.e._ its table name. + public var recordType: String + + /// The record zone name. + public var zoneName: String + + /// The record owner name. + public var ownerName: String + + /// The name of the record synchronized. + /// + /// This field encodes both the table name and primary key of the record synchronized in + /// the format "primaryKey:tableName", for example: + /// + /// ```swift + /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" + /// ``` + @Column(generated: .virtual) + public let recordName: String + + /// The unique identifier of this record's parent, if any. + public var parentRecordPrimaryKey: String? + + /// The type of this record's parent, _i.e._ its table name, if any. + public var parentRecordType: String? + + /// The name of this record's parent, if any. + /// + /// This field encodes both the table name and primary key of the parent record in the format + /// "primaryKey:tableName", for example: + /// + /// ```swift + /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" + /// ``` + @Column(generated: .virtual) + public let parentRecordName: String? + + /// The last known `CKRecord` received from the server. + /// + /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. + @Column(as: CKRecord?.SystemFieldsRepresentation.self) + public var lastKnownServerRecord: CKRecord? + + /// The last known `CKRecord` received from the server with all fields archived. + @Column(as: CKRecord?._AllFieldsRepresentation.self) + public var _lastKnownServerRecordAllFields: CKRecord? + + /// The `CKShare` associated with this record, if it is shared. + @Column(as: CKShare?.SystemFieldsRepresentation.self) + public var share: CKShare? + + /// Determines if the metadata has been "soft" deleted. It will be fully deleted once the + /// next batch of pending changes is processed. + public var _isDeleted = false + + @Column(generated: .virtual) + public let hasLastKnownServerRecord: Bool + + /// Determines if the record associated with this metadata is currently shared in CloudKit. + /// + /// This can only return `true` for root records. For example, the metadata associated with a + /// `RemindersList` can have `isShared == true`, but a `Reminder` associated with the list + /// will have `isShared == false`. + @Column(generated: .virtual) + public let isShared: Bool + + /// The time the user last modified the record. + public var userModificationTime: Int64 + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + package init( + recordPrimaryKey: String, + recordType: String, + zoneName: String, + ownerName: String, + parentRecordPrimaryKey: String? = nil, + parentRecordType: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + _lastKnownServerRecordAllFields: CKRecord? = nil, + share: CKShare? = nil, + userModificationTime: Int64 + ) { + self.recordPrimaryKey = recordPrimaryKey + self.recordType = recordType + self.recordName = "\(recordPrimaryKey):\(recordType)" + self.zoneName = zoneName + self.ownerName = ownerName + self.parentRecordPrimaryKey = parentRecordPrimaryKey + self.parentRecordType = parentRecordType + if let parentRecordPrimaryKey, let parentRecordType { + self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" + } else { + self.parentRecordName = nil + } + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields + self.share = share + self.hasLastKnownServerRecord = lastKnownServerRecord != nil + self.isShared = share != nil + self.userModificationTime = userModificationTime + } + + package static func find(_ recordID: CKRecord.ID) -> Where { + Self.where { + $0.recordName.eq(recordID.recordName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) + } + } + + package static func findAll(_ recordIDs: some Collection) -> Where { + let condition: QueryFragment = recordIDs.map { + "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" + } + .joined(separator: ", ") + return Self.where { + #sql("(\($0.recordName), \($0.zoneName), \($0.ownerName)) IN (\(condition))") + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + /// A query for finding the metadata associated with a record. + /// + /// - Parameter primaryKey: The primary key of the record whose metadata to look up. + public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { + SyncMetadata.where { + #sql( + """ + \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \ + AND \($0.recordType) = \(bind: tableName) + """ + ) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. + /// + /// - Parameter id: The ID of the record. + package static func recordName(for id: PrimaryKey.QueryOutput) -> String { + "\(id.rawIdentifier):\(tableName)" + } + + var recordName: String { + Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { + /// A query expression for whether or not this row has associated sync metadata. + /// + /// This helper can be useful when joining your tables to the ``SyncMetadata`` table: + /// + /// ```swift + /// RemindersList + /// .leftJoin(SyncMetadata.all) { $0.hasMetadata.in($1) } + /// ``` + public func hasMetadata(in metadata: SyncMetadata.TableColumns) -> some QueryExpression { + metadata.recordType.eq(QueryValue.tableName) + && #sql("\(primaryKey)").eq(metadata.recordPrimaryKey) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition { + var _recordName: some QueryExpression { + #sql("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + } + } +#endif diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md new file mode 100644 index 00000000..6e2c2350 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -0,0 +1,872 @@ +# Getting started with CloudKit + +Learn how to seamlessly add CloudKit synchronization to your SQLiteData application. + +## Overview + +SQLiteData allows you to seamlessly synchronize your SQLite database with CloudKit. After a few +steps to set up your project and a ``SyncEngine``, your database can be automatically synchronized +to CloudKit. However, distributing your app's schema across many devices is an impactful decision +to make, and so an abundance of care must be taken to make sure all devices remain consistent +and capable of communicating with each other. Please read the documentation closely and thoroughly +to make sure you understand how to best prepare your app for cloud synchronization. + + - [Setting up your project](#Setting-up-your-project) + - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) + - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) + - [Primary keys](#Primary-keys) + - [Primary keys on every table](#Primary-keys-on-every-table) + - [Uniqueness constraints](#Uniqueness-constraints) + - [Foreign key relationships](#Foreign-key-relationships) + - [Record conflicts](#Record-conflicts) + - [Backwards compatible migrations](#Backwards-compatible-migrations) + - [Adding tables](#Adding-tables) + - [Adding columns](#Adding-columns) + - [Disallowed migrations](#Disallowed-migrations) + - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) + - [Assets](#Assets) + - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) + - [How SQLiteData handles distributed schema scenarios](#How-SQLiteData-handles-distributed-schema-scenarios) + - [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) + - [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) + - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) + - [Add primary key to all tables](#Add-primary-key-to-all-tables) + - [Migrating from Swift Data to SQLiteData](#Migrating-from-Swift-Data-to-SQLiteData) + - [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) + - [Tips and tricks](#Tips-and-tricks) + - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) + - [Topics](#Topics) + - [Go deeper](#Go-deeper) + +## Setting up your project + +The steps to set up your SQLiteData project for CloudKit synchronization are the +[same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: + + * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. + * Follow the [Configuring background execution modes] guide for adding the Background Modes + capability to your project. + * If you want to enable sharing of records with other iCloud users, be sure to add a + `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented + in [Apple's documentation for sharing]. + * Once you are ready to deploy your app be sure to read Apple's documentation on + [Deploying an iCloud Container’s Schema]. + +With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate +synchronizing your database to and from CloudKit. + +[Deploying an iCloud Container’s Schema]: https://developer.apple.com/documentation/CloudKit/deploying-an-icloud-container-s-schema +[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic +[setup-cloudkit-apple]: https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices#Add-the-iCloud-and-Background-Modes-capabilities +[Configuring iCloud services]: https://developer.apple.com/documentation/Xcode/configuring-icloud-services +[Configuring background execution modes]: https://developer.apple.com/documentation/Xcode/configuring-background-execution-modes + +## Setting up a SyncEngine + +The foundational tool used to synchronize your SQLite database to CloudKit is a ``SyncEngine``. +This is a wrapper around CloudKit's `CKSyncEngine` and performs all the necessary work to listen +for changes in your database to play them back to CloudKit, and listen for changes in CloudKit to +play them back to SQLite. + +Before constructing a ``SyncEngine`` you must have already created and migrated your app's local +SQLite database as detailed in . Immediately after that is done in the +`prepareDependencies` of the entry point of your app you will override the +``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies +the database to synchronize, as well as the tables you want to synchronize: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self + ) + } + } + + // ... +} +``` + +The `SyncEngine` +[initializer]() +has more options you may be interested in configuring. + +> Important: You must explicitly provide all tables that you want to synchronize. We do this so that +> you can have the option of having some local tables that are not synchronized to CloudKit, such as +> full-text search indices, cached data, etc. + +Once this work is done the app should work exactly as it did before, but now any changes made +to the database will be synchronized to CloudKit. You will still interact with your local SQLite +database the same way you always have. You can use ``FetchAll`` to fetch data to be used in a view +or `@Observable` model, and you can use the `defaultDatabase` dependency to write to the database. + +There is one additional step you can optionally take if you want to gain access to the underlying +CloudKit metadata that is stored by the library. When constructing the connection to your database +you can use the `prepareDatabase` method on `Configuration` to attach the metadatabase: + +```swift +func appDatabase() -> any DatabaseWriter { + var configuration = Configuration() + configuration.prepareDatabase = { db in + db.attachMetadatabase() + … + } +} +``` + +This will allow you to query the ``SyncMetadata`` table, which gives you access to the `CKRecord` +stored for each of your records, as well as the `CKShare` for any shared records. + +See the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` for more information, as well +as below. + +## Designing your schema with synchronization in mind + +Distributing your app's schema across many devices is a big decision to make for your app, and +care must be taken. It is not true that you can simply take any existing schema, add a +``SyncEngine`` to it, and have it magically synchronize data across all devices and across all +versions of your app. There are a number of principles to keep in mind while designing and evolving +your schema to make sure every device can synchronize changes to every other device, no matter the +version. + +#### Globally unique primary keys + +> TL;DR: Primary keys should be globally unique identifiers, such as UUID. We further recommend +> specifying a `NOT NULL` constraint with a `ON CONFLICT REPLACE` action. + +Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a +primary key by using an `AUTOINCREMENT` integer. This makes it so that newly inserted rows get +a unique ID by simply adding 1 to the largest ID in the table. However, that does not play nicely +with distributed schemas. That would make it possible for two devices to create a record with +`id: 1`, and when those records synchronize there would be an irreconcilable conflict. + +For this reason, primary keys in SQLite tables should be _globally_ unique, such as a UUID. The +easiest way to do this is to store your table's ID in a `TEXT` column, adding a +default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint: + +```sql +CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + … +) +``` + +> Tip: The `ON CONFLICT REPLACE` clause must be placed directly after `NOT NULL`. + +This allows you to insert a row with a NULL value for the primary key and SQLite will compute +the primary key from the default value specified. This kind of pattern is commonly used with the +`Draft` type generated for primary keyed tables: + +```swift +try database.write { db in + try Reminder.upsert { + // ℹ️ Omitting 'id' allows the database to initialize it for you. + Reminder.Draft(title: "Get milk") + } + .execute(db) +} +``` + +If you would like to use a unique identifier other than the `UUID` provided by Foundation, you can +conform your identifier type to ``IdentifierStringConvertible``. We still recommend using +`NOT NULL ON CONFLICT REPLACE` on your column, as well as a default, but the default will need +to be provided outside of SQLite. You can do this by registering a function in SQLite and calling +out to it for the default value of your column: + +```sql +CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (customUUIDv7()), + … +) +``` + +Registering custom database functions for ID generation also makes it possible to generate +deterministic IDs for tests, making it easier to test your queries. + +#### Primary keys on every table + +> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in +> synchronization, even if it is not used by your app. + +_Every_ table being synchronized must have a single primary key and cannot have compound primary +keys. This includes join tables that typically only have two foreign keys pointing to the two +tables they are joining. For example, a `ReminderTag` table that joins reminders to tags should be +designed like so: + +```sql +CREATE TABLE "reminderTags" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE +) STRICT +``` + +Note that the `id` column might not be needed for your application's logic, but it is necessary to +facilitate synchronizing to CloudKit. + +#### Foreign key relationships + +> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to +> cascade deletions. + +SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, +and you can enforce foreign key constraints in your database connection. While it is possible for +the sync engine to receive records in an order that could cause a foreign key constraint failure, +such as receiving a child record before its parent, the sync engine will cache the child record +until the parent record has been synchronized, at which point the child record will also be +synchronized. + +Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`. +In particular, `RESTRICT` and `NO ACTION` are not supported, and if you try to use those actions +in your schema an error will be thrown when constructing ``SyncEngine``. + +#### Uniqueness constraints + +> TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow +> for distributed creation of records. + +Tables with unique constraints on their columns, other than on the primary key, cannot be +synchronized. As an example, suppose you have a `Tag` table with a unique constraint on the +`title` column. It is not clear how the application should handle if two different devices create +a tag with the title "Family" at the same time. When the two devices synchronize their data +they will have a conflict on the uniqueness constraint, but it would not be correct to +discard one of the tags. + +For this reason uniqueness constraints are not allowed in schemas, and this will be validated +when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be +thrown. + +Sometimes it is possible to make the column that you want to be unique into the primary key of +your table. For example, tags with a unique title could be modeled like so: + +```swift +@Table struct Tag { + let title: String +} +// CREATE TABLE "tags" ( +// "title" TEXT NOT NULL PRIMARY KEY +// ) STRICT +``` + +This will make it so that there can be at most one tag with a specific title. However, there are +important caveats to be aware of: + +> Important: The primary key of a row is encoded into the `recordName` of a `CKRecord`, along with +> the table name. There are [restrictions][CKRecord.ID] on the value of `recordName`: +> +> * It may only contain ASCII characters +> * It must be less than 255 characters +> * It must not begin with an underscore +> +> If your primary key violates any of these rules, a `DatabaseError` will be thrown with a message +> of ``SyncEngine/invalidRecordNameError``. + +[CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id + +## Backwards compatible migrations + +> TL;DR: Database migrations should be done carefully and with full backwards compatibility +> in mind in order to support multiple devices running with different schema versions. + +Migrations of a distributed schema come with even more complications than what is mentioned above. +If you ship a 1.0 of your app, and then in 1.1 you add a column to a table, you will need to +contend with the fact that users of the 1.0 will be creating records without that column. This can +cause problems if your migration is not designed correctly. + +#### Adding tables + +Adding new tables to a schema is perfectly safe thing to do in a CloudKit application. If a record +from a device is synchronized to a device that does not have that table it will cache the record +for later use. Then, when a device updates to the newest version of the app and detects a new table +has been added to the schema, it will populate the table with the cached records it received. + +#### Adding columns + +> TL;DR: When adding columns to a table that has already been deployed to users' devices, you will +either need to make the column nullable, or a default value must be provided with an +`ON CONFLICT REPLACE` clause. + +As an example, suppose the 1.0 of your app shipped a table for a reminders list: + +```swift +@Table +struct RemindersList { + let id: UUID + var title = "" +} +``` + +…and you created the SQL table for this like so: + +```sql +CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' +) STRICT +``` + +Next suppose in 1.1 you want to add a column to the `RemindersList` type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" ++ var position = 0 + } +``` + +…with the corresponding SQL migration: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 +``` + +Unfortunately this schema is problematic for synchronization. When a device running the 1.0 of the +app creates a record, it will not have the `position` field. And when that synchronizes to devices +running the 1.1 of the app, the ``SyncEngine`` will attempt to run a query that is essentially this: + +```sql +INSERT INTO "remindersLists" +("id", "title", "position") +VALUES +(NULL, 'Personal', NULL) +``` + +This will generate a SQL error because the "position" column was declared as `NOT NULL`, and so this +record will not properly synchronize to devices running a newer version of the app. + +The fix is to allow for inserting `NULL` values into `NOT NULL` columns by using the default of the +column. This can be done like so: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 +``` + +> Important: The `ON CONFLICT REPLACE` clause must come directly after `NOT NULL` because it +> modifies that constraint. + +Now when this query is executed: + +```sql +INSERT INTO "remindersLists" +("id", "title", "position") +VALUES +(NULL, 'Personal', NULL) +``` + +…it will use 0 for the `position` column. + +Sometimes it is not possible to specify a default for a newly added column. Suppose in version 1.2 +of your app you add groups for reminders lists. This can be expressed as a new field on the +`RemindersList` type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" + var position = 0 ++ var remindersListGroupID: RemindersListGroup.ID + } +``` + +However, there is no sensible default that can be used for this schema. But, if you migrate your +table like so: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "remindersListGroupID" TEXT NOT NULL +REFERENCES "remindersListGroups"("id") +``` + +…then this will be problematic when older devices create reminders lists with no +`remindersListGroupID`. In this situation you have no choice but to make the field optional in +the type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" + var position = 0 +- var remindersListGroupID: RemindersListGroup.ID ++ var remindersListGroupID: RemindersListGroup.ID? + } +``` + +And your migration will need to add a nullable column to the table: + +```diff + ALTER TABLE "remindersLists" +-ADD COLUMN "remindersListGroupID" TEXT NOT NULL ++ADD COLUMN "remindersListGroupID" TEXT + REFERENCES "remindersListGroups"("id") +``` + +It may be disappointing to have to weaken your domain modeling to accommodate synchronization, but +that is the unfortunate reality of a distributed schema. In order to allow multiple versions of your +schema to be run on devices so that each device can create new records and edit existing records +that all devices can see, you will need to make some compromises. + +#### Disallowed migrations + +Certain kinds of migrations are simply not allowed when synchronizing your schema to multiple +devices. They are: + + * Removing columns + * Renaming columns + * Renaming tables + +## Record conflicts + +> TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each +> column of the record. + +Conflicts between record edits will inevitably happen, and it's just a fact of dealing with +distributed data. The library handles conflicts automatically, but does so with a single strategy +that is currently not customizable. When a column is edited on a record, the library keeps track +of the timestamp for that particular column. When merging two conflicting records, each column +is analyzed, and the column that was most recently edited will win over the older data. + +We do not employ more advanced merge conflict strategies, such as CRDT synchronization. We may +allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is +the only strategy available and we feel serves the needs of the most number of people. + +## Sharing records with other iCloud users + +SQLiteData provides the tools necessary to share a record with another iCloud user so that +multiple users can collaborate on a single record. Sharing a record with another user brings +extra complications to an app that go beyond the existing complications of sharing a schema +across many devices. Please read the documentation carefully and thoroughly to understand +how to best situate your app for sharing that does not cause problems down the road. + +See for more information. + +## Assets + +> TL;DR: The library packages all BLOB columns in a table into `CKAsset`s and seamlessly decodes +> `CKAsset`s back into your tables. We recommend putting large binary blobs of data in their own +> tables. + +All BLOB columns in a table are automatically turned into `CKAsset`s and synchronized to CloudKit. +This process is completely seamless and you do not have to take any explicit steps to support +assets. + +However, general database design guidelines still apply. In particular, it is not recommended to +store large binary blobs in a table that is queried often. If done naively you may accidentally +large amounts of data into memory when querying your table, and further large binary blobs can +slow down SQLite's ability to efficiently access the rows in your tables. + +It is recommended to hold binary blobs in a separate, but related, table. For example, if you are +building a reminders app that has lists, and you allow your users to assign an image to a list. +One way to model this is a table for the reminders list data, without the image, and then another +table for the image data associated with a reminders list. Further, the primary key of the cover +image table can be the foreign key pointing to the associated reminders list: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} + +@Table +struct RemindersListCoverImage { + @Column(primaryKey: true) + let remindersListID: RemindersList.ID + var image: Data +} +/* +CREATE TABLE "remindersListCoverImages" ( + "remindersListID" TEXT PRIMARY KEY NOT NULL REFERENCES "remindersLists"("id"), + "image" BLOB NOT NULL +) +*/ +``` + +This allows you to efficiently query `RemindersList` while still allowing you to load the image +data for a list when you need it. + +## Accessing CloudKit metadata + +While the library tries to make CloudKit synchronization as seamless and hidden as possible, +there are times you will need to access the underlying CloudKit types for your tables and records. +The ``SyncMetadata``table is the central place where this data is stored, and it is publicly +exposed for you to query it in whichever way you want. + +> Important: In order to query the `SyncMetadata` table from your database connection you will need +to attach the metadatabase to your database connection. This can be done with the +``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. See + for more information on how to do this. + +With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method +to construct a SQL query for fetching the meta data associated with one of your records. + +For example, if you want to retrieve the `CKRecord` that is associated with a particular row in +one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to +retrieve the `CKRecord` and then invoke a CloudKit database function to retrieve all of the details: + +```swift +let lastKnownServerRecord = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.lastKnownServerRecord) + .fetchOne(db) + ?? nil +} +guard let lastKnownServerRecord +else { return } + +let ckRecord = try await container.privateCloudDatabase + .record(for: lastKnownServerRecord.recordID) +``` + +> Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is +> only appropriate if the user is the owner of the record. If the user is only a participant in +> a shared record, which can be determined from [SyncMetadata.share](), +> then you must use `sharedCloudDatabase` to fetch the newest record. + +You are free to invoke any CloudKit functions you want with the `CKRecord` retrieved from +``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your +SQLite database by the ``SyncEngine``. + +It is also possible to fetch the `CKShare` associated with a record if it has been shared, which +will give you access to the most current list of participants and permissions for the shared record: + +```swift +let share = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.share) + .fetchOne(db) +} +guard let share +else { return } + +let ckRecord = try await container.sharedCloudDatabase + .record(for: share.recordID) +``` + +> Important: In the above snippet we are using the `sharedCloudDatabase` and this is always +appropriate to use when fetching the details of a `CKShare` as they are always stored in the +shared database. + +It is also possible to join the ``SyncMetadata`` table directly to your tables so that you can +select this additional information on a per-record basis. For example, if you want to select all +reminders lists, along with a boolean that determines if it is shared or not, you can do the +following: + +```swift +@Selection struct Row { + let remindersList: RemindersList + let isShared: Bool +} + +@FetchAll( + RemindersList + .leftJoin(SyncMetadata.all) { $0.hasMetadata(in: $1) } + .select { + Row.Columns( + remindersList: $0, + isShared: $1.isShared ?? false + ) + } +) +var rows +``` + +Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/hasMetadata(in:)`` helper +that is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. + + + +## Unit testing and Xcode previews + +It is possible to run your features in tests and previews even when using the ``SyncEngine``. You +will need to prepare it for dependencies exactly as you do in the entry point of your app. This +can lead to some code duplication, and so you may want to extract that work to a mutating +`bootstrapDatabase` method on `DependencyValues` like so: + +```swift +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} +``` + +Then in your app entry point you can use it like so: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + try! $0.bootstrapDatabase() + } + } + + // ... +} +``` + +In tests you can use it like so: + +```swift +@Suite(.dependencies { try! $0.bootstrapDatabase() }) +struct MySuite { + // ... +} +``` + +And in preivews you can use it like so: + +```swift +#Preview { + try! prepareDependencies { + try! $0.bootstrapDatabase() + } + // ... +} +``` + +## Preparing an existing schema for synchronization + +If you have an existing app deployed to the app store using SQLite, then there may be a number +of steps you must take to prepare for adding CloudKit synchronization, mostly having to do with +primary keys. + +### 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 +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. + +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 +migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + -- all other columns from 'remindersLists' table + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'remindersLists' to 'new_remindersLists' and convert integer + // IDs to UUIDs + try #sql(""" + INSERT INTO "new_remindersLists" + ( + "id", + -- all other columns from 'remindersLists' table + ) + SELECT + -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001 + '00000000-0000-0000-0000-' || printf('%012x', "id"), + -- all other columns from 'remindersLists' table + FROM "remindersLists" + """) + .execute(db) + + // Step 3: Drop the old 'remindersLists' table + try #sql(""" + DROP TABLE "remindersLists" + """) + .execute(db) + + // Step 4: Rename 'new_remindersLists' to 'remindersLists' + try #sql(""" + ALTER TABLE "new_remindersLists" RENAME TO "remindersLists" + """) + .execute(db) +} +``` + +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: + +```swift +migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, + -- all other columns from 'reminders' table + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'reminders' to 'new_reminders' and convert integer + // IDs to UUIDs + try #sql(""" + INSERT INTO "new_reminders" + ( + "id", + "remindersListID", + -- all other columns from 'reminders' table + ) + SELECT + -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001 + '00000000-0000-0000-0000-' || printf('%012x', "id"), + '00000000-0000-0000-0000-' || printf('%012x', "remindersListID"), + -- all other columns from 'reminders' table + FROM "remindersLists" + """) + .execute(db) + + // Step 3 and 4 are unchanged... +} +``` + +### Add primary key to all tables + +All tables must have a primary key to be synchronized to CloudKit, even typically you would not +add one to the table. For example, a join table that joins reminders to tags: + +```swift +@Table +struct ReminderTag { + let reminderID: Reminder.ID + let tagID: Tag.ID +} +``` + +…must be updated to have a primary key: + + +```diff + @Table + struct ReminderTag { ++ let id: UUID + let reminderID: Reminder.ID + let tagID: Tag.ID + } +``` + +And a migration must be run to add that column to the table. However, you must perform a multi-step +migration similar to what is described above in . +You must 1) create a new table with the new primary key column, 2) copy data from the old table +to the new table, 3) delete the old table, and finally 4) rename the new table. + +Here is how such a migration can look like for the `ReminderTag` table above: + +```swift +migrator.registerMigration("Add primary key to 'reminderTags' table") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_reminderTags" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'reminderTags' to 'new_reminderTags' + try #sql(""" + INSERT INTO "new_reminderTags" + ("reminderID", "tagID") + SELECT "reminderID", "tagID" + FROM "reminderTags" + """) + .execute(db) + + // Step 3: Drop the old 'reminderTags' table + try #sql(""" + DROP TABLE "reminderTags" + """) + .execute(db) + + // Step 4: Rename 'new_reminderTags' to 'reminderTags' + try #sql(""" + ALTER TABLE "new_reminderTags" RENAME TO "reminderTags" + """) + .execute(db) +} +``` + +## Tips and tricks + +### Updating triggers to be compatible with synchronization + +If you have triggers installed on your tables, then you may want to customize their definitions +to behave differently depending on whether a write is happening to your database from your own +code or from the sync engine. For example, if you have a trigger that refreshes an `updatedAt` +timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine +updates a row from data received from CloudKit. But, if you have a trigger that updates a local +[FTS] index, then you would want to perform that work regardless if your app is updating the data +or CloudKit is updating the data. + +[FTS]: https://sqlite.org/fts5.html + +To customize this behavior you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. +It represents a custom database function that is installed in your database connection, and it will +return true if the write to your database originates from the sync engine. You can use it in a +trigger like so: + +```swift +#sql( + """ + CREATE TEMPORARY TRIGGER "…" + AFTER DELETE ON "…" + FOR EACH ROW WHEN NOT \(SyncEngine.isSynchronizingChanges()) + BEGIN + … + END + """ +) +``` + +Or if you are using the trigger building tools from [StructuredQueries] you can use it like so: + +[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries + +```swift +Model.createTemporaryTrigger( + after: .insert { new in + // ... + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } +) +``` + +This will skip the trigger's action when the row is being updated due to data being synchronized +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 +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/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md new file mode 100644 index 00000000..01e5e32e --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -0,0 +1,473 @@ +# Sharing data with other iCloud users + +Learn how to allow your users to share certain records with other iCloud users for collaboration. + +## Overview + +SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple +users can collaborate on a single record. Sharing a record with another user brings extra +complications to an app that go beyond the existing complications of sharing a schema across many +devices. Please read the documentation carefully and thoroughly to understand how to best design +your schema for sharing that does not cause problems down the road. + +> Important: To enable sharing of records be sure to add a `CKSharingSupported` key to your +Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. + +[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic + + - [Creating CKShare records](#Creating-CKShare-records) + - [Accepting shared records](#Accepting-shared-records) + - [Diving deeper into sharing](#Diving-deeper-into-sharing) + - [Sharing root records](#Sharing-root-records) + - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) + - [One-to-many relationships](#One-to-many-relationships) + - [Many-to-many relationships](#Many-to-many-relationships) + - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) + - [Sharing permissions](#Sharing-permissions) + - [Controlling what data is shared](#Controlling-what-data-is-shared) + - [Querying share metadata](#Querying-share-metadata) + +## Creating CKShare records + +To share a record with another user one must first create a `CKShare`. SQLiteData provides the +method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` for a +record. Further, the value returned from this method can be stored in a view and be used to drive a +sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's +`UICloudSharingController`. + +As an example, a reminders app that wants to allow sharing a reminders list with another user can do +so like this: + +```swift +struct RemindersListView: View { + let remindersList: RemindersList + @State var sharedRecord: SharedRecord? + + var body: some View { + Form { + … + } + .toolbar { + Button("Share") { + Task { + await withErrorReporting { + sharedRecord = try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)'!" + } + } + } + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } + } +} +``` + +When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state in +the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can configure +how they want to share the record. A record can be _unshared_ by presenting the same +``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. + +If you would like to provide a custom sharing experience outside of what `UICloudSharingController` +offers, you can find more info in [Apple's documentation]. + +[Apple's documentation]: https://developer.apple.com/documentation/cloudkit/shared-records + +## Accepting shared records + +Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the +share link sent to them (whether that is by text, email, etc.), the app will be launched with +special options provided or a special delegate method will be invoked in the app's scene delegate. +You must implement these delegate methods and invoke the ``SyncEngine/acceptShare(metadata:)`` +method. + +As a simplified example, a `UIWindowSceneDelegate` subclass can implement the delegate method like +so: + +```swift +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} +``` + +The unstructured task is necessary because the delegate method does not work with an async context, +and the `acceptShare` method is async. + +## Diving deeper into sharing + +The above gives a broad overview of how one shares a record with a user, and how a user accepts a +shared record. There is, however, a lot more to know about sharing. There are important restrictions +placed on what kind of records you are allowed to share, and what associations of those records are +shared. + +In a nutshell, only "root" records can be directly shared, _i.e._ records with no foreign keys. +Further, an association of a root record can only be shared if it has only one foreign key pointing +to the root record. And this last rule applies recursively: a leaf association is shared only if +it has exactly one foreign key pointing to a record that also satisfies this property. + +For more in-depth information, keep reading. + +### Sharing root records + +> Important: It is only possible to share "root" records, _i.e._ records with no foreign keys. + +A record can be shared only if it is a "root" record. That means it cannot have any +foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because +it does not have any fields pointing to other tables: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} +``` + +On the other hand, a `Reminder` table with a foreign key pointing to the `RemindersList` is _not_ +a root record: + +```swift +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Such records cannot be shared because it is not appropriate to also share the parent record (_i.e._ +the reminders list). + +For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this +reminder with someone, then it becomes difficult to figure out what to do when they make certain +changes to the reminder: + + * If they decide to reassign the reminder to their personal "Life" list, what should + happen? Should their "Life" list suddenly be synchronized to your device? + * Or what if they delete the list? Would you want that to delete your list and all of the + reminders in the list? + +For these reasons, and more, it is not possible to share non-root records, like reminders. Instead, +you can share root records, like reminders lists. If you do invoke +``SyncEngine/share(record:configure:)`` with a non-root record, an error will be thrown. + +> Note: A reminder can still be shared as an association to a shared reminders list, as discussed +> [in the next section](). However, a single +> reminder cannot be shared on its own. + +For a more complex example, consider the following diagrammatic schema for a reminders app: + +@Image(source: "sync-diagram-root-record.png") { + The green node represents a "root" record, i.e. a record with no foreign key relationships. +} + +In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a +`Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes +a "root" is `RemindersList`. It is the only one with no foreign key relationships. None of +`Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own because they +are not root tables. + +#### Sharing foreign key relationships + +> Important: Foreign key relationships are automatically synchronized, but only if the related +> record has a single foreign key. Records with multiple foreign keys cannot be synchronized. + +Relationships between models will automatically be shared when sharing a root record, but with some +limitations. An associated record of a shared record will only be shared if it has exactly one +foreign key pointing to the root shared record, whether directly or indirectly through other records +satisfying this property. + +Below we describe some of the most common types of relationships in SQL databases, as well as +which are possible to synchronize, which cannot be synchronized, and which can be adapted to +play nicely with synchronization. + +##### One-to-many relationships + +One-to-many relationships are the simplest to share with other users. As an example, consider a +`RemindersList` table that can have many `Reminder`s associated with it: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} + +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since +`Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared. + +Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a +`Reminder`: + +```swift +@Table +struct ChildReminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var parentReminderID: Reminders.ID +} +``` + +This too will be shared because it has one single foreign key pointing to a table that also has one +single foreign key pointing to the root record being shared. + +As a more complex example, consider the following diagrammatic schema: + +@Image(source: "sync-diagram-one-to-many.png") { + The green node is a shareable root record, and all blue records are relationships that will also + be shared when the root is shared. +} + +In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` can +have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, cover +image, and even child reminders. The child reminders are synchronized because it has a single +foreign key pointing to a table that also has a single foreign key pointing to the root record. + +##### Many-to-many relationships + +Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a table +has multiple foreign keys, then it will not be shared even if one of those foreign keys points to +the shared record. + +As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a +`ReminderTag` join table: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" +} +@Table +struct ReminderTag: Identifiable { + let id: UUID + var reminderID: Reminder.ID + var tagID: Tag.ID +} +``` + +In diagrammatic form, this schema looks like the following: + +@Image(source: sync-diagram-many-to-many.png) { + The green record is a shareable record, the blue record will be shared when the root is shared, + and the light purple records cannot be shared. +} + +The `ReminderTag` records will _not_ be shared because it has two foreign key relationships, +represented by the two arrows leaving the `ReminderTag` node. As a consequence, the `Tag` records +will also not be shared. Sharing these records cannot be done in a consistent and logical manner. + +> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing +> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. Sharing +> a list of reminders with another use does not share its tags with that user. + +To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which +holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag +were shared with your friend, then what happens when they delete the tag? Would it be appropriate to +delete that tag from all of your reminders, even the ones that were not shared? For this reason, +and more, records with multiple foreign keys cannot be shared with a record. + +If you want to support many tags associated with a single reminder, you will have no choice +but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" + var reminderID: Reminder.ID +} +``` + +In diagrammatic form this schema now looks like the following: + +@Image(source: sync-diagram-many-to-many-refactor.png) { + The green record is a shareable root record, and the blue records will be shared when the root is + shared. +} + +This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will +automatically share all of its `Reminder`s, which will subsequently also share all of their +`Tag`s. + +But, this does now mean it's possible to have multiple `Tag` rows in the database that have the +same title and thus represent the same tag. You wil have to put extra care in your queries and +application logic to properly aggregate these tags together, but luckily this is something that SQL +excels at. + +##### One-to-"at most one" relationships + +One-to-"at most one" relationships in SQLite allow you to associate zero or one records with +another record. For an example of this, suppose we wanted to hold onto a cover image for reminders +lists (see for more information on synchronizing assets such as images). It +is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one +should put this data in a separate table. + +The way to model this kind of relationship in SQLite is by making a foreign key point from the image +table to the reminders list table, _and_ to make that foreign key the primary key of the table. That +enforces that at most one image is associated with a reminders list. + +In diagrammatic form, it looks like this: + +![One-to-"at most one" relationship with uniqueness](sync-diagram-one-to-at-most-one-unique.png) + + +Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but since +it is also the primary key of the table it enforces that at most one cover image belongs to a list. + +## Sharing permissions + +CloudKit sharing supports permissions so that you can give read-only or read-write access to the +data you share with other users. These permissions are automatically observed by the library and +enforced when writing to your database. If your application tries to write to a record that it +does not have permission for, a `DatabaseError` will be emitted. + +To check for this error you can catch `DatabaseError` and compare its message to +``SyncEngine/writePermissionError``: + +```swift +do { + try await database.write { db in + Reminder.find(id) + .update { $0.title = "Personal" } + .execute(db) + } +} catch let error as DatabaseError where error.message == SyncEngine.writePermissionError { + // User does not have permission to write to this record. +} +``` + +See for more information on accessing the metadata +associated with your user's data. + +Ideally your app would not allow the user to write to records that they do not have permissions for. +To check their permissions for a record, you can join the root record table to ``SyncMetadata`` and +select the ``SyncMetadata/share`` value: + +```swift +let share = try await database.read { db in + RemindersList + .metadata(for: id) + .select(\.share) + .fetchOne(db) + ?? nil +} +guard + share?.currentUserParticipant?.permission == .readWrite + || share?.permission == .readWrite +else { + // User does not have permissions to write to record. + return +} +``` + +This allows you to determine the sharing permissions for a root record. + +## Controlling what data is shared + +It is possible to specify that certain associations that are shareable not be shared. For example, +suppose that you want reminders lists to be sorted by your user, and so add a `position` column to +the table: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var position = 0 + var title = "" +} +``` + +Sharing this record will mean also sharing the position of the list. That means when one user +reorders their local lists, even ones that are private to them, it will reorder the lists for +everyone shared. This is probably not what you want. + +So, private and non-shareable information about this record can be stored in a separate table, and +we can use the trick mentioned in by making +the foreign key of the table also be the table's primary key: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} +@Table +struct RemindersListPrivate: Identifiable { + @Column(primaryKey: true) + let remindersListID: RemindersList.ID + var position = 0 +} +``` + +And then when creating the ``SyncEngine`` we can specifically ask it to not share this record when +the reminders list is shared by specifying the `privateTables` argument: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self, + privateTables: RemindersListPrivate.self + ) + } + } + + … +} +``` + +This table will still be synchronized across all of a single user's devices, but if that user +shares a list with a friend, it will _not_ share the private table, allowing each user to have +their own personal ordering of lists. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md similarity index 80% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md rename to Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md index 9cac0d12..3f2eadfb 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -1,10 +1,10 @@ # Comparison with SwiftData -Learn how SharingGRDB compares to SwiftData when solving a variety of problems. +Learn how SQLiteData compares to SwiftData when solving a variety of problems. ## Overview -The SharingGRDB library can replace SwiftData for many kinds of apps, and provide additional +The SQLiteData library can replace SwiftData for many kinds of apps, and provide additional benefits such as direct access to the underlying SQLite schema, and better integration outside of SwiftUI views (including UIKit, `@Observable` models, _etc._). This article describes how the two approaches compare in a variety of situations, such as setting up the data store, fetching data, @@ -21,12 +21,13 @@ associations, and more. * [Migrations](#Migrations) * [Lightweight migrations](#Lightweight-migrations) * [Manual migrations](#Manual-migrations) + * [CloudKit](#CloudKit) * [Supported Apple platforms](#Supported-Apple-platforms) ### Defining your schema -Both SharingGRDB and SwiftData come with tools to expose your data types' fields to the compiler -so that type-safe and schema-safe queries can be written. SharingGRDB uses another library of ours +Both SQLiteData and SwiftData come with tools to expose your data types' fields to the compiler +so that type-safe and schema-safe queries can be written. SQLiteData uses another library of ours to provide these tools, called [StructuredQueries][sq-gh], and its `@Table` macro works similarly to SwiftData's `@Model` macro: @@ -35,12 +36,12 @@ to SwiftData's `@Model` macro: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" - var isInStock = true + var isInStock = true var notes = "" } ``` @@ -54,8 +55,8 @@ to SwiftData's `@Model` macro: var isInStock: Bool var notes: String init( - title: String = "", - isInStock: Bool = true, + title: String = "", + isInStock: Bool = true, notes: String = "" ) { self.title = title @@ -81,15 +82,15 @@ to define your schema. ### Setting up external storage -Both SharingGRDB and SwiftData require some work to be done at the entry point of the app in order -to set up the external storage system that will be used throughout the app. In SharingGRDB we use +Both SQLiteData and SwiftData require some work to be done at the entry point of the app in order +to set up the external storage system that will be used throughout the app. In SQLiteData we use the `prepareDependencies` function to set up the default database used, and in SwiftUI you construct a `ModelContainer` and propagate it through the environment: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @main struct MyApp: App { init() { @@ -108,11 +109,11 @@ a `ModelContainer` and propagate it through the environment: // SwiftData @main struct MyApp: App { - let container = { + let container = { // Create/configure a container try! ModelContainer(/* ... */) }() - + var body: some Scene { WindowGroup { ContentView() @@ -125,21 +126,21 @@ a `ModelContainer` and propagate it through the environment: } See for more advice on the various ways you will want to create and -configure your SQLite database for use with SharingGRDB. +configure your SQLite database for use with SQLiteData. ### Fetching data for a view -To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SharingGRDB, +To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SQLiteData, whereas you use the `@Query` macro with SwiftData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData struct ItemsView: View { @FetchAll(Item.order(by: \.title)) var items - + var body: some View { ForEach(items) { item in Text(item.name) @@ -154,7 +155,7 @@ whereas you use the `@Query` macro with SwiftData: struct ItemsView: View { @Query(sort: \Item.title) var items: [Item] - + var body: some View { ForEach(items) { item in Text(item.name) @@ -165,8 +166,8 @@ whereas you use the `@Query` macro with SwiftData: } } -The `@FetchAll` property wrapper takes a variety of options and allows you to write queries using a -type-safe and schema-safe builder syntax, or you can write safe SQL strings that are schema-safe and +The `@FetchAll` property wrapper takes a variety of options and allows you to write queries using a +type-safe and schema-safe builder syntax, or you can write safe SQL strings that are schema-safe and protect you from SQL injection. The library also ships a few other property wrappres that have no equivalent in SwiftData. For @@ -186,8 +187,8 @@ data from your database using the tools of this library. ### Fetching data for an @Observable model There are many reasons one may want to move logic out of the view and into an `@Observable` model, -such as allowing to unit test your feature's logic, and making it possible to deep link in your -app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as +such as allowing to unit test your feature's logic, and making it possible to deep link in your +app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as well in an `@Observable` model as they do in a SwiftUI view. The state held in the property wrapper automatically updates when changes are made to the database. @@ -198,7 +199,7 @@ its functionality from scratch: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Observable class FeatureModel { @ObservationIgnored @@ -227,11 +228,11 @@ its functionality from scratch: } fetchItems() } - + deinit { NotificationCenter.default.removeObserver(observer) } - + func fetchItems() { do { items = try modelContext.fetch( @@ -247,7 +248,7 @@ its functionality from scratch: } } -> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the +> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the > `@Observable` macro due to how macros interact with property wrappers. However, `@FetchAll` > handles its own observation, and so state will still be observed when accessed in a view. @@ -260,11 +261,11 @@ search for rows in a table: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData struct ItemsView: View { @State var searchText = "" @FetchAll var items: [Item] - + var body: some View { ForEach(items) { item in Text(item.name) @@ -274,7 +275,7 @@ search for rows in a table: await updateSearchQuery() } } - + func updateSearchQuery() { await $items.load( .fetchAll( @@ -292,7 +293,7 @@ search for rows in a table: // SwiftData struct ItemsView: View { @State var searchText = "" - + var body: some View { SearchResultsView( searchText: searchText @@ -300,18 +301,18 @@ search for rows in a table: .searchable(text: $searchText) } } - + struct SearchResultsView: View { @Query var items: [Item] - + init(searchText: String) { _items = Query( - filter: #Predicate { - $0.title.contains(searchText) + filter: #Predicate { + $0.title.contains(searchText) } ) } - + var body: some View { ForEach(items) { item in Text(item.name) @@ -322,10 +323,10 @@ search for rows in a table: } } -Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`, +Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`, holds onto the `searchText` state that the user can change and uses the `searchable` SwiftUI view modifier. Then, the inner view, `SearchResultsView`, holds onto the `@Query` state so that it can -initialize with a dynamic predicate based on the `searchText`. These two views are necessary +initialize with a dynamic predicate based on the `searchText`. These two views are necessary because `@Query` state is not mutable after it is initialized. The only way to change `@Query` state is if the view holding it is reinitialized, which requires a parent view to recreate the child view. @@ -347,7 +348,7 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database ``` } @@ -359,14 +360,14 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope } } -Then, to create a new row in a table you use the `write` and `insert` methods from SharingGRDB: +Then, to create a new row in a table you use the `write` and `insert` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in try Item.insert(Item(/* ... */)) .execute(db) @@ -377,7 +378,7 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr ```swift // SwiftData @Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -385,14 +386,14 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr } } -To update an existing row you can use the `write` and `update` methods from SharingGRDB: +To update an existing row you can use the `write` and `update` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database - + existingItem.title = "Computer" try database.write { db in try Item.update(existingItem).execute(db) @@ -403,21 +404,21 @@ To update an existing row you can use the `write` and `update` methods from Shar ```swift // SwiftData @Environment(\.modelContext) var modelContext - + existingItem.title = "Computer" try modelContext.save() ``` } } -And to delete an existing row, you can use the `write` and `delete` methods from SharingGRDB: +And to delete an existing row, you can use the `write` and `delete` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in try Item.delete(existingItem).execute(db) } @@ -427,7 +428,7 @@ And to delete an existing row, you can use the `write` and `delete` methods from ```swift // SwiftData @Environment(\.modelContext) var modelContext - + modelContext.delete(existingItem)) try modelContext.save() ``` @@ -436,8 +437,8 @@ And to delete an existing row, you can use the `write` and `delete` methods from ### Associations -The biggest difference between SwiftData and SharingGRDB is that SwiftData provides tools for an -Object Relational Mapping (ORM), whereas SharingGRDB is largely just a nice API for interacting with SQLite +The biggest difference between SwiftData and SQLiteData is that SwiftData provides tools for an +Object Relational Mapping (ORM), whereas SQLiteData is largely just a nice API for interacting with SQLite directly. For example, SwiftData allows you to model a `Sport` type that belongs to many `Team`s like @@ -469,11 +470,11 @@ mechanism to work is for `Team` and `Sport` to be classes, and the `@Model` macr Second, because the SQLite execution is so abstracted from us, it makes it easy to execute many, _many_ queries, leading to inefficient code. In this case, we are first executing a query to get all sports, and then executing a query for each sport to get the number of teams in each -sport. And on top of that, we are loading every team into memory just to compute the number of +sport. And on top of that, we are loading every team into memory just to compute the number of teams. We don't actually need any data from the team, only their aggregate count. -SharingGRDB does not provide these kinds of tools, and for good reason. Instead, if you know you -want to fetch all of the teams with their corresponding sport, you can simply perform a single +SQLiteData does not provide these kinds of tools, and for good reason. Instead, if you know you +want to fetch all of the teams with their corresponding sport, you can simply perform a single query that joins the two tables together: ```swift @@ -498,12 +499,12 @@ If either of the "sports" or "teams" tables change, this query will be executed state will update to the freshest values. This style of handling associations does require you to be knowledgable in SQL to wield it -correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of +correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of technologies in the history of computers, and knowing how to wield their powers is a huge benefit. ### Booleans and enums -While it may be hard to believe at first, SwiftData does not fully support boolean or enum values +While it may be hard to believe at first, SwiftData does not fully support boolean or enum values for fields of a model. Take for example this following model: ```swift @@ -512,11 +513,11 @@ class Reminder { var isCompleted = false var priority: Priority? init(isCompleted: Bool = false, priority: Priority? = nil) { - self.isCompleted = isCompleted + self.isCompleted = isCompleted self.priority = priority } - enum Priority: Int, Codable { + enum Priority: Int, Codable { case low, medium, high } } @@ -548,7 +549,7 @@ class Reminder { var isCompleted = 0 var priority: Int? init(isCompleted: Int = 0, priority: Int? = nil) { - self.isCompleted = isCompleted + self.isCompleted = isCompleted self.priority = priority } } @@ -563,14 +564,14 @@ var highPriorityReminders: [Reminder] This will now work, but of course these fields can now hold over 9 quintillion possible values when only a few values are valid. -On the other hand, booleans and enums work just fine in SharingGRDB: +On the other hand, booleans and enums work just fine in SQLiteData: ```swift @Table struct Reminder { var isCompleted = false var priority: Priority? - enum Priority: Int, QueryBindable { + enum Priority: Int, QueryBindable { case low, medium, high } } @@ -594,7 +595,7 @@ can even leave off the type annotation for `reminders` because it is inferred fr explicit where you make direct changes to the schemas in your database. This includes creating tables, adding, removing or altering columns, adding or removing indices, and more. -Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work +Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work implicitly by comparing your data types to the database schema and updating the schema accordingly. That cannot always work, and so there are "manual" migrations where you explicitly describe how to change the database schema. @@ -606,14 +607,14 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true } - + migrator.registerMigration("Create 'items' table") { db in try #sql( """ @@ -640,7 +641,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a } } -Note that in GRDB we must explicitly create the table, specify its columns, as well as its +Note that in GRDB we must explicitly create the table, specify its columns, as well as its constraints, such as if it is nullable or has a default value. Similarly, adding a column to a data type is also a lightweight migration in SwiftData, such as @@ -651,16 +652,16 @@ adding a `description` field to the `Item` type: ```swift @Table struct Item { - let id: Int + let id: UUID var title = "" var description = "" var isInStock = true } - + migrator.registerMigration("Add 'description' column to 'items'") { db in try #sql( """ - ALTER TABLE "items" + ALTER TABLE "items" ADD COLUMN "description" TEXT """ ) @@ -681,21 +682,21 @@ adding a `description` field to the `Item` type: } } -In each of these cases, the lightweight migration of SwiftData is less code and the actual +In each of these cases, the lightweight migration of SwiftData is less code and the actual migration logic is implicit and hidden away from you. #### Manual migrations However, unfortunately, not all migrations can be "lightweight". In fact, from our experience, -real world apps tend to require complex logic when performing most migrations. Something as simple +real world apps tend to require complex logic when performing most migrations. Something as simple as changing an optional field to be a non-optional field cannot be done as a lightweight migration -since SwiftData does not know what value to insert into the database for any rows with a NULL +since SwiftData does not know what value to insert into the database for any rows with a NULL value. Even adding a unique index to a column is not possible because that may introduce constraint errors if two rows have the same value. -For the times that a lightweight migration is not possible in SwiftData, one must turn to +For the times that a lightweight migration is not possible in SwiftData, one must turn to "manual" migrations via the `VersionedSchema` protocol. As an example, consider adding a unique -index on the "title" column of the "items" table. +index on the "title" column of the "items" table. In GRDB this is a simple two-step process: @@ -704,7 +705,7 @@ In GRDB this is a simple two-step process: incorporate a "#" suffix to differentiate between items with the same name. 1. Add the unique index. -In SwiftData this is a much more involved process since migrations are implicitly tied to the +In SwiftData this is a much more involved process since migrations are implicitly tied to the structure of your data types. The overall steps to follow are as such: 1. Create a type that conforms to the `VersionedSchema` protocol, which represents the current @@ -714,7 +715,7 @@ structure of your data types. The overall steps to follow are as such: 1. Duplicate the entire `@Model` data type so that you can specify the unique index. This type will need a new name so as to not conflict with the current, and so often it is nested in the type created in the previous step. - 1. Because you now have different data types representing `Item` it is customary to add a + 1. Because you now have different data types representing `Item` it is customary to add a type alias that represents the most "current" version of the `Item`. 1. Create a type that conforms to the `SchemaMigrationPlan` which allows you to specify the "stages" that will be executed when a migration is performed. @@ -726,7 +727,7 @@ structure of your data types. The overall steps to follow are as such: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData migrator.registerMigration("Make 'title' unique") { db in // 1️⃣ Delete all items that have duplicate title, keeping the first created one: try Item @@ -742,8 +743,8 @@ structure of your data types. The overall steps to follow are as such: // 2️⃣ Create unique index try #sql( """ - CREATE UNIQUE INDEX - "items_title" ON "items" ("title") + CREATE UNIQUE INDEX + "items_title" ON "items" ("title") """ ) .execute(db) @@ -763,7 +764,7 @@ structure of your data types. The overall steps to follow are as such: var isInStock = true } } - + // 2️⃣ Create type to conform to VersionedSchema: enum Schema2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) @@ -777,19 +778,19 @@ structure of your data types. The overall steps to follow are as such: var isInStock = true } } - + // 4️⃣ Create a type alias for the newest Item schema: typealias Item = Schema2.Item - + // 5️⃣ Create a type to conform to the SchemaMigrationPlan protocol: enum MigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ - Schema1.self, + Schema1.self, Schema2.self ] } - + // 6️⃣ Create MigrationStage values to implement the logic for migration from one schema // to the next: static var stages: [MigrationStage] { @@ -816,12 +817,12 @@ structure of your data types. The overall steps to follow are as such: } } try context.save() - } didMigrate: { _ in + } didMigrate: { _ in } ] } } - + // 7️⃣ Create ModelContainer with migration plan in entry point of app: @main struct MyApp: App { @@ -844,27 +845,109 @@ Some things to note about the above comparison: with a duplicate title (keeping the first) by using a subquery. * The SwiftData migration is many, many times longer than the equivalent SQLite version involving many intricate steps that are hard to remember and easy to get wrong. - * Because database schemas are tightly coupled to type definitions we have no choice but to + * Because database schemas are tightly coupled to type definitions we have no choice but to duplicate our data type so that we can apply the `@Attribute(.unique)` macro. - * Further, we will need to move all helper methods and computed properties from the previous + * Further, we will need to move all helper methods and computed properties from the previous version of the data type to the new version. * The work in step #6 that deletes items if they have a duplicate titles is very inefficient, but it's not possible to make much more efficient. SwiftData does not provide us with tools to run - raw SQL on the tables, and so we have no choice but to load all of the items into memory and - manually check for unique titles. This is memory intensive and CPU intensive work and may - require extra attention if there are thousands of items in the table. On the other hand, SQLite - can perform this work efficiently on millions of rows without ever loading a single `Item` into + raw SQL on the tables, and so we have no choice but to load all of the items into memory and + manually check for unique titles. This is memory intensive and CPU intensive work and may + require extra attention if there are thousands of items in the table. On the other hand, SQLite + can perform this work efficiently on millions of rows without ever loading a single `Item` into memory. -So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that +So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that complex "manual" migrations are common enough that one should optimize for them rather than the other way around. +### CloudKit + +Both SQLiteData and SwiftData support basic synchronization of models to CloudKit so that data +can be made available on all of a user's devices. However, SQLiteData also supports sharing records +with other iCloud users, and it exposes the underlying CloudKit data types (e.g. `CKRecord`) so +that you can interact directly with CloudKit if needed. + +Setting up a database and sync engine in SQLiteData isn't much different from setting up a +SwiftData stack with CloudKit. The main difference is that one must explicitly provide the +container identifier in SQLiteData because SwiftData has been privileged in being able to +inspect the Entitlements.plist in order to automatically extract that information: + +@Row { + @Column { + ```swift + // SQLiteData + @main + struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self + ) + } + } + + … + } + ``` + } + @Column { + ```swift + // SwiftData + @main + struct MyApp: App { + let modelContainer: ModelContainer + init() { + let schema = Schema([ + Reminder.self, + RemindersList.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema) + modelContainer = try! ModelContainer( + for: schema, + configurations: [modelConfiguration] + ) + } + + … + } + ``` + } +} + +Once this initial set up is performed, all insertions, updates and deletions from the database +will be automatically synchronized to CloudKit. + +SwiftData also has a few limitations in what features you are allowed to use in your schema: + +* Unique constraints are not allowed on columns. +* All properties on a model must be optional or have a default value. +* All relationships must be optional. + +SQLiteData has only one of these limitations: + +* Unique constraints on columns (except for the primary key) cannot be upheld on a distributed +schema. For example, if you have a `Tag` table with a unique `title` column, then what +are you to do if two different devices create a tag with the title "family" at the same time? +See for more information. +* Columns on freshly created tables do not need to have default values or be nullable. Only +newly added columns to existing tables need to either be nullable or have a default. See + for more info. +* Relationships on freshly created do not need to be nullable. Only newly added columns to +existing tables need to be nullable. See for more info. + +For more information about requirements of your schema in order to use CloudKit synchronization, +see and +, and for more general +information about CloudKit synchronization, see . + ### Supported Apple platforms -SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and +SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and some newer features require even more recent versions of iOS. -Meanwhile, SharingGRDB has a broad set of deployment targets supporting all the way back to iOS 13, -macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any +Meanwhile, SQLiteData has a broad set of deployment targets supporting all the way back to iOS 13, +macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any application today with no restrictions. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md similarity index 90% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md rename to Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md index 84322a82..9d4a7dca 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md @@ -40,11 +40,11 @@ It fetches _all_ items from the backing database into memory and then Swift does filtering, sorting, and truncating this data before it is displayed to the user. This means if the table contains thousands, or even hundreds of thousands of rows, every single one will be loaded into memory and processed, which is incredibly inefficient to do. Worse, this work will be performed -every single time `displayedItems` is evaluated, which will be at least once for each time the +every single time `displayedItems` is evaluated, which will be at least once for each time the view's body is computed, but could also be more. This kind of data processing is exactly what SQLite excels at, and so we can offload this work by -modifying the query itself. One can do this with SharingGRDB by using the `load` method on +modifying the query itself. One can do this with SQLiteData by using the `load` method on ``FetchAll``, ``FetchOne`` or ``Fetch`` in order to load a new key, and hence execute a new query: ```swift @@ -87,5 +87,8 @@ struct ContentView: View { > Important: If a parent view refreshes, a dynamically-updated query can be overwritten with the > initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query -> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying +> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying > `FetchAll` value you can use `wrappedValue`. +> +> This only happens when using `@FetchAll`/`@FetchOne`/`@Fetch` directly in a view, and does not +> affect using these tools elsewhere in your application. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md similarity index 85% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md rename to Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index 4f65e426..e2b2d380 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -1,6 +1,7 @@ # Fetching model data -Learn about the various tools for fetching data from a SQLite database. +Learn how to use the `@FetchAll`, `@FetchOne` and `@Fetch` property wrappers for performing +SQL queries to load data from your database. ## Overview @@ -16,17 +17,17 @@ queries in a single transaction. ### @FetchAll The [`@FetchAll`]() property wrapper allows you to fetch a collection of results from -your database using a SQL query. The query is created using our +your database using a SQL query. The query is created using our [StructuredQueries][structured-queries-gh] library, which can build type-safe queries that safely and performantly decode into Swift data types. -To get access to these tools you must apply the `@Table` macro to your data type that represents +To get access to these tools you must apply the `@Table` macro to your data type that represents your table: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -40,15 +41,15 @@ simply doing: @FetchAll var reminders: [Reminder] ``` -If you want to execute a more complex query, such as one that sorts the results by the reminder's -title, then you can use the various query building APIs on `Reminder`: +If you want to execute a more complex query, such as one that sorts the results by the reminder's +title, then you can use the various query building APIs on `Reminder`: ```swift @FetchAll(Reminder.order(by: \.title)) var reminders ``` -Or if you want to only select the completed reminders, sorted by their titles in a descending +Or if you want to only select the completed reminders, sorted by their titles in a descending fashion: ```swift @@ -68,7 +69,7 @@ You can even execute a SQL string to populate the data in your features: var completedReminders: [Reminder] ``` -This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are +This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are automatically protected from SQL injection attacks, and it is even possible to use the static description of your schema to prevent accidental typos: @@ -97,7 +98,7 @@ exactly one list: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -105,7 +106,7 @@ struct Reminder { } @Table struct RemindersList: Identifiable { - let id: Int + let id: UUID var title = "" } ``` @@ -123,7 +124,7 @@ struct Record { } ``` -And then we construct a query that joins the `Reminder` table to the `RemindersList` table and +And then we construct a query that joins the `Reminder` table to the `RemindersList` table and selects the titles from each table: ```swift @@ -132,7 +133,7 @@ selects the titles from each table: .join(RemindersList.all) { $0.remindersListID.eq($1.id) } .select { Record.Columns( - reminderTitle: $0.title, + reminderTitle: $0.title, remindersListTitle: $1.title ) } @@ -151,9 +152,9 @@ you must construct a dedicated `FetchDescriptor` value and set its `propertiesTo ### @FetchOne The [`@FetchOne`]() property wrapper works similarly to `@FetchAll`, but fetches -only a single record from the database and you must provide a default for when no record is found. -This tool can be handy for computing aggregate data, such as the number of reminders in the -database: +only a single record from the database and you must provide a default for when no record is found or +use an optional value. This tool can be handy for computing aggregate data, such as the number of +reminders in the database: ```swift @FetchOne(Reminder.count()) @@ -179,9 +180,9 @@ var completedRemindersCount = 0 It is also possible to execute multiple database queries to fetch data for your features. This can be useful for performing several queries in a single database transaction: -Each instance of `@FetchAll` in a feature executes their queries in a separate transaction. So, if -we wanted to query for all completed reminders, along with a total count of reminders (completed and -uncompleted), we could do so like this: +Each instance of `@FetchAll` and `@FetchOne` executes their queries in a separate transaction and +manage separate observations of the database. So, if we wanted to query for all completed reminders, +along with a total count of reminders (completed and uncompleted), we could do so like this: ```swift @FetchOne(Reminder.count()) @@ -191,13 +192,13 @@ var remindersCount = 0 var completedReminders ``` -…this is technically 2 queries run in 2 separate database transactions. +…this is technically 2 separate database transactions with 2 separate observations. Often this can be just fine, but if you have multiple queries that tend to change at the same time (_e.g._, when reminders are created or deleted, `remindersCount` and `completedReminders` will change at the same time), then you can bundle these two queries into a single transaction. -To do this, one simply defines a conformance to our ``FetchKeyRequest`` protocol, and in that +To do this, one defines a conformance to our ``FetchKeyRequest`` protocol, and in that conformance one can use the builder tools to query the database: ```swift @@ -219,9 +220,9 @@ Here we have defined a ``FetchKeyRequest/Value`` type inside the conformance tha data we want to query for in a single transaction, and then we can construct it and return it from the ``FetchKeyRequest/fetch(_:)`` method. -With this conformance defined we can use the +With this conformance defined we can use the [`@Fetch`]() property wrapper to execute the query specified by -the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties +the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties to get to the queried data: ```swift diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md similarity index 87% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md rename to Sources/SQLiteData/Documentation.docc/Articles/Observing.md index 0f70c837..f1ce70e5 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md @@ -1,6 +1,7 @@ # Observing changes to model data -Learn about the various tools for observing changes to the database in SwiftUI, UIKit, and more. +Learn how to observe changes to your database in SwiftUI views, UIKit view controllers, and +more. ## Overview @@ -10,9 +11,9 @@ macro from SwiftData. ### SwiftUI -The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]() -property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData. -You simply add a property to the view that is annotated with one of the various ways of +The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]() +property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData. +You simply add a property to the view that is annotated with one of the various ways of [querying your database](): ```swift @@ -54,7 +55,7 @@ struct ItemsView: View { ``` > Note: Due to how macros work in Swift, property wrappers must be annotated with -> `@ObservationIgnored`, but this does not affect observation as SharingGRDB handles its own +> `@ObservationIgnored`, but this does not affect observation as SQLiteData handles its own > observation. ### UIKit @@ -66,15 +67,15 @@ then you can do roughly the following: ```swift class ItemsViewController: UICollectionViewController { @FetchAll var items: [Item] - + override func viewDidLoad() { // Set up data source and cell registration... - + // Observe changes to items in order to update data source: - $items.publisher.sink { items in + $items.publisher.sink { items in guard let self else { return } dataSource.apply( - NSDiffableDataSourceSnapshot(items: items), + NSDiffableDataSourceSnapshot(items: items), animatingDifferences: true ) } @@ -86,20 +87,20 @@ class ItemsViewController: UICollectionViewController { This uses the `publisher` property that is available on every fetched value to update the collection view's data source whenever the `items` change. -> Tip: There is an alternative way to observe changes to `items`. If you are already depending on -> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI +> Tip: There is an alternative way to observe changes to `items`. If you are already depending on +> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI > and UIKitNavigation, then you can use the [`observe`][observe-docs] tool to update the database > without using Combine: -> +> > ```swift > override func viewDidLoad() { > // Set up data source and cell registration... -> +> > // Observe changes to items in order to update data source: > observe { [weak self] in > guard let self else { return } > dataSource.apply( -> NSDiffableDataSourceSnapshot(items: items), +> NSDiffableDataSourceSnapshot(items: items), > animatingDifferences: true > ) > } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md similarity index 60% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md rename to Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md index 098db6ed..eadbdac1 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md @@ -1,10 +1,11 @@ # Preparing a SQLite database -Learn how to create and configure the SQLite database that holds your application's data. +Learn how to create, configure and migrate the SQLite database that holds your application’s +data. ## Overview -Before you can use any of the tools of this library you must create and configure the SQLite +Before you can use any of the tools of this library you must create and configure the SQLite database that will be used throughout the app. There are a few steps to getting this right, and a few optional steps you can perform to make the database you provision work well for testing and Xcode previews. @@ -14,6 +15,7 @@ and Xcode previews. * [Step 3: Create database connection](#Step-3-Create-database-connection) * [Step 4: Migrate database](#Step-4-Migrate-database) * [Step 5: Set database connection in entry point](#Step-5-Set-database-connection-in-entry-point) +* [(Optional) Step 6: Set up CloudKit SyncEngine](#Optional-Step-6-Set-up-CloudKit-SyncEngine) ### Step 1: App database connection @@ -35,36 +37,28 @@ func appDatabase() -> any DatabaseWriter { ### Step 2: Create configuration Inside this static variable we can create a [`Configuration`][config-docs] value that is used to -configure the database. We highly recommend always turning on -[foreign key](https://www.sqlite.org/foreignkeys.html) constraints to protect the integrity of your -data: +configure the database if there is any custom configuration you want to perform. This is an +optional step: ```diff func appDatabase() -> any DatabaseWriter { + var configuration = Configuration() -+ configuration.foreignKeysEnabled = true } ``` -This will prevent you from deleting rows that leave other rows with invalid associations. For -example, if a "reminders" table had an association to a "remindersLists" table, you would not be -allowed to delete a list row unless there were no reminders associated with it, or if you had -specified a cascading action (such as delete). - -We further recommend that you enable query tracing to log queries that are executed in your -application. This can be handy for tracking down long-running queries, or when more queries execute -than you expect. We also recommend only doing this in debug builds to avoid leaking sensitive -information when the app is running on a user's device, and we further recommned using OSLog -when running your app in the simulator/device and using `Swift.print` in previews: +One configuration you may want to enable is query tracing in order to log queries that are executed +in your application. This can be handy for tracking down long-running queries, or when more queries +execute than you expect. We also recommend only doing this in debug builds to avoid leaking +sensitive information when the app is running on a user's device, and we further recommend using +OSLog when running your app in the simulator/device and using `Swift.print` in previews: ```diff import OSLog - import SharingGRDB + import SQLiteData func appDatabase() -> any DatabaseWriter { + @Dependency(\.context) var context var configuration = Configuration() - configuration.foreignKeysEnabled = true + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { @@ -85,15 +79,14 @@ when running your app in the simulator/device and using `Swift.print` in preview > sensitive data that you may not want to leak. In this case we feel it is OK because everything > is surrounded in `#if DEBUG`, but it is something to be careful of in your own apps. - > Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library, -> which SharingGRDB uses to share its database connection across fetch keys. It allows you to +> which SQLiteData uses to share its database connection across fetch keys. It allows you to > inspect the context your app is running in: live, preview or test. [swift-dependencies-gh]: https://github.com/pointfreeco/swift-dependencies -For more information on configuring tracing, see [GRDB's documentation][trace-docs] on the -matter. +For more information on configuring the database connection, see [GRDB's documentation][config-docs] +on the matter. [config-docs]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/configuration [trace-docs]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/database/trace(options:_:) @@ -101,14 +94,14 @@ matter. ### Step 3: Create database connection Once a `Configuration` value is set up we can construct the actual database connection. The simplest -way to do this is to construct the database connection for a path on the file system like so: +way to do this is to construct the database connection using the +n``defaultDatabase(path:configuration:)`` function: ```diff -func appDatabase() -> any DatabaseWriter { +func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context var configuration = Configuration() - configuration.foreignKeysEnabled = true #if DEBUG configuration.prepareDatabase { db in db.trace(options: .profile) { @@ -120,59 +113,18 @@ way to do this is to construct the database connection for a path on the file sy } } #endif -+ let path = URL.documentsDirectory.appending(component: "db.sqlite").path() -+ logger.info("open \(path)") -+ let database = try DatabasePool(path: path, configuration: configuration) ++ let database = try defaultDatabase(configuration: configuration) ++ logger.info("open '\(database.path)'") + return database } ``` -However, this can be improved. First, this code will crash if it is executed in Xcode previews -because SQLite is unable to form a connection to a database on disk in a preview context. And -second, in tests we should write this databadse to the temporary directoy on disk with a unique -name so that each test gets a fresh database and so that multiple tests can run in parallel. - -To fix this we can use `@Dependency(\.context)` to determine if we are in a "live" application -context or if we're in a preview or test. - -```diff - func appDatabase() -> any DatabaseWriter { - @Dependency(\.context) var context - var configuration = Configuration() - configuration.foreignKeysEnabled = true - #if DEBUG - configuration.prepareDatabase { db in - db.trace(options: .profile) { - if context == .preview { - print("\($0.expandedDescription)") - } else { - logger.debug("\($0.expandedDescription)") - } - } - } - #endif -- let path = URL.documentsDirectory.appending(component: "db.sqlite").path() -- logger.info("open \(path)") -- let database = try DatabasePool(path: path, configuration: configuration) -+ @Dependency(\.context) var context -+ let database: any DatabaseWriter -+ if context == .live { -+ let path = URL.documentsDirectory.appending(component: "db.sqlite").path() -+ logger.info("open \(path)") -+ database = try DatabasePool(path: path, configuration: configuration) -+ } else if context == .test { -+ let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() -+ database = try DatabasePool(path: path, configuration: configuration) -+ } else { -+ database = try DatabaseQueue(configuration: configuration) -+ } - return database - } -``` +This function provisions a context-dependent database for you, _e.g._ in previews and tests it +will provision unique, temporary databases that won't conflict with your live app's database. ### Step 4: Migrate database -Now that the database connection is created we can migrate the database. GRDB provides all the +Now that the database connection is created we can migrate the database. GRDB provides all the tools necessary to perform [database migrations][grdb-migration-docs], but the basics include creating a `DatabaseMigrator`, registering migrations with it, and then using it to migrate the database connection: @@ -181,7 +133,6 @@ database connection: func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context var configuration = Configuration() - configuration.foreignKeysEnabled = true #if DEBUG configuration.prepareDatabase { db in db.trace(options: .profile) { @@ -193,17 +144,8 @@ database connection: } } #endif - let database: any DatabaseWriter - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { - let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) - } + let database = try defaultDatabase(configuration: configuration) + logger.info("open '\(database.path)'") + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true @@ -218,9 +160,9 @@ database connection: As your application evolves you will register more and more migrations with the migrator. -It is up to you how you want to actually execute the SQL that creates your tables. There are -[APIs in the community][grdb-table-definition] for building table definition statements using Swift -code, but we personally feel that it is simpler, more flexible and more powerful to use +It is up to you how you want to actually execute the SQL that creates your tables. There are +[APIs in the community][grdb-table-definition] for building table definition statements using Swift +code, but we personally feel that it is simpler, more flexible and more powerful to use [plain SQL strings][table-definition-tools]: [grdb-table-definition]: https://swiftpackageindex.com/groue/grdb.swift/v7.6.1/documentation/grdb/database/create(table:options:body:) @@ -250,7 +192,7 @@ migrator.registerMigration("Create tables") { db in It may seem counterintuitive that we recommend using SQL strings for table definitions when so much of the library provides type-safe and schema-safe tools for executing SQL. But table definition SQL is fundamentally different from other SQL as it is frozen in time and should never be edited -after it has been deployed to users. Read [this article][table-definition-tools] from our +after it has been deployed to users. Read [this article][table-definition-tools] from our StructuredQueries library to learn more about this decision. [table-definition-tools]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/definingyourschema#Table-definition-tools @@ -260,34 +202,24 @@ we have just written in one snippet: ```swift import OSLog -import SharingGRDB +import SQLiteData func appDatabase() throws -> any DatabaseWriter { - @Dependency(\.context) var context - var configuration = Configuration() - configuration.foreignKeysEnabled = true - #if DEBUG - configuration.prepareDatabase { db in - db.trace(options: .profile) { - if context == .preview { - print("\($0.expandedDescription)") - } else { - logger.debug("\($0.expandedDescription)") - } - } - } - #endif - let database: any DatabaseWriter - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { - let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) - } + @Dependency(\.context) var context + var configuration = Configuration() + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } + } + } + #endif + let database = try defaultDatabase(configuration: configuration) + logger.info("open '\(database.path)'") var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -311,13 +243,13 @@ it as the `defaultDatabase` for your app in its entry point. This can be in done `prepareDependencies` in the `init` of your `App` conformance: ```swift -import SharingGRDB +import SQLiteData import SwiftUI @main struct MyApp: App { init() { - prepareDependencies { + prepareDependencies { $0.defaultDatabase = try! appDatabase() } } @@ -332,7 +264,7 @@ If using app or scene delegates, then you can prepare the `defaultDatabase` in o conformances: ```swift -import SharingGRDB +import SQLiteData import UIKit class AppDelegate: NSObject, UIApplicationDelegate { @@ -352,7 +284,7 @@ It is also important to prepare the database in Xcode previews. This can be done ```swift #Preview { - let _ = prepareDependencies { + let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } // ... @@ -367,3 +299,9 @@ func feature() { // ... } ``` + +### (Optional) Step 6: Set up CloudKit SyncEngine + +If you plan on synchronizing your local database to CloudKit so that your user's data is available +on all of their devices, there is an additional step you must take. See + for more information. diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Database.md b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md new file mode 100644 index 00000000..8c8f2316 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md @@ -0,0 +1,16 @@ +# ``GRDB/Database`` + +## Topics + +### Seeding model data + +- ``seed(_:)`` + +### User-defined functions + +- ``add(function:)`` +- ``remove(function:)`` + +### Querying CloudKit metadata + +- ``attachMetadatabase(containerIdentifier:)`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md similarity index 80% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md rename to Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md index d6a51b81..0657e8f9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/Fetch`` +# ``SQLiteData/Fetch`` ## Overview @@ -8,8 +8,6 @@ - ``FetchKeyRequest`` - ``init(wrappedValue:_:database:)`` -- ``init(_:database:)`` -- ``init(database:)`` - ``init(wrappedValue:)`` - ``load(_:database:)`` @@ -34,9 +32,7 @@ - ``init(wrappedValue:_:database:scheduler:)`` - ``load(_:database:scheduler:)`` -### Sharing infrastructure +### Sharing integration - ``sharedReader`` - ``subscript(dynamicMember:)`` -- ``FetchKey`` -- ``FetchKeyID`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md similarity index 95% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md index 457b6e99..14c32601 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchAll`` +# ``SQLiteData/FetchAll`` ## Overview diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md new file mode 100644 index 00000000..1142cc2e --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md @@ -0,0 +1,7 @@ +# ``StructuredQueriesCore/Statement/fetchCursor(_:)`` + +## Topics + +### Iterating over rows + +- ``QueryCursor`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md new file mode 100644 index 00000000..732a804b --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md @@ -0,0 +1,7 @@ +# ``SQLiteData/FetchKeyRequest`` + +## Topics + +### Error handling + +- ``NotFound`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md similarity index 92% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md index d001456e..787d2914 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchOne`` +# ``SQLiteData/FetchOne`` ## Overview @@ -7,7 +7,6 @@ ### Fetching data - ``init(wrappedValue:database:)`` -- ``init(database:)`` - ``init(wrappedValue:_:database:)`` - ``load(_:database:)`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md new file mode 100644 index 00000000..15411423 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md @@ -0,0 +1,29 @@ +# ``IdentifierStringConvertible`` + +## Topics + +### Conformances + +- ``Swift/Bool`` +- ``Swift/Character`` +- ``Swift/Double`` +- ``Swift/Float`` +- ``Swift/Float16`` +- ``Swift/Int`` +- ``Swift/Int128`` +- ``Swift/Int16`` +- ``Swift/Int32`` +- ``Swift/Int64`` +- ``Swift/Int8`` +- ``Swift/String`` +- ``Swift/Substring`` +- ``Swift/UInt`` +- ``Swift/UInt128`` +- ``Swift/UInt16`` +- ``Swift/UInt32`` +- ``Swift/UInt64`` +- ``Swift/UInt8`` +- ``Swift/Optional`` +- ``Swift/Unicode/Scalar`` +- ``Foundation/UUID`` +- ``Tagged/Tagged`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md new file mode 100644 index 00000000..ccf259bf --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md @@ -0,0 +1 @@ +# ``CloudKit/CKRecord/SystemFieldsRepresentation`` diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png new file mode 100644 index 00000000..6b05e402 Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png differ diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png new file mode 100644 index 00000000..aab886d3 Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png differ diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png new file mode 100644 index 00000000..7807c602 Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png differ diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png new file mode 100644 index 00000000..24c74221 Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png differ diff --git a/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png new file mode 100644 index 00000000..2e8a1a69 Binary files /dev/null and b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png differ diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md similarity index 57% rename from Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md rename to Sources/SQLiteData/Documentation.docc/SQLiteData.md index eec45bf1..c9a3bc32 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -1,23 +1,24 @@ -# ``SharingGRDBCore`` +# ``SQLiteData`` -A fast, lightweight replacement for SwiftData, powered by SQL. This module is automatically imported -when you `import SharingGRDB`. +A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit +synchronization. ## Overview -SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the -way back to the iOS 13 generation of targets. +SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData, supporting CloudKit +synchronization (and even CloudKit sharing), that deploys all the way back to the iOS 13 generation +of targets. @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @FetchAll var items: [Item] @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -51,15 +52,14 @@ way back to the iOS 13 generation of targets. Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SharingGRDB is powered directly by SQLite using [Sharing](#What-is-Sharing), -[StructuredQueries](#What-is-StructuredQueries), and [GRDB](#What-is-GRDB), and is usable from -anywhere, including UIKit, `@Observable` models, and more. +but SQLiteData is powered directly by SQLite and is usable from anywhere, including UIKit, +`@Observable` models, and more. -> Note: For more information on SharingGRDB's querying capabilities, see . +> Note: For more information on SQLiteData's querying capabilities, see . ## Quick start -Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide---at +Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide---at runtime---the default database it should use. This is typically done as early as possible in your app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage in SwiftData: @@ -67,7 +67,7 @@ in SwiftData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @main struct MyApp: App { init() { @@ -86,11 +86,11 @@ in SwiftData: // SwiftData @main struct MyApp: App { - let container = { + let container = { // Create/configure a container try! ModelContainer(/* ... */) }() - + var body: some Scene { WindowGroup { ContentView() @@ -104,9 +104,8 @@ in SwiftData: > Note: For more information on preparing a SQLite database, see . -This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like -[`@FetchAll`](), which are similar to SwiftData's -`@Query` macro, but more powerful: +This `defaultDatabase` connection is used implicitly by SQLiteData's property wrappers, like +``FetchAll``, which are similar to SwiftData's `@Query` macro, but more powerful: @Row { @Column { @@ -120,8 +119,13 @@ This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies @FetchAll(Item.where(\.isInStock)) var items + + + @FetchAll(Item.order(by: \.isInStock)) + var items + @FetchOne(Item.count()) - var inStockItemsCount = 0 + var itemsCount = 0 ``` } @Column { @@ -132,8 +136,13 @@ This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies @Query(sort: [SortDescriptor(\.title)]) var items: [Item] - // No @Query equivalent of filtering - // by 'isInStock: Bool' + @Query(filter: #Predicate { + $0.isInStock + }) + var items: [Item] + + // No @Query equivalent of ordering + // by boolean column. // No @Query equivalent of counting // entries in database without loading @@ -148,12 +157,12 @@ a model context, via a property wrapper: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in - try Item.insert(Item(/* ... */)) - .execute(db) + try Item.insert { Item(/* ... */) } + .execute(db) } ``` } @@ -161,7 +170,7 @@ a model context, via a property wrapper: ```swift // SwiftData @Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -169,15 +178,42 @@ a model context, via a property wrapper: } } -> Note: For more information on how SharingGRDB compares to SwiftData, see -> . +> Important: SQLiteData uses [GRDB] under the hood for interacting with SQLite, and you will use +> its tools for creating transactions for writing to the database, such as the `database.write` +> method above. + +[GRDB]: https://github.com/groue/GRDB.swift + +For more information on how SQLiteData compares to SwiftData, see . + +Further, if you want to synchronize the local database to CloudKit so that it is available on +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: + +```swift +@main +struct MyApp: App { + init() { + prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = SyncEngine( + for: $0.defaultDatabase, + tables: /* ... */ + ) + } + } + // ... +} +``` + +> For more information on synchronizing the database to CloudKit and sharing records with iCloud +> users, see . -This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read +This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles](#Essentials) below to learn how to best utilize this library. ## Performance -SharingGRDB leverages high-performance decoding from +SQLiteData leverages high-performance decoding from [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) to turn fetched data into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly. @@ -187,19 +223,21 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SharingGRDB (0.2.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +┌──────────────────────────────────────────────────────────────────┐ +│ SQLiteData (1.0.0) 0 0.172 8.511 │ +└──────────────────────────────────────────────────────────────────┘ + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required -SQLite is one of the +SQLite is one of the [most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of software in the history of software. Knowledge of SQLite is a great skill for any app developer to have, and this library does not want to conceal it from you. So, we feel that to best wield this @@ -212,19 +250,6 @@ for data and keep your views up-to-date when data in the database changes, and y either using its type-safe, discoverable query building APIs, or using its `#sql` macro for writing safe SQL strings. -## What is Sharing? - -[Sharing](https://github.com/pointfreeco/swift-sharing) is a universal and extensible solution for -sharing your app's model data across features and with external systems, such as user defaults, -the file system, and more. This library builds upon the tools from Sharing in order to allow for -the [fetching]() and [observing]() of data in a SQLite database. - -This is all you need to know about Sharing to hit the ground running with SharingGRDB, but it only -scratches the surface of what the library makes possible. It can also act as a replacement to -SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated -with custom persistence strategies. To learn more, check out -[the documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/~/documentation/sharing/). - ## What is StructuredQueries? [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) is a library for @@ -232,7 +257,7 @@ building SQL in a safe, expressive, and composable manner, and decoding results performance. Learn more about designing schemas and building queries with the library by seeing its [documentation](https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/). -SharingGRDB contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB, +SQLiteData contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB, though its query builder and decoder are general purpose tools that can interface with other databases (MySQL, Postgres, _etc._) and database libraries. @@ -240,15 +265,15 @@ databases (MySQL, Postgres, _etc._) and database libraries. [GRDB](https://github.com/groue/GRDB.swift) is a popular Swift interface to SQLite with a rich feature set and -[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). -This library leverages GRDB's' observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch` +[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). This +library leverages GRDB's observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch` property wrappers in sync with the database and update SwiftUI views. If you're already familiar with SQLite, GRDB provides thin APIs that can be leveraged with raw SQL in short order. If you're new to SQLite, GRDB offers a great introduction to a highly portable database engine. We recommend ([as does GRDB](https://github.com/groue/GRDB.swift?tab=readme-ov-file#documentation)) a familiarity -with SQLite to take full advantage of GRDB and SharingGRDB. +with SQLite to take full advantage of GRDB and SQLiteData. ## Topics @@ -259,18 +284,32 @@ with SQLite to take full advantage of GRDB and SharingGRDB. - - - -- ### Database configuration and access +- ``defaultDatabase(path:configuration:)`` +- ``GRDB/Database`` - ``Dependencies/DependencyValues/defaultDatabase`` -### Fetch and observing queries +### Querying model data + +- ``StructuredQueriesCore/Statement`` +- ``StructuredQueriesCore/SelectStatement`` +- ``QueryCursor`` + +### Observing model data - ``FetchAll`` - ``FetchOne`` - ``Fetch`` -### Deprecated interfaces +### CloudKit synchronization and sharing -- +- +- +- ``SyncEngine`` +- ``Dependencies/DependencyValues/defaultSyncEngine`` +- ``IdentifierStringConvertible`` +- ``SyncMetadata`` +- ``StructuredQueriesCore/PrimaryKeyedTableDefinition/hasMetadata(in:)`` +- ``SharedRecord`` diff --git a/Sources/SharingGRDBCore/Fetch.swift b/Sources/SQLiteData/Fetch.swift similarity index 99% rename from Sources/SharingGRDBCore/Fetch.swift rename to Sources/SQLiteData/Fetch.swift index dd40d9d8..5f916314 100644 --- a/Sources/SharingGRDBCore/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif diff --git a/Sources/SharingGRDBCore/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift similarity index 94% rename from Sources/SharingGRDBCore/FetchAll.swift rename to Sources/SQLiteData/FetchAll.swift index 4a1ba03b..18239778 100644 --- a/Sources/SharingGRDBCore/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif @@ -70,6 +72,11 @@ public struct FetchAll: Sendable { #endif /// Initializes this property with a query that fetches every row from a table. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil @@ -88,6 +95,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -109,6 +117,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -133,6 +142,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -201,6 +211,7 @@ extension FetchAll { /// Initializes this property with a query that fetches every row from a table. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed @@ -218,6 +229,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -242,6 +254,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -270,6 +283,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -366,6 +380,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query that fetches every row from a table. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -383,6 +398,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -412,6 +428,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -439,6 +456,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). diff --git a/Sources/SharingGRDBCore/FetchKeyRequest.swift b/Sources/SQLiteData/FetchKeyRequest.swift similarity index 99% rename from Sources/SharingGRDBCore/FetchKeyRequest.swift rename to Sources/SQLiteData/FetchKeyRequest.swift index cd89d074..17f713a9 100644 --- a/Sources/SharingGRDBCore/FetchKeyRequest.swift +++ b/Sources/SQLiteData/FetchKeyRequest.swift @@ -1,5 +1,3 @@ -import GRDB - /// A type that can request a value from a database. /// /// This type can be used to describe a transaction to read data from SQLite: diff --git a/Sources/SharingGRDBCore/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift similarity index 99% rename from Sources/SharingGRDBCore/FetchOne.swift rename to Sources/SQLiteData/FetchOne.swift index 9313a850..e5e58a25 100644 --- a/Sources/SharingGRDBCore/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif diff --git a/Sources/SQLiteData/Internal/DataManager.swift b/Sources/SQLiteData/Internal/DataManager.swift new file mode 100644 index 00000000..68b43ffe --- /dev/null +++ b/Sources/SQLiteData/Internal/DataManager.swift @@ -0,0 +1,61 @@ +import Dependencies +import Foundation + +package protocol DataManager: Sendable { + func load(_ url: URL) throws -> Data + func save(_ data: Data, to url: URL) throws + var temporaryDirectory: URL { get } +} + +struct LiveDataManager: DataManager { + func load(_ url: URL) throws -> Data { + try Data(contentsOf: url) + } + func save(_ data: Data, to url: URL) throws { + try data.write(to: url) + } + var temporaryDirectory: URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + } +} + +package struct InMemoryDataManager: DataManager { + package let storage = LockIsolated<[URL: Data]>([:]) + + package init() {} + + package func load(_ url: URL) throws -> Data { + try storage.withValue { storage in + guard let data = storage[url] + else { + struct FileNotFound: Error {} + throw FileNotFound() + } + return data + } + } + + package func save(_ data: Data, to url: URL) throws { + storage.withValue { $0[url] = data } + } + + package var temporaryDirectory: URL { + URL(fileURLWithPath: "/") + } +} + +private enum DataManagerKey: DependencyKey { + static var liveValue: any DataManager { + LiveDataManager() + } + static var testValue: any DataManager { + InMemoryDataManager() + } +} + +extension DependencyValues { + package var dataManager: DataManager { + get { self[DataManagerKey.self] } + set { self[DataManagerKey.self] = newValue } + } +} diff --git a/Sources/SQLiteData/Internal/Exports.swift b/Sources/SQLiteData/Internal/Exports.swift new file mode 100644 index 00000000..e59b61a1 --- /dev/null +++ b/Sources/SQLiteData/Internal/Exports.swift @@ -0,0 +1,12 @@ +@_exported import Dependencies +@_exported import StructuredQueriesSQLite + +@_exported import struct GRDB.Configuration +@_exported import class GRDB.Database +@_exported import struct GRDB.DatabaseError +@_exported import struct GRDB.DatabaseMigrator +@_exported import class GRDB.DatabasePool +@_exported import class GRDB.DatabaseQueue +@_exported import protocol GRDB.DatabaseReader +@_exported import protocol GRDB.DatabaseWriter +@_exported import protocol GRDB.ValueObservationScheduler diff --git a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift new file mode 100644 index 00000000..83abd437 --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift @@ -0,0 +1,47 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import SwiftUI + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey { + .fetch(request, database: database, scheduler: .animation(animation)) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey.Default { + .fetch(request, database: database, scheduler: .animation(animation)) + } + } + + package struct AnimatedScheduler: ValueObservationScheduler, Equatable { + let animation: Animation + package func immediateInitialValue() -> Bool { true } + package func schedule(_ action: @escaping @Sendable () -> Void) { + DispatchQueue.main.async { + withAnimation(animation) { + action() + } + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension AnimatedScheduler: Hashable {} + + extension ValueObservationScheduler where Self == AnimatedScheduler { + package static func animation(_ animation: Animation) -> Self { + AnimatedScheduler(animation: animation) + } + } +#endif diff --git a/Sources/SQLiteData/Internal/FetchKey.swift b/Sources/SQLiteData/Internal/FetchKey.swift new file mode 100644 index 00000000..7761d728 --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchKey.swift @@ -0,0 +1,190 @@ +import Dependencies +import Dispatch +import Foundation +import GRDB +import Sharing + +#if canImport(Combine) + @preconcurrency import Combine +#endif + +extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey { + FetchKey(request: request, database: database, scheduler: nil) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey.Default { + Self[.fetch(request, database: database), default: Value()] + } +} + +extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey { + FetchKey(request: request, database: database, scheduler: scheduler) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey.Default { + Self[.fetch(request, database: database, scheduler: scheduler), default: Value()] + } +} + +struct FetchKey: SharedReaderKey { + let database: any DatabaseReader + let request: any FetchKeyRequest + let scheduler: (any ValueObservationScheduler & Hashable)? + #if DEBUG + let isDefaultDatabase: Bool + #endif + + public typealias ID = FetchKeyID + + public var id: ID { + ID(database: database, request: request, scheduler: scheduler) + } + + init( + request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + @Dependency(\.defaultDatabase) var defaultDatabase + self.scheduler = scheduler + self.database = database ?? defaultDatabase + self.request = request + #if DEBUG + self.isDefaultDatabase = self.database.configuration.label == .defaultDatabaseLabel + #endif + } + + public func load(context: LoadContext, continuation: LoadContinuation) { + #if DEBUG + guard !isDefaultDatabase else { + continuation.resumeReturningInitialValue() + return + } + #endif + guard case .userInitiated = context else { + continuation.resumeReturningInitialValue() + return + } + let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() + database.asyncRead { dbResult in + let result = dbResult.flatMap { db in + Result { + try request.fetch(db) + } + } + scheduler.schedule { + switch result { + case let .success(value): + continuation.resume(returning: value) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + public func subscribe( + context: LoadContext, subscriber: SharedSubscriber + ) -> SharedSubscription { + #if DEBUG + guard !isDefaultDatabase else { + return SharedSubscription {} + } + #endif + let observation = ValueObservation.tracking { db in + Result { try request.fetch(db) } + } + + let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() + #if canImport(Combine) + let dropFirst = + switch context { + case .initialValue: false + case .userInitiated: true + } + let cancellable = observation.publisher(in: database, scheduling: scheduler) + .dropFirst(dropFirst ? 1 : 0) + .sink { completion in + switch completion { + case let .failure(error): + subscriber.yield(throwing: error) + case .finished: + break + } + } receiveValue: { newValue in + switch newValue { + case let .success(value): + subscriber.yield(value) + case let .failure(error): + subscriber.yield(throwing: error) + } + } + return SharedSubscription { + cancellable.cancel() + } + #else + let cancellable = observation.start(in: database, scheduling: scheduler) { error in + subscriber.yield(throwing: error) + } onChange: { newValue in + switch newValue { + case let .success(value): + subscriber.yield(value) + case let .failure(error): + subscriber.yield(throwing: error) + } + } + return SharedSubscription { + cancellable.cancel() + } + #endif + } +} + +struct FetchKeyID: Hashable { + fileprivate let databaseID: ObjectIdentifier + fileprivate let request: AnyHashableSendable + fileprivate let requestTypeID: ObjectIdentifier + fileprivate let scheduler: AnyHashableSendable? + + fileprivate init( + database: any DatabaseReader, + request: some FetchKeyRequest, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + self.databaseID = ObjectIdentifier(database) + self.request = AnyHashableSendable(request) + self.requestTypeID = ObjectIdentifier(type(of: request)) + self.scheduler = scheduler.map { AnyHashableSendable($0) } + } +} + +public struct NotFound: Error { + public init() {} +} + +private struct ImmediateScheduler: ValueObservationScheduler, Hashable { + func immediateInitialValue() -> Bool { true } + func schedule(_ action: @escaping @Sendable () -> Void) { + action() + } +} diff --git a/Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift b/Sources/SQLiteData/Internal/ISO8601.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift rename to Sources/SQLiteData/Internal/ISO8601.swift diff --git a/Sources/SharingGRDBCore/Internal/StatementKey.swift b/Sources/SQLiteData/Internal/StatementKey.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/StatementKey.swift rename to Sources/SQLiteData/Internal/StatementKey.swift diff --git a/Sources/SQLiteData/Internal/UserDatabase.swift b/Sources/SQLiteData/Internal/UserDatabase.swift new file mode 100644 index 00000000..520e8601 --- /dev/null +++ b/Sources/SQLiteData/Internal/UserDatabase.swift @@ -0,0 +1,55 @@ +import Dependencies +import GRDB + +package struct UserDatabase { + package let database: any DatabaseWriter + package init(database: any DatabaseWriter) { + self.database = database + } + + var path: String { + database.path + } + + var configuration: Configuration { + database.configuration + } + + package func write( + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + try await database.write { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + + package func read( + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + try await database.read { db in + try updates(db) + } + } + + @_disfavoredOverload + package func write( + _ updates: (Database) throws -> T + ) throws -> T { + try database.write { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + + @_disfavoredOverload + package func read( + _ updates: (Database) throws -> T + ) throws -> T { + try database.read { db in + try updates(db) + } + } +} diff --git a/Sources/StructuredQueriesGRDBCore/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/CustomFunctions.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift diff --git a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift similarity index 61% rename from Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift index b495710e..fb3a044e 100644 --- a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -1,6 +1,50 @@ import Dependencies +import Foundation import GRDB +/// Prepares a context-sensitive database writer. +/// +/// * In a live app context (e.g. simulator, device), a database pool is provisioned in the app +/// container (unless explicitly overridden with the `path` parameter). +/// * In an Xcode preview context, an in-memory database is provisioned. +/// * In a test context, a database pool is provisioned at a temporary file. +/// +/// - Parameters: +/// - path: A path to the database. If `nil`, a path to a file in the application support +/// directory will be used. +/// - configuration: A database configuration. +/// - Returns: A context-sensitive database writer. +public func defaultDatabase( + path: String? = nil, + configuration: Configuration = Configuration() +) throws -> any DatabaseWriter { + let database: any DatabaseWriter + @Dependency(\.context) var context + switch context { + case .live: + var defaultPath: String { + get throws { + let applicationSupportDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return applicationSupportDirectory.appendingPathComponent("SQLiteData.db").absoluteString + } + } + database = try DatabasePool(path: path ?? defaultPath, configuration: configuration) + case .preview: + database = try DatabaseQueue(configuration: configuration) + case .test: + database = try DatabasePool( + path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", + configuration: configuration + ) + } + return database +} + extension DependencyValues { /// The default database used by `fetchAll`, `fetchOne`, and `fetch`. /// @@ -8,7 +52,7 @@ extension DependencyValues { /// SwiftUI, using `prepareDependencies`: /// /// ```swift - /// import SharingGRDB + /// import SQLiteData /// import SwiftUI /// /// @main @@ -52,7 +96,7 @@ extension DependencyValues { case .live: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB', use the 'prepareDependencies' tool as early as possible in the lifetime \ + 'SQLiteData', use the 'prepareDependencies' tool as early as possible in the lifetime \ of your app, such as in your app or scene delegate in UIKit, or the app entry point in \ SwiftUI: @@ -70,7 +114,7 @@ extension DependencyValues { case .preview: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB' in a preview, use a tool like 'prepareDependencies': + 'SQLiteData' in a preview, use a tool like 'prepareDependencies': #Preview { let _ = prepareDependencies { @@ -83,7 +127,7 @@ extension DependencyValues { case .test: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB' in a test, use a tool like the 'dependency' trait from \ + 'SQLiteData' in a test, use a tool like the 'dependency' trait from \ 'DependenciesTestSupport': import DependenciesTestSupport @@ -109,6 +153,6 @@ extension DependencyValues { #if DEBUG extension String { - package static let defaultDatabaseLabel = "co.pointfree.SharingGRDB.testValue" + package static let defaultDatabaseLabel = "co.pointfree.SQLiteData.testValue" } #endif diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift similarity index 96% rename from Sources/StructuredQueriesGRDBCore/QueryCursor.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index 9bb396b2..6fe5c4fc 100644 --- a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -55,7 +55,7 @@ final class QueryValueCursor: QueryCursor { typealias Element = () // NB: Required to workaround a "Legacy previews execution" bug - // https://github.com/pointfreeco/sharing-grdb/pull/60 + // https://github.com/pointfreeco/sqlite-data/pull/60 @usableFromInline override init(db: Database, query: QueryFragment) throws { try super.init(db: db, query: query) diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift diff --git a/Sources/StructuredQueriesGRDBCore/Seed.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Seed.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift diff --git a/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift diff --git a/Sources/SQLiteData/Traits/Tagged.swift b/Sources/SQLiteData/Traits/Tagged.swift new file mode 100644 index 00000000..c7fe36bb --- /dev/null +++ b/Sources/SQLiteData/Traits/Tagged.swift @@ -0,0 +1,14 @@ +#if SQLiteDataTagged + import Tagged + + extension Tagged: IdentifierStringConvertible where RawValue: IdentifierStringConvertible { + public init?(rawIdentifier: String) { + guard let rawValue = RawValue(rawIdentifier: rawIdentifier) else { return nil } + self.init(rawValue) + } + + public var rawIdentifier: String { + rawValue.rawIdentifier + } + } +#endif diff --git a/Sources/SharingGRDBTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift similarity index 98% rename from Sources/SharingGRDBTestSupport/AssertQuery.swift rename to Sources/SQLiteDataTestSupport/AssertQuery.swift index 86ca41f7..3009db47 100644 --- a/Sources/SharingGRDBTestSupport/AssertQuery.swift +++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift @@ -3,8 +3,8 @@ import Dependencies import Foundation import GRDB import InlineSnapshotTesting +import SQLiteData import StructuredQueriesCore -import StructuredQueriesGRDBCore import StructuredQueriesTestSupport /// An end-to-end snapshot testing helper for database content. @@ -47,7 +47,9 @@ import StructuredQueriesTestSupport /// - column: The source `#column` associated with the assertion. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @_disfavoredOverload -public func assertQuery>( +public func assertQuery< + each V: QueryRepresentable, S: StructuredQueriesCore.Statement<(repeat each V)> +>( includeSQL: Bool = false, _ query: S, database: (any DatabaseWriter)? = nil, diff --git a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md deleted file mode 100644 index 416a0572..00000000 --- a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md +++ /dev/null @@ -1,43 +0,0 @@ -# ``SharingGRDB`` - -A fast, lightweight replacement for SwiftData, powered by SQL. - -## Overview - -The core functionality of this library is defined in -[`SharingGRDBCore`](sharinggrdbcore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore), -which this module automatically exports. - -> Note: This module also exports `StructuredQueries`, which provides the `@Table` macro for building -> and decoding queries. If you are using [GRDB][]'s built-in tools instead of -> [StructuredQueries][], consider depending on `SharingGRDBCore`, instead. - -See [`SharingGRDBCore`](sharinggrdbcore) for documentation on the integration with the -`@FetchAll` property wrapper, which is equivalent to SwiftData's `@Query`. - -See [`StructuredQueriesGRDBCore`](sharinggrdbcore) for documentation on the integration between -[StructuredQueries][] and [GRDB][]. - -> Tip: SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the -> library's functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, -> and tools for driving StructuredQueries using GRDB. This is the module that most library users -> should depend on. -> -> If you are a library author that wishes to extend SharingGRDB with additional functionality, you -> may want to depend on a different module: -> -> * [`SharingGRDBCore`](sharinggrdbcore): This product includes everything in `SharingGRDB` -> _except_ the macros (`@Table`, `#sql`, _etc._). This module can be imported to extend -> SharingGRDB with additional functionality without forcing the heavyweight dependency of -> SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the -> `@Fetch` family of property wrappers. It can be imported if you want to extend -> StructuredQueries' GRDB driver but do not need access to observation tools provided by -> Sharing. -> * [`StructuredQueriesGRDBCore`](sharinggrdbcore): This product includes everything in -> `StructuredQueriesGRDB` _except_ the macros. This module can be imported to extend -> StructuredQueries' GRDB driver with additional functionality without forcing the heavyweight -> dependency of SwiftSyntax on your users. - -[GRDB]: https://github.com/groue/GRDB.swift -[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift deleted file mode 100644 index 07a5c659..00000000 --- a/Sources/SharingGRDB/Exports.swift +++ /dev/null @@ -1,2 +0,0 @@ -@_exported import SharingGRDBCore -@_exported import StructuredQueriesGRDB diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md deleted file mode 100644 index b2eb37c8..00000000 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md +++ /dev/null @@ -1,14 +0,0 @@ -# Deprecations - -Review unsupported APIs and their replacements. - -## Overview - -Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use -instead. - -## Topics - -### Sharing extensions - -- ``Sharing`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md deleted file mode 100644 index 38e793af..00000000 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md +++ /dev/null @@ -1,17 +0,0 @@ -# Migration guides - -Learn how to upgrade your application to the newest version of SharingGRDB. - -## Overview - -SharingGRDB is under constant development, and we are always looking for ways to -simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs -in favor of newer ones. We recommend people update their code as quickly as possible to the newest -APIs, and these guides contain tips to do so. - -> Important: Before following any particular migration guide be sure you have followed all the -> preceding migration guides. - -## Topics - -- diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md deleted file mode 100644 index 9495bcbc..00000000 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md +++ /dev/null @@ -1,70 +0,0 @@ -# Migrating to 0.2 - -Update your code to make use of powerful new querying capabilities. - -## Overview - -SharingGRDB is under constant development, and we are always looking for ways to -simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs -in favor of newer ones. We recommend people update their code as quickly as possible to the newest -APIs, and these guides contain tips to do so. - -* [@FetchAll, @FetchOne, @Fetch](#) -* [fetchAll, fetchOne, fetch: soft-deprecated](#) -* [Avoiding the cost of macros](#) - -## @FetchAll, @FetchOne, @Fetch - -SharingGRDB 0.2.0 comes with 3 brand new property wrappers that largely replace the need for -SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL -string: - -```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM reminders WHERE isCompleted ORDER BY title")) -var completedReminders: [Reminder] -``` - -Or by defining a ``FetchKeyRequest`` conformance to perform a query using GRDB's query builder: - -```swift -struct CompletedReminders: FetchKeyRequest { - func fetch(_ db: Database) throws -> [Reminder] { - Reminder.all() - .where(Column("isCompleted")) - .order(Column("title")) - } -} - -@SharedReader(.fetch(CompletedReminders())) -var completedReminders -``` - -Each of these are cumbersome, and version 0.2.0 of SharingGRDB fixes things thanks to our newly -released [StructuredQueries][] library. You can now describe the query for your data in a type-safe -manner, and directly inline: - -```swift -@FetchAll(Reminder.where(\.isCompleted).order(by: \.title)) -var completedReminders: [Reminder] -``` - -Read for more information on how to use these new property wrappers. - -[StructuredQueries]: http://github.com/pointfreeco/swift-structured-queries - -## fetchAll, fetchOne, fetch: soft-deprecated - -The [`.fetchAll`](), -[`.fetchOne`](), -and [`.fetch`]() APIs have been soft-deprecated -in favor of the more modern tools described above and in . They will be hard -deprecated in a future release of SharingGRDB, and removed in 1.0. - -## Avoiding the cost of macros - -SharingGRDB introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and -unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax -from scratch, which can take time. If the cost of macros is too high for you, then you can depend -on the SharingGRDBCore module instead of the full SharingGRDB module. This will give you access to -only a subset of tools provided by SharingGRDB, but you will have access to all tools that were -available in version 0.1.0 of the library. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md deleted file mode 100644 index 49ed8509..00000000 --- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``SharingGRDBCore/FetchKey`` - -## Topics - -### Key identity - -- ``FetchKeyID`` -- ``ID-swift.typealias`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md deleted file mode 100644 index 501238a2..00000000 --- a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md +++ /dev/null @@ -1,11 +0,0 @@ -# ``SharingGRDBCore/FetchKeyRequest`` - -## Topics - -### Fetch keys - -- ``FetchKey`` - -### Error handling - -- ``NotFound`` diff --git a/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift b/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift deleted file mode 100644 index 9ca42d0d..00000000 --- a/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift +++ /dev/null @@ -1,138 +0,0 @@ -#if canImport(SwiftUI) - import GRDB - import Sharing - import SwiftUI - - extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// A version of `fetch` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey { - .fetch(request, database: database, scheduler: .animation(animation)) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetch` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey.Default { - .fetch(request, database: database, scheduler: .animation(animation)) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetchAll` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey<[Record]>.Default { - .fetchAll( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } - - /// A key that can query for a value in a SQLite database. - /// - /// A version of `fetchOne` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey { - .fetchOne( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } - } - -package struct AnimatedScheduler: ValueObservationScheduler, Equatable { - let animation: Animation - package func immediateInitialValue() -> Bool { true } - package func schedule(_ action: @escaping @Sendable () -> Void) { - DispatchQueue.main.async { - withAnimation(animation) { - action() - } - } - } - } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension AnimatedScheduler: Hashable {} - - extension ValueObservationScheduler where Self == AnimatedScheduler { - package static func animation(_ animation: Animation) -> Self { - AnimatedScheduler(animation: animation) - } - } -#endif diff --git a/Sources/SharingGRDBCore/FetchKey.swift b/Sources/SharingGRDBCore/FetchKey.swift deleted file mode 100644 index 834dad73..00000000 --- a/Sources/SharingGRDBCore/FetchKey.swift +++ /dev/null @@ -1,435 +0,0 @@ -import Dependencies -import Dispatch -import Foundation -import GRDB -import Sharing -import StructuredQueriesGRDBCore - -#if canImport(Combine) - @preconcurrency import Combine -#endif - -extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// This key takes a ``FetchKeyRequest`` conformance, which you define yourself. It has a single - /// requirement that describes fetching a value from a database connection. For examples, we can - /// define an `Items` request that uses GRDB's query builder to fetch some items: - /// - /// ```swift - /// struct Items: FetchKeyRequest { - /// func fetch(_ db: Database) throws -> [Item] { - /// try Item.all - /// .order { $0.timestamp.desc() } - /// .fetchAll(db) - /// } - /// } - /// ``` - /// - /// And one can query for this data by wrapping the request in this key and provide it to the - /// `@SharedReader` property wrapper: - /// - /// ```swift - /// @SharedReader(.fetch(Items()) var items - /// ``` - /// - /// For simpler querying needs, you can skip the ceremony of defining a ``FetchKeyRequest`` and - /// use a raw SQL query with ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` or - /// ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)``, instead. - /// - /// To animate or observe changes with a custom scheduler, see - /// ``Sharing/SharedReaderKey/fetch(_:database:animation:)`` or - /// ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)``. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey { - FetchKey(request: request, database: database, scheduler: nil) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetch` that allows you to omit the type and default from the `@SharedReader` - /// property wrapper: - /// - /// ```diff - /// -@SharedReader(.fetch(Items()) var items: [Item] = [] - /// +@SharedReader(.fetch(Items()) var items - /// ``` - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey.Default { - Self[.fetch(request, database: database), default: Value()] - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// This key gives you the ability to fetch and observe the results of a raw SQL query decoded to - /// some `GRDB.FetchableRecord` type: - /// - /// ```swift - /// @SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] - /// ``` - /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch(FetchAllRequest(sql: sql, arguments: arguments), database: database), - default: [] - ] - } - - /// A key that can query for a value in a SQLite database. - /// - /// This key gives you the ability to fetch and observe the result of a raw SQL query converted to - /// some `GRDB.DatabaseValueConvertible` type: - /// - /// ```swift - /// @SharedReader(.fetchOne(sql: "SELECT count(*) FROM items")) var itemsCount = 0 - /// ``` - /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey { - .fetch(FetchOneRequest(sql: sql, arguments: arguments), database: database) - } -} - -extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a - /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this - /// API. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey { - FetchKey(request: request, database: database, scheduler: scheduler) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a - /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this - /// API. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey.Default { - Self[.fetch(request, database: database, scheduler: scheduler), default: Value()] - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` that can be - /// configured with a scheduler. See ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` - /// for more info on how to use this API. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch( - FetchAllRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ), - default: [] - ] - } - - /// A key that can query for a value in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` that can be - /// configured with a scheduler. See ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` - /// for more info on how to use this API. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey { - .fetch( - FetchOneRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ) - } -} - -/// A type defining a reader of GRDB queries. -/// -/// You typically do not refer to this type directly, and will use -/// [`fetchAll`](), -/// [`fetchOne`](), and -/// [`fetch`]() to create instances, instead. -public struct FetchKey: SharedReaderKey { - let database: any DatabaseReader - let request: any FetchKeyRequest - let scheduler: (any ValueObservationScheduler & Hashable)? - #if DEBUG - let isDefaultDatabase: Bool - #endif - - public typealias ID = FetchKeyID - - public var id: ID { - ID(database: database, request: request, scheduler: scheduler) - } - - init( - request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: (any ValueObservationScheduler & Hashable)? - ) { - @Dependency(\.defaultDatabase) var defaultDatabase - self.scheduler = scheduler - self.database = database ?? defaultDatabase - self.request = request - #if DEBUG - self.isDefaultDatabase = self.database.configuration.label == .defaultDatabaseLabel - #endif - } - - public func load(context: LoadContext, continuation: LoadContinuation) { - #if DEBUG - guard !isDefaultDatabase else { - continuation.resumeReturningInitialValue() - return - } - #endif - guard case .userInitiated = context else { - continuation.resumeReturningInitialValue() - return - } - let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() - database.asyncRead { dbResult in - let result = dbResult.flatMap { db in - Result { - try request.fetch(db) - } - } - scheduler.schedule { - switch result { - case let .success(value): - continuation.resume(returning: value) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - } - - public func subscribe( - context: LoadContext, subscriber: SharedSubscriber - ) -> SharedSubscription { - #if DEBUG - guard !isDefaultDatabase else { - return SharedSubscription {} - } - #endif - let observation = ValueObservation.tracking { db in - Result { try request.fetch(db) } - } - - let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() - #if canImport(Combine) - let dropFirst = - switch context { - case .initialValue: false - case .userInitiated: true - } - let cancellable = observation.publisher(in: database, scheduling: scheduler) - .dropFirst(dropFirst ? 1 : 0) - .sink { completion in - switch completion { - case let .failure(error): - subscriber.yield(throwing: error) - case .finished: - break - } - } receiveValue: { newValue in - switch newValue { - case let .success(value): - subscriber.yield(value) - case let .failure(error): - subscriber.yield(throwing: error) - } - } - return SharedSubscription { - cancellable.cancel() - } - #else - let cancellable = observation.start(in: database, scheduling: scheduler) { error in - subscriber.yield(throwing: error) - } onChange: { newValue in - switch newValue { - case let .success(value): - subscriber.yield(value) - case let .failure(error): - subscriber.yield(throwing: error) - } - } - return SharedSubscription { - cancellable.cancel() - } - #endif - } -} - -/// A value that uniquely identifies a fetch key. -public struct FetchKeyID: Hashable { - fileprivate let databaseID: ObjectIdentifier - fileprivate let request: AnyHashableSendable - fileprivate let requestTypeID: ObjectIdentifier - fileprivate let scheduler: AnyHashableSendable? - - fileprivate init( - database: any DatabaseReader, - request: some FetchKeyRequest, - scheduler: (any ValueObservationScheduler & Hashable)? - ) { - self.databaseID = ObjectIdentifier(database) - self.request = AnyHashableSendable(request) - self.requestTypeID = ObjectIdentifier(type(of: request)) - self.scheduler = scheduler.map { AnyHashableSendable($0) } - } -} - -private struct FetchAllRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> [Element] { - try Element.fetchAll(db, sql: sql, arguments: arguments) - } -} - -private struct FetchOneRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> Value { - guard let value = try Value.fetchOne(db, sql: sql, arguments: arguments) - else { throw NotFound() } - return value - } -} - -public struct NotFound: Error { - public init() {} -} - -private struct ImmediateScheduler: ValueObservationScheduler, Hashable { - func immediateInitialValue() -> Bool { true } - func schedule(_ action: @escaping @Sendable () -> Void) { - action() - } -} diff --git a/Sources/SharingGRDBCore/Internal/Deprecations.swift b/Sources/SharingGRDBCore/Internal/Deprecations.swift deleted file mode 100644 index 96df088e..00000000 --- a/Sources/SharingGRDBCore/Internal/Deprecations.swift +++ /dev/null @@ -1,555 +0,0 @@ -#if canImport(Combine) - import Combine -#endif -#if canImport(SwiftUI) - import SwiftUI -#endif - -// NB: Deprecated after 0.2.2 - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -@available( - *, - deprecated, - message: "Use the '@Selection' macro to bundle multiple values into a value." -) -extension FetchAll { - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch(FetchAllStatementPackRequest(statement: statement), database: database) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - #if canImport(SwiftUI) - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - #endif -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private struct FetchAllStatementPackRequest: StatementKeyRequest { - let statement: SQLQueryExpression<(repeat each Value)> - init(statement: some StructuredQueriesCore.Statement<(repeat each Value)>) { - self.statement = SQLQueryExpression(statement) - } - func fetch(_ db: Database) throws -> [(repeat (each Value).QueryOutput)] { - try statement.fetchAll(db) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -@available( - *, - deprecated, - message: "Use the '@Selection' macro to bundle multiple values into a value." -) -extension FetchOne { - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - /// Replaces the wrapped value with data from the given query. - /// - /// - Parameters: - /// - statement: A query associated with the wrapped value. - /// - database: The database to read from. A value of `nil` will use the default database - /// (`@Dependency(\.defaultDatabase)`). - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - #if canImport(SwiftUI) - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - #endif -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private struct FetchOneStatementPackRequest: StatementKeyRequest { - let statement: SQLQueryExpression<(repeat each Value)> - init(statement: some StructuredQueriesCore.Statement<(repeat each Value)>) { - self.statement = SQLQueryExpression(statement) - } - func fetch(_ db: Database) throws -> (repeat (each Value).QueryOutput) { - guard let result = try statement.fetchOne(db) - else { throw NotFound() } - return result - } -} diff --git a/Sources/SharingGRDBCore/Internal/Exports.swift b/Sources/SharingGRDBCore/Internal/Exports.swift deleted file mode 100644 index 8768304b..00000000 --- a/Sources/SharingGRDBCore/Internal/Exports.swift +++ /dev/null @@ -1,4 +0,0 @@ -@_exported import Dependencies -@_exported import GRDB -@_exported import Sharing -@_exported import StructuredQueriesGRDBCore diff --git a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md deleted file mode 100644 index c62671c0..00000000 --- a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md +++ /dev/null @@ -1,10 +0,0 @@ -# ``StructuredQueriesGRDB`` - -A library interfacing StructuredQueries with GRDB. This module is automatically imported when you -`import SharingGRDB`. - -## Overview - -The core functionality of this module is defined in -[`StructuredQueriesGRDBCore`](structuredqueriesgrdbcore) and then re-exported alongside -`StructuredQueries` and its macros. diff --git a/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift b/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift deleted file mode 100644 index 047e9bfe..00000000 --- a/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift +++ /dev/null @@ -1,3 +0,0 @@ -@_exported import StructuredQueries -@_exported import StructuredQueriesSQLite -@_exported import StructuredQueriesGRDBCore diff --git a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md deleted file mode 100644 index be53d0f2..00000000 --- a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md +++ /dev/null @@ -1,70 +0,0 @@ -# ``StructuredQueriesGRDBCore`` - -The core functionality of interfacing StructuredQueries with GRDB. This module is automatically -imported when you `import SharingGRDB` or `StructuredQueriesGRDB`. - -## Overview - -This library can be used to directly execute queries built using the [StructuredQueries][sq-gh] -library and a [GRDB][grdb-gh] database. - -While the `SharingGRDB` module provides tools to observe queries using the `@FetchAll`, `@FetchOne`, -and `@Fetch` property wrappers, you will also want to execute one-off queries directly, especially -when it comes to `INSERT`, `UPDATE`, and `DELETE` statements. This module extends -StructuredQueries' `Statement` type with `execute`, `fetchAll`, `fetchOne`, and `fetchCount` methods -that execute the query on a given GRDB database. - -```swift -@Table -struct Player { - let id: Int - var name = "" - var score = 0 -} - -try #sql( - """ - CREATE TABLE players ( - id INTEGER PRIMARY KEY, - name TEXT, - score INTEGER - ) - """ -) -.execute(db) - -let players = Player - .where { $0.score > 10 } - .fetchAll(db) -// SELECT … FROM "players" -// WHERE "players"."score" > 10 - -let averageScore = try Player - .select { $0.score.avg() } - .fetchOne(db) -// SELECT avg("players"."score") FROM "players" -``` - -For more information on how to build queries, see the [StructuredQueries documentation][sq-spi]. - -[sq-gh]: https://github.com/pointfreeco/swift-structured-queries -[sq-spi]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore -[grdb-gh]: https://github.com/groue/GRDB.swift - -## Topics - -### Executing statements - -- ``StructuredQueriesCore/Statement/execute(_:)`` -- ``StructuredQueriesCore/Statement/fetchAll(_:)`` -- ``StructuredQueriesCore/Statement/fetchOne(_:)`` -- ``StructuredQueriesCore/Statement/fetchCursor(_:)`` -- ``StructuredQueriesCore/SelectStatement/fetchCount(_:)`` - -### Iterating over rows - -- ``QueryCursor`` - -### Seeding data - -- ``GRDB/Database/seed(_:)`` diff --git a/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift b/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift deleted file mode 100644 index e29fce7e..00000000 --- a/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift +++ /dev/null @@ -1,2 +0,0 @@ -@_exported import StructuredQueriesCore -@_exported import StructuredQueriesSQLiteCore diff --git a/Tests/SharingGRDBTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift similarity index 91% rename from Tests/SharingGRDBTests/AssertQueryTests.swift rename to Tests/SQLiteDataTests/AssertQueryTests.swift index bebbb987..4dfb959a 100644 --- a/Tests/SharingGRDBTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,19 +1,16 @@ -import Dependencies import DependenciesTestSupport import Foundation -import GRDB -import Sharing -import SharingGRDB -import SharingGRDBTestSupport +import SQLiteData +import SQLiteDataTestSupport import SnapshotTesting -import StructuredQueries import Testing @Suite( .dependency(\.defaultDatabase, try .database()), - .snapshots(record: .failed), + .snapshots(record: .missing), ) struct AssertQueryTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasic() throws { assertQuery( Record.all.select(\.id) @@ -27,6 +24,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecord() throws { assertQuery( Record.where { $0.id == 1 } @@ -41,6 +40,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasicUpdate() throws { assertQuery( Record.all @@ -56,6 +57,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecordUpdate() throws { assertQuery( Record @@ -73,7 +76,9 @@ struct AssertQueryTests { """ } } + #if DEBUG + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasicIncludeSQL() throws { assertQuery( includeSQL: true, @@ -94,7 +99,9 @@ struct AssertQueryTests { } } #endif + #if DEBUG + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecordIncludeSQL() throws { assertQuery( includeSQL: true, diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift new file mode 100644 index 00000000..e5865a70 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -0,0 +1,618 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + import SQLiteDataTestSupport + + extension BaseCloudKitTests { + @MainActor + final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { + try await userDatabase.userWrite { 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) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await signOut() + + try await userDatabase.read { 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, *) + @Test(.accountStatus(.noAccount)) func signInUploadsLocalRecordsToCloudKit() async throws { + try await userDatabase.userWrite { 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) + } + } + + 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) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + await signIn() + + try await syncEngine.processPendingDatabaseChanges(scope: .private) + 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: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 0, + remindersListID: 1 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + + // * Create reminders list + // * Soft log out + // * Create reminder in list + // * Sign in + // * Reminder is sync'd to CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func signInUploadsLocalRecordsToCloudKit_SkipExistingCloudKitRecords() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await softSignOut() + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 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: [] + ) + ) + """ + } + + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + 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: "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: [] + ) + ) + """ + } + } + + // * Join shared reminders list + // * Soft log out + // * Create reminder in list + // * Sign in + // * Reminder is sync'd to CloudKit with proper metadata + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createSharedRecordWhileSoftLoggedOut() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: freshShare + ) + ) + + await softSignOut() + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ 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, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ 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, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Get milk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @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) + } + } + } + ) + func doNotUploadExistingDataToCloudKitWhenSignedOut() { + assertQuery(SyncMetadata.all, database: userDatabase.database) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift new file mode 100644 index 00000000..e6af0fd2 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -0,0 +1,139 @@ +#if canImport(CloudKit) && canImport(UIKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + import SQLiteDataTestSupport + + import UIKit + + extension BaseCloudKitTests { + @MainActor + @Suite + final class AppLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendChangesOnBackground() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + defaultNotificationCenter.post( + name: UIApplication.willResignActiveNotification, object: nil) + try await Task.sleep(for: .seconds(1)) + 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendSharedChanges() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + defaultNotificationCenter.post( + name: UIApplication.willResignActiveNotification, object: nil) + try await Task.sleep(for: .seconds(1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift new file mode 100644 index 00000000..2be6702b --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -0,0 +1,174 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } + + @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(id: 1, coverImage: Data("image".utf8), remindersListID: 1) + } + } + + 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, + id: 1, + remindersListID: 1, + coverImage: CKAsset( + fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), + dataString: "image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + inMemoryDataManager.storage.withValue { storage in + let url = URL( + string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + )! + #expect(storage[url] == Data("image".utf8)) + } + + 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, + id: 1, + remindersListID: 1, + coverImage: CKAsset( + fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), + dataString: "new-image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + inMemoryDataManager.storage.withValue { storage in + let url = URL( + string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" + )! + #expect(storage[url] == Data("new-image".utf8)) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveAsset() 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 + ) + + 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)) + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift new file mode 100644 index 00000000..460c27da --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -0,0 +1,896 @@ +#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 CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() throws { + let zones = try syncEngine.metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: zones, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [1]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coverImage" BLOB NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + isNotNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [2]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [3]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "remindersListID" INTEGER NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + isNotNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [4]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [5]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [6]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [7]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + isNotNull: false, + type: "INTEGER" + ) + ] + ), + [8]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "parentID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [9]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "count", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [10]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isOn", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [11]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ) + ] + """# + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownAndReSetUp() async throws { + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.select(\.recordName), database: syncEngine.metadatabase) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } + 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAndRemoveFunctions() async throws { + let query = #sql( + """ + SELECT name + FROM pragma_function_list + WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") + ORDER BY name + """, + as: String.self + ) + assertInlineSnapshot( + of: try { try userDatabase.write { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [ + [0]: "sqlitedata_icloud_currentownername", + [1]: "sqlitedata_icloud_currenttime", + [2]: "sqlitedata_icloud_currentzonename", + [3]: "sqlitedata_icloud_diddelete", + [4]: "sqlitedata_icloud_didupdate", + [5]: "sqlitedata_icloud_haspermission", + [6]: "sqlitedata_icloud_syncengineissynchronizingchanges" + ] + """ + } + try syncEngine.tearDownSyncEngine() + + assertInlineSnapshot( + of: try { try userDatabase.read { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [] + """ + } + + try syncEngine.setUpSyncEngine() + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertUpdateDelete() async throws { + try await userDatabase.userWrite { db in + try RemindersList + .insert { RemindersList(id: 1, title: "Personal") } + .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:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList + .find(1) + .update { $0.title = "Work" } + .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:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Work" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.userWrite { db in + try RemindersList + .find(1) + .delete() + .execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdate() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 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) { + """ + ┌─────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Work" │ + │ ) │ + └─────────────────┘ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + ┌────┐ + │ 60 │ + └────┘ + """ + } + 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: "Work" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerSendsRecordWithNoChanges() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) + } + } + + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + 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: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdateWithOldRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + 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() + + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + ┌───┐ + │ 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordDeleted() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 1)] + ) + .notify() + + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + """ + } + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cascadingDeletionOrder() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "fun") + Tag(title: "weekend") + } + } + for _ in 1...100 { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 1, remindersListID: 1) + Reminder(id: 1, title: "", remindersListID: 1) + Reminder(id: 2, title: "", remindersListID: 1) + Reminder(id: 3, title: "", remindersListID: 1) + Reminder(id: 4, title: "", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: "fun") + ReminderTag(id: 2, reminderID: 2, tagID: "fun") + ReminderTag(id: 3, reminderID: 3, tagID: "fun") + ReminderTag(id: 4, reminderID: 4, tagID: "fun") + ReminderTag(id: 5, reminderID: 1, tagID: "weekend") + ReminderTag(id: 6, reminderID: 2, tagID: "weekend") + ReminderTag(id: 7, reminderID: 3, tagID: "weekend") + ReminderTag(id: 8, reminderID: 4, tagID: "weekend") + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(fun:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "fun" + ), + [1]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func generatedColumns() async throws { + try await userDatabase.userWrite { db in + try db.seed { + ModelA(id: 1, count: 42, isEven: true) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: ModelA.recordID(for: 1)) + record.encryptedValues["isEven"] = false + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1, + isEven: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.read { db in + let modelA = try #require(try ModelA.find(1).fetchOne(db)) + #expect(modelA.isEven == true) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift new file mode 100644 index 00000000..6e6007af --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -0,0 +1,741 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite + final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveExtraFieldsToSyncMetadata() 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 syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue("Hello world! 🌎🌎🌎", forKey: "newField", at: now) + + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + + do { + let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in + try SyncMetadata + .order(by: \.recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { + """ + [ + [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, + newField: "Hello world! 🌎🌎🌎", + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + """ + } + } + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + do { + let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in + try SyncMetadata + .order(by: \.recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { + """ + [ + [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: 1, + newField: "Hello world! 🌎🌎🌎", + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + """ + } + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteChangeParentRelationship() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + } + + try await withDependencies { + $0.currentTime.now += 2 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌──────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: true, │ + │ priority: nil, │ + │ 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 │ + └────────────────────┴────────────────────┘ + """ + } + 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(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 1, + remindersListID: 2, + title: "Get milk" + ), + [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: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @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.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) + } + } + 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( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue("1", forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue("1", forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + await remindersListModification.notify() + + try await withDependencies { + $0.currentTime.now += 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) + + 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( + 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: "Buy 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteMultipleRecords() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 3, title: "Get milk", remindersListID: 1) + RemindersList(id: 2, title: "Business") + Reminder(id: 4, title: "Call accountant", remindersListID: 2) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [ + RemindersList.recordID(for: 1), + RemindersList.recordID(for: 2), + Reminder.recordID(for: 3), + Reminder.recordID(for: 4), + ] + ) + .notify() + + try await userDatabase.read { db in + try #expect(Reminder.all.fetchCount(db) == 0) + try #expect(RemindersList.all.fetchCount(db) == 0) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveRecord_SingleFieldPrimaryKey() async throws { + let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) + tagRecord.encryptedValues["title"] = "weekend" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + try await userDatabase.read { db in + try #expect(Tag.all.fetchAll(db) == [Tag(title: "weekend")]) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func renamePrimaryKey() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "weekend") + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Tag.find("weekend").update { $0.title = "optional" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.select(\.recordName), database: userDatabase.database) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + │ "1:reminders" │ + │ "1:reminderTags" │ + │ "optional:tags" │ + └────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: 1, + reminderID: 1, + tagID: "optional" + ), + [1]: 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" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [3]: CKRecord( + recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "optional" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagLocallyThenCreateSameTagRemotely() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────┐ + │ Tag(title: "tag") │ + └───────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "tag", │ + │ recordType: "tags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "tag:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "tag" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagRemotelyThenCreateSameTagLocally() async throws { + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + let modifications = try syncEngine.modifyRecords(scope: .private, saving: [tagRecord]) + + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────┐ + │ Tag(title: "tag") │ + └───────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "tag", │ + │ recordType: "tags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "tag:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "tag" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.userWrite { db in + try Tag.find("tag").update { $0.title = "weekend" }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Tag(title: "weekend") │ + └───────────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "weekend", │ + │ recordType: "tags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func invalidRecordName() async throws { + let error = await #expect(throws: DatabaseError.self) { + try await self.userDatabase.userWrite { db in + try Tag.insert { Tag(title: "_tag") }.execute(db) + } + } + #expect(error?.message == SyncEngine.invalidRecordNameError) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift new file mode 100644 index 00000000..d73cb343 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -0,0 +1,152 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite + final class FetchedDatabaseChangesTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteSyncEngineZone() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersListPrivate(id: 1, remindersListID: 1) + RemindersListPrivate(id: 2, remindersListID: 2) + UnsyncedModel(id: 1) + UnsyncedModel(id: 2) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await syncEngine.modifyRecordZones( + scope: .private, + deleting: [syncEngine.defaultZone.zoneID] + ).notify() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + + try await userDatabase.read { db in + try #expect(Reminder.all.fetchAll(db) == []) + try #expect(RemindersList.all.fetchAll(db) == []) + try #expect(RemindersListPrivate.all.fetchAll(db) == []) + try #expect( + UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersListPrivate(id: 1, remindersListID: 1) + RemindersListPrivate(id: 2, remindersListID: 2) + UnsyncedModel(id: 1) + UnsyncedModel(id: 2) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await syncEngine + .handleEvent( + SyncEngine.Event.fetchedDatabaseChanges( + modifications: [], + deletions: [(syncEngine.defaultZone.zoneID, .encryptedDataReset)] + ), + syncEngine: syncEngine.private + ) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 2) + try #expect(RemindersList.count().fetchOne(db) == 2) + try #expect(RemindersListPrivate.count().fetchOne(db) == 2) + try #expect(UnsyncedModel.count().fetchOne(db) == 2) + } + + 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: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 2, + isCompleted: 0, + remindersListID: 2, + title: "Call accountant" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 0, + remindersListID: 1 + ), + [3]: CKRecord( + recordID: CKRecord.ID(2:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 2, + position: 0, + remindersListID: 2 + ), + [4]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [5]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 2, + title: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift new file mode 100644 index 00000000..40688a42 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -0,0 +1,864 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import SQLiteDataTestSupport + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + 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( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + await remindersListModification.notify() + + try await withDependencies { + $0.currentTime.now += 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) + + 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( + 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: "Buy 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: [] + ) + ) + """ + } + } + + /* + * Remote client creates records A <- B <- C + * Records A and C are sync'd to local client. + * Remote deletes record B and C. + * Unsynced C should be deleted from local client. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { + let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) + let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) + modelBRecord.setValue(1, forKey: "modelAID", at: now) + modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) + let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) + modelCRecord.setValue(1, forKey: "modelBID", at: now) + modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) + + try await syncEngine.modifyRecords(scope: .private, saving: [modelARecord]).notify() + _ = try syncEngine.modifyRecords(scope: .private, saving: [modelBRecord]) + try await syncEngine.modifyRecords(scope: .private, saving: [modelCRecord]).notify() + + 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( + scope: .private, + deleting: [modelCRecord.recordID, modelBRecord.recordID] + ) + .notify() + + 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( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Receive child record with no 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 { + 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + let freshReminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + let freshRemindersListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [freshReminderRecord, freshRemindersListRecord] + ) + .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( + 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveChild_Relaunch_ReceiveParent() 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + """ + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine + .handleEvent( + .fetchedRecordZoneChanges(modifications: [remindersListRecord], deletions: []), + syncEngine: relaunchedSyncEngine.private + ) + + 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 { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) + } + + 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( + 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: "Buy 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: [] + ) + ) + """ + } + } + + // * Remote moves child to a parent the local client does not know about. + // * Remote syncs child to local. + // * Remote syncs parent to local. + // => Parent and child records are synchronized. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test + func changeParentRelationshipToUnknownRecord() async throws { + let personalListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(1, forKey: "id", at: now) + personalListRecord.setValue("Personal", forKey: "title", at: now) + + let businessListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(2, forKey: "id", at: now) + businessListRecord.setValue("Business", forKey: "title", at: now) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: personalListRecord, + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord, personalListRecord] + ).notify() + + let modifications = try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + saving: [businessListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + return modifications + } + + 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( + privateCloudDatabase: MockCloudDatabase( + 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, + remindersListID: 2, + title: "Get milk" + ), + [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: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * 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 + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try withDependencies { + $0.currentTime.now += 1 + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + + try await withDependencies { + $0.currentTime.now += 2 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1) + .update { + $0.title = "Buy milk" + $0.remindersListID = 3 + } + .execute(db) + } + } + + await modifications.notify() + 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) + ], + as: .customDump + ) { + """ + 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: "Buy milk" + ) + """ + } + } + + // * 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() + async throws + { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try withDependencies { + $0.currentTime.now += 1 + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + + try await withDependencies { + $0.currentTime.now += 2 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + 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: "Get milk", │ + │ remindersListID: 3 │ + │ ) │ + └───────────────────────┘ + """ + } + 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(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Get milk" + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cascadingDeletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + RemindersList(id: 2, title: "Work") + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersList(id: 3, title: "Secret") + Reminder(id: 3, title: "Schedule secret meeting", remindersListID: 3) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.where { $0.id <= 2 }.delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 3, + isCompleted: 0, + remindersListID: 3, + title: "Schedule secret meeting" + ), + [1]: CKRecord( + recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 3, + title: "Secret" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertForeignKeyConstraintFailure() async throws { + await #expect(throws: (any Error).self) { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift new file mode 100644 index 00000000..e3a5964a --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -0,0 +1,713 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: 60) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.currentTime.now += 30 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.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:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.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, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 30, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordUpdatedBeforeClientRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: 30) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.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:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 30 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.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, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 60, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverAndClientEditDifferentFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: 30) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + } + } + await modificationCallback.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 60, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 30 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + try await withDependencies { + $0.currentTime.now += 30 + } operation: { + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: now) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + await modificationCallback.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + } + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedAndProcessedBeforeClient() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: 30) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + } + await modificationCallback.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: 30) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func mergeWithNullableFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue( + Date(timeIntervalSince1970: Double(now + 30)), + forKey: "dueDate", + at: now + ) + let modificationsFinished = try syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord] + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.priority = 3 }.execute(db) + } + await modificationsFinished.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDate🗓️: 0, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + priority: 3, + priority🗓️: 1, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.read { db in + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + #expect( + reminder + == Reminder( + id: 1, + dueDate: Date(timeIntervalSince1970: 30), + priority: 3, + remindersListID: 1 + ) + ) + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift new file mode 100644 index 00000000..e8e40745 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -0,0 +1,605 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import SQLiteDataTestSupport + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func parentRecordNameUpdatesAfterMovingReminderToDifferentList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Work") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try withDependencies { + $0.currentTime.now += 60 + } operation: { + 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( + privateCloudDatabase: MockCloudDatabase( + 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: "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: [] + ) + ) + """ + } + } + + // '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 + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Tag(title: "weekend") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: 1, + reminderID: 1, + tagID: "weekend" + ), + [1]: 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" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [3]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + 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 metadataFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + 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) + + assertQuery( + SyncMetadata.order(by: \.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminderTags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminderTags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminderTags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "optional", │ + │ recordType: "tags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "weekend", │ + │ recordType: "tags", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func hasMetadataHelper() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Work") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery( + RemindersList.join(SyncMetadata.all) { $0.hasMetadata(in: $1) }, + database: userDatabase.database + ) { + """ + ┌─────────────────────┬────────────────────────────────────────────────────────────────────┐ + │ RemindersList( │ SyncMetadata( │ + │ id: 1, │ recordPrimaryKey: "1", │ + │ title: "Personal" │ recordType: "remindersLists", │ + │ ) │ zoneName: "zone", │ + │ │ ownerName: "__defaultOwner__", │ + │ │ 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, │ + │ │ hasLastKnownServerRecord: true, │ + │ │ isShared: false, │ + │ │ userModificationTime: 0 │ + │ │ ) │ + ├─────────────────────┼────────────────────────────────────────────────────────────────────┤ + │ RemindersList( │ SyncMetadata( │ + │ id: 2, │ recordPrimaryKey: "2", │ + │ title: "Work" │ recordType: "remindersLists", │ + │ ) │ zoneName: "zone", │ + │ │ ownerName: "__defaultOwner__", │ + │ │ 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: "Work" │ + │ │ ), │ + │ │ share: nil, │ + │ │ _isDeleted: false, │ + │ │ hasLastKnownServerRecord: true, │ + │ │ isShared: false, │ + │ │ userModificationTime: 0 │ + │ │ ) │ + └─────────────────────┴────────────────────────────────────────────────────────────────────┘ + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift new file mode 100644 index 00000000..963599f7 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -0,0 +1,414 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + override init() async throws { + try await super.init() + let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( + saving: [ + CKRecordZone( + zoneID: CKRecord(recordType: "A\(Int.random(in: 1...999_999_999))").recordID.zoneID + ) + ], + deleting: [] + ) + #expect(saveZoneResults.allSatisfy({ (try? $1.get()) != nil })) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func fetchRecordInUnknownZone() async throws { + let error = #expect(throws: CKError.self) { + try self.syncEngine.private.database.record( + for: CKRecord.ID( + recordName: "A", + zoneID: CKRecordZone.ID(zoneName: "unknownZone") + ) + ) + } + #expect(error == CKError(.zoneNotFound)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func fetchUnknownRecord() async throws { + let error = #expect(throws: CKError.self) { + try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "A")) + } + #expect(error == CKError(.unknownItem)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveTransaction_ChildBeforeParent() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [child, parent], + deleting: [] + ) + #expect(saveRecordResults.allSatisfy({ (try? $1.get()) != nil })) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveTransaction_ChildNoParent() async throws { + let parent = CKRecord(recordType: "Parent", recordID: CKRecord.ID(recordName: "Parent")) + let child = CKRecord(recordType: "Child", recordID: CKRecord.ID(recordName: "Child")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [child], + deleting: [] + ) + let error = #expect(throws: CKError.self) { + try saveRecordResults[child.recordID]?.get() + } + #expect(error == CKError(.referenceViolation)) + + try await syncEngine.modifyRecords(scope: .private, saving: [child]).notify() + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveInUnknownZone() async throws { + let record = CKRecord( + recordType: "Record", + recordID: CKRecord.ID( + recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) + ) + + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [record], + deleting: [] + ) + let error = #expect(throws: CKError.self) { + try saveRecordResults[record.recordID]?.get() + } + #expect(error == CKError(.zoneNotFound)) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteTransaction_ParentBeforeChild() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + let _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [parent.recordID, child.recordID] + ) + #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteUnknownRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [record.recordID] + ) + #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRecordInUnknownZone() async throws { + let record = CKRecord( + recordType: "A", + recordID: CKRecord.ID(recordName: "A", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) + ) + + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [record.recordID] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[record.recordID]?.get() + } + #expect(error == CKError(.zoneNotFound)) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteTransaction_DeleteParentButNotChild() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [parent.recordID] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[CKRecord.ID(recordName: "A")]?.get() + } + #expect(error == CKError(.referenceViolation)) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteUnknownZone() async throws { + let (_, deleteResults) = try syncEngine.private.database.modifyRecordZones( + saving: [], + deleting: [CKRecordZone.ID(zoneName: "unknownZone")] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[CKRecordZone.ID(zoneName: "unknownZone")]?.get() + } + #expect(error == CKError(.zoneNotFound)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func accountTemporarilyAvailable() async throws { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func noAccount() async throws { + container._accountStatus.withValue { $0 = .noAccount } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func accountNotDetermined() async throws { + container._accountStatus.withValue { $0 = .couldNotDetermine } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func restrictedAccount() async throws { + container._accountStatus.withValue { $0 = .restricted } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveShareWithoutRootRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) + } matching: { issue in + issue.description.hasSuffix( + """ + An added share is being saved without its rootRecord being saved in the \ + same operation. + """) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveShareAndRootThenSaveShareAlone() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + + let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift new file mode 100644 index 00000000..6c0868fb --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -0,0 +1,77 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import SQLiteDataTestSupport + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @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) + 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: "Write blog post" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┐ + │ "1:reminders" │ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift new file mode 100644 index 00000000..839f2df7 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -0,0 +1,228 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func noMetadataForRecord() async throws { + syncEngine.private.state.add( + pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] + ) + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func nonExistentTable() async throws { + try await userDatabase.userWrite { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: "1", + recordType: UnrecognizedTable.tableName, + zoneName: "zone-name", + ownerName: "owner-name", + userModificationTime: 0 + ) + } + .execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func metadataRowWithNoCorrespondingRecordRow() async throws { + try await userDatabase.userWrite { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: "1", + recordType: RemindersList.tableName, + zoneName: syncEngine.defaultZone.zoneID.zoneName, + ownerName: syncEngine.defaultZone.zoneID.ownerName, + userModificationTime: 0 + ) + } + .execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + + 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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveRecordWithParent() 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 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: "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: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func savePrivateRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 42, remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 42, + 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: [] + ) + ) + """ + } + } + } + } + + @Table struct UnrecognizedTable { + let id: UUID + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift new file mode 100644 index 00000000..20bf20a3 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -0,0 +1,526 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() async throws { + let recordTypes = try await syncEngine.metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: recordTypes, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [1]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coverImage" BLOB NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + isNotNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [2]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [3]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "remindersListID" INTEGER NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + isNotNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + isNotNull: true, + type: "INTEGER" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [4]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [5]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [6]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [7]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + isNotNull: false, + type: "INTEGER" + ) + ] + ), + [8]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "parentID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [9]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "count", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [10]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isOn", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + isNotNull: true, + type: "INTEGER" + ) + ] + ), + [11]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ) + ] + """# + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownErasesMetadata() async throws { + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Personal") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.all.fetchCount(db) > 0) + try #expect(RecordType.all.fetchCount(db) > 0) + try #expect(StateSerialization.all.fetchCount(db) == 0) + } + + try syncEngine.tearDownSyncEngine() + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.all.fetchCount(db) == 0) + try #expect(RecordType.all.fetchCount(db) == 0) + try #expect(StateSerialization.all.fetchCount(db) == 0) + } + try syncEngine.setUpSyncEngine() + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @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 syncEngine.metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + expectNoDifference(recordTypes, recordTypesAfterReSetup) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func migration() async throws { + let recordTypes = try await syncEngine.metadatabase.read { db in + try RecordType.order(by: \.tableName).fetchAll(db) + } + syncEngine.stop() + try syncEngine.tearDownSyncEngine() + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL + """ + ) + .execute(db) + } + try syncEngine.setUpSyncEngine() + try await syncEngine.start() + + let recordTypesAfterMigration = try await syncEngine.metadatabase.read { db in + try RecordType.order(by: \.tableName).fetchAll(db) + } + let remindersTableIndex = try #require( + recordTypesAfterMigration.firstIndex { $0.tableName == Reminder.tableName } + ) + #expect( + recordTypes[0.. When data is synchronized the reminder and list are deleted. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveReminderToList_RemoteDeletesList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 2)] + ) + try withDependencies { + $0.currentTime.now += 1 + } operation: { + try userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.read { db in + try #expect(Reminder.find(1).fetchCount(db) == 0) + try #expect(RemindersList.find(2).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: [] + ) + ) + """ + } + + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") + ] + ) + } + } + + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Local data is sync'd first, then remote data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteList_RemoteAddsReminderToList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + } + let modifications = try withDependencies { + $0.currentTime.now += 2 + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.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 + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) + } + } + + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Remote data is sync'd first, then local data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteList_RemoteAddsReminderToList_Variation() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + } + let modifications = try withDependencies { + $0.currentTime.now += 2 + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + 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 + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) + } + } + + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to NULL and parent is deleted. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetNull(id: 1, parentID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.currentTime.now += 2 + } operation: { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/zone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: nil, + share: nil, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + assertQuery(ChildWithOnDeleteSetNull.all, database: userDatabase.database) { + """ + ┌───────────────────────────┐ + │ ChildWithOnDeleteSetNull( │ + │ id: 1, │ + │ parentID: nil │ + │ ) │ + └───────────────────────────┘ + """ + } + assertQuery(Parent.all, database: userDatabase.database) { + """ + ┌───────────────┐ + │ Parent(id: 1) │ + └───────────────┘ + """ + } + } + } + + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to default value. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 0) + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetDefault(id: 1, parentID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.currentTime.now += 2 + } operation: { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/zone/__defaultOwner__), + recordType: "childWithOnDeleteSetDefaults", + parent: CKReference(recordID: CKRecord.ID(0:parents/zone/__defaultOwner__)), + share: nil, + id: 1, + parentID: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(0:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 0 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try await userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ + ChildWithOnDeleteSetDefault(id: 1, parentID: 0) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] + ) + } + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift new file mode 100644 index 00000000..ccf90787 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -0,0 +1,325 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addColumnToRemindersAndRemindersLists() async throws { + let personalList = RemindersList(id: 1, title: "Personal") + let businessList = RemindersList(id: 2, title: "Business") + let reminder = Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + personalList + businessList + reminder + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(1, forKey: "position", at: now) + + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(2, forKey: "position", at: now) + + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(3, forKey: "position", at: now) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, reminderRecord] + ) + .notify() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) + try #sql( + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != Reminder.self && $0 != RemindersList.self } + + [ReminderWithPosition.self, RemindersListWithPosition.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let remindersLists = try await userDatabase.read { db in + try RemindersListWithPosition.order(by: \.id).fetchAll(db) + } + let reminders = try await userDatabase.read { db in + try ReminderWithPosition.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithPosition(id: 1, title: "Personal", position: 1), + RemindersListWithPosition(id: 2, title: "Business", position: 2), + ] + ) + expectNoDifference( + reminders, + [ + ReminderWithPosition( + id: 1, + title: "Get milk", + position: 3, + remindersListID: 1 + ) + ] + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList() async throws { + let personalList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + personalList + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord] + ) + .notify() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let remindersLists = try await userDatabase.read { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: 1, image: Data("image".utf8), title: "Personal") + ] + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList_Redownload() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(Array("personal-image".utf8), forKey: "image", at: now) + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(Array("business-image".utf8), forKey: "image", at: now) + let secretListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 3) + ) + secretListRecord.setValue(Array("secret-image".utf8), forKey: "image", at: now) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, secretListRecord] + ) + .notify() + + inMemoryDataManager.storage.withValue { $0.removeAll() } + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let remindersLists = try await userDatabase.read { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: 1, image: Data("personal-image".utf8), title: "Personal"), + RemindersListWithData(id: 2, image: Data("business-image".utf8), title: "Business"), + RemindersListWithData(id: 3, image: Data("secret-image".utf8), title: "Secret"), + ] + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func newTable() async throws { + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + let imageRecord = CKRecord( + recordType: "images", + recordID: Image.recordID(for: 1) + ) + imageRecord.setValue("1", forKey: "id", at: now) + imageRecord.setValue("A good image", forKey: "caption", at: now) + imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [imageRecord] + ) + .notify() + + inMemoryDataManager.storage.withValue { $0.removeAll() } + + try await userDatabase.userWrite { db in + try #sql( + """ + CREATE TABLE "images" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "caption" TEXT NOT NULL, + "image" BLOB NOT NULL + ) + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + [Image.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let images = try await userDatabase.read { db in + try Image.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + images, + [ + Image(id: 1, image: Data("image".utf8), caption: "A good image") + ] + ) + } + } + } + } + + @Table("remindersLists") + private struct RemindersListWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 + } + + @Table("reminders") + private struct ReminderWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 + var remindersListID: RemindersList.ID + } + + @Table("remindersLists") + private struct RemindersListWithData: Equatable, Identifiable { + let id: Int + var image: Data + var title = "" + } + + @Table + private struct Image: Equatable, Identifiable { + let id: Int + var image: Data + var caption = "" + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift new file mode 100644 index 00000000..2f867b2f --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift @@ -0,0 +1,470 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import GRDB + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class SharingPermissionsTests: BaseCloudKitTests, @unchecked Sendable { + /// Inserting record into shared record when user does not have permission should be rejected. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertRecordInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.all.fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + /// Delete record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReminderInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [reminderRecord, remindersListRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.find(1).delete().execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.count().fetchOne(db) == 1) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + /// Editing record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editReminderInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.update { $0.isCompleted = true }.execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await self.userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.read { db in + try #expect(Reminder.all.fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await self.userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Business" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.read { db in + try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift new file mode 100644 index 00000000..7c9646e9 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -0,0 +1,2582 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import SQLiteDataTestSupport + import Foundation + import GRDB + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class SharingTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareNonRootRecord() async throws { + let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + reminder + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) + } + assertInlineSnapshot(of: error?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "reminders", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .cascade, + onDelete: .cascade, + isNotNull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnrecognizedTable() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: UnsyncedModel(id: 42), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SharingError( + recordTableName: "unsyncedModels", + recordPrimaryKey: "42", + reason: .recordTableNotSynchronized, + debugDescription: "Table is not shareable: table type not passed to \'tables\' parameter of \'SyncEngine.init\'." + ) + """# + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharePrivateTable() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: RemindersListPrivate(id: 1, remindersListID: 1), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersListPrivates", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + isNotNull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareRecordBeforeSync() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: RemindersList(id: 1), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersLists", + recordPrimaryKey: "1", + reason: .recordMetadataNotFound, + debugDescription: "No sync metadata found for record. Has the record been saved to the database?" + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordInExternallySharedRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareDeliveredBeforeRecord() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: externalZone.zoneID + ) + ) + + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.shared.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]) + .notify() + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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" + ) + ] + ) + ) + """ + } + + assertQuery(SyncMetadata.order(by: \.recordName), database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareeCreatesMultipleChildModels() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 1, zoneID: externalZoneID) + ) + modelARecord.setValue(1, forKey: "id", at: now) + modelARecord.setValue(0, forKey: "count", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]).notify() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + ModelB(id: 1, modelAID: 1) + ModelC(id: 1, modelBID: 1) + } + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 0, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "1:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 0, │ + │ modelAID: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 60 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelCs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelCs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelBs", │ + │ parentRecordName: "1:modelBs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ modelBID: 1, │ + │ title: "" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 60 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: nil, + count: 0, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 0, + modelAID: 1 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRecordInExternallySharedRecord() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + + try await syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ).notify() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).delete().execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func share() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + 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) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // NB: Swift 6.2 cannot currently compile this: + // Pattern that the region based isolation checker does not understand how to check. + // Please file a bug. + #if swift(<6.2) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func unshareNonSharedRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withKnownIssue { + try await syncEngine.unshare(record: remindersList) + } matching: { issue in + issue.description.hasSuffix( + """ + Issue recorded: No share found associated with record. + """ + ) + } + } + #endif + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnshareShareAgain() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await syncEngine.unshare(record: remindersList) + + 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: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShare() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: freshShare + ) + ) + + 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) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShareCreateReminder() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ 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, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Get milk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserOwnsRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try #expect(RemindersList.all.fetchCount(db) == 0) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + /// Deleting a root shared record that is not owned by current user should only delete + /// the CKShare but not the actual records. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserNotOwner() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Take a walk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery(RemindersList.all, database: userDatabase.database) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 2, + isCompleted: 0, + remindersListID: 1, + title: "Take a walk" + ), + [2]: 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, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + /// Syncing deletion of a root shared record that is not owned by current user should delete + /// entire zone. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncDeletedRootSharedRecord_CurrentUserNotOwner() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Take a walk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await syncEngine.modifyRecordZones(scope: .shared, deleting: [externalZone.zoneID]) + .notify() + + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery(RemindersList.all, database: userDatabase.database) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // NB: Come back to this when we have time to investigate. + // /// Deleting a root shared record that is not owned by current user should only delete + // /// the CKShare, not delete the actual CloudKit records, but delete all the local records. + // @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Test func deleteRootSharedRecord_OnDeleteSetNull() async throws { + // let externalZone = CKRecordZone( + // zoneID: CKRecordZone.ID( + // zoneName: "external.zone", + // ownerName: "external.owner" + // ) + // ) + // try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + // + // let parentRecord = CKRecord( + // recordType: Parent.tableName, + // recordID: Parent.recordID(for: 1, zoneID: externalZone.zoneID) + // ) + // parentRecord.setValue(1, forKey: "id", at: now) + // let share = CKShare( + // rootRecord: parentRecord, + // shareID: CKRecord.ID( + // recordName: "share-\(parentRecord.recordID.recordName)", + // zoneID: parentRecord.recordID.zoneID + // ) + // ) + // + // try await syncEngine + // .acceptShare( + // metadata: ShareMetadata( + // containerIdentifier: container.containerIdentifier!, + // hierarchicalRootRecordID: parentRecord.recordID, + // rootRecord: parentRecord, + // share: share + // ) + // ) + // + // try await userDatabase.userWrite { db in + // try db.seed { + // ChildWithOnDeleteSetNull(id: 1, parentID: 1) + // } + // } + // + // try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + // + // try await userDatabase.userWrite { db in + // try Parent.find(1).delete().execute(db) + // } + // + // try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + // + // assertQuery(Parent.all, database: userDatabase.database) + // assertQuery(ChildWithOnDeleteSetNull.all, database: userDatabase.database) + // assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) + // assertInlineSnapshot(of: container, as: .customDump) + // } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func movesChildRecordFromPrivateParentToSharedParent() async throws { + try await userDatabase.userWrite { db in + try db.seed { + ModelA.Draft(id: 1, count: 42) + ModelB.Draft(id: 1, isOn: true, modelAID: 1) + ModelC.Draft(id: 1, title: "Blob", modelBID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID) + ) + modelARecord.setValue(2, forKey: "id", at: now) + modelARecord.setValue(1729, forKey: "count", at: now) + let share = CKShare( + rootRecord: modelARecord, + shareID: CKRecord.ID( + recordName: "share-\(modelARecord.recordID.recordName)", + zoneID: modelARecord.recordID.zoneID + ) + ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, modelARecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshModelARecord = try syncEngine.shared.database.record(for: modelARecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshModelARecord.recordID, + rootRecord: freshModelARecord, + share: freshShare + ) + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await self.userDatabase.userWrite { db in + try ModelB.find(1).update { $0.modelAID = 2 }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + } + + assertQuery(ModelB.all, database: userDatabase.database) { + """ + ┌───────────────┐ + │ ModelB( │ + │ id: 1, │ + │ isOn: true, │ + │ modelAID: 2 │ + │ ) │ + └───────────────┘ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + ┌──────────────────┐ + │ ModelC( │ + │ id: 1, │ + │ title: "Blob", │ + │ modelBID: 1 │ + │ ) │ + └──────────────────┘ + """ + } + assertQuery( + SyncMetadata.order { ($0.recordType, $0.recordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 42, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "2:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), │ + │ count: 1729, │ + │ id: 2 │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "2:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 1, │ + │ modelAID: 2 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 1 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelCs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelCs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelBs", │ + │ parentRecordName: "1:modelBs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ modelBID: 1, │ + │ title: "Blob" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), + count: 1729, + id: 2 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 1, + modelAID: 2 + ), + [3]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "Blob" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func movesChildRecordFromPrivateParentToSharedParent_ReceiveDeleteBeforeSave() + async throws + { + try await userDatabase.userWrite { db in + try db.seed { + ModelA.Draft(id: 1, count: 42) + ModelB.Draft(id: 1, isOn: true, modelAID: 1) + ModelC.Draft(id: 1, title: "Blob", modelBID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID) + ) + modelARecord.setValue(2, forKey: "id", at: now) + modelARecord.setValue(1729, forKey: "count", at: now) + let share = CKShare( + rootRecord: modelARecord, + shareID: CKRecord.ID( + recordName: "share-\(modelARecord.recordID.recordName)", + zoneID: modelARecord.recordID.zoneID + ) + ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, modelARecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshModelARecord = try syncEngine.shared.database.record(for: modelARecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshModelARecord.recordID, + rootRecord: freshModelARecord, + share: freshShare + ) + ) + + let movedModelBRecord = CKRecord( + recordType: ModelB.tableName, + recordID: ModelB.recordID(for: 1, zoneID: externalZone.zoneID) + ) + movedModelBRecord.setValue(1, forKey: "id", at: now) + movedModelBRecord.setValue(true, forKey: "isOn", at: now) + movedModelBRecord.setValue(2, forKey: "modelAID", at: now) + movedModelBRecord.parent = CKRecord.Reference( + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID), + action: .none + ) + let movedModelCRecord = CKRecord( + recordType: ModelC.tableName, + recordID: ModelC.recordID(for: 1, zoneID: externalZone.zoneID) + ) + movedModelCRecord.setValue(1, forKey: "id", at: now) + movedModelCRecord.setValue("Blob", forKey: "title", at: now) + movedModelCRecord.setValue(1, forKey: "modelBID", at: now) + movedModelCRecord.parent = CKRecord.Reference( + recordID: ModelB.recordID(for: 1, zoneID: externalZone.zoneID), + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [ModelB.recordID(for: 1), ModelC.recordID(for: 1)] + ).notify() + try await syncEngine.modifyRecords( + scope: .shared, + saving: [movedModelBRecord, movedModelCRecord] + ).notify() + + assertQuery(ModelB.all, database: userDatabase.database) { + """ + ┌───────────────┐ + │ ModelB( │ + │ id: 1, │ + │ isOn: true, │ + │ modelAID: 2 │ + │ ) │ + └───────────────┘ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + ┌──────────────────┐ + │ ModelC( │ + │ id: 1, │ + │ title: "Blob", │ + │ modelBID: 1 │ + │ ) │ + └──────────────────┘ + """ + } + assertQuery( + SyncMetadata.order { ($0.recordType, $0.recordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 42, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "2:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), │ + │ count: 1729, │ + │ id: 2 │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "2:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 1, │ + │ modelAID: 2 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelCs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelCs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelBs", │ + │ parentRecordName: "1:modelBs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ modelBID: 1, │ + │ title: "Blob" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), + count: 1729, + id: 2 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 1, + modelAID: 2 + ), + [3]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "Blob" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func movesChildRecordFromPrivateParentToSharedParent_ReceiveSaveBeforeDelete() + async throws + { + try await userDatabase.userWrite { db in + try db.seed { + ModelA.Draft(id: 1, count: 42) + ModelB.Draft(id: 1, isOn: true, modelAID: 1) + ModelC.Draft(id: 1, title: "Blob", modelBID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID) + ) + modelARecord.setValue(2, forKey: "id", at: now) + modelARecord.setValue(1729, forKey: "count", at: now) + let share = CKShare( + rootRecord: modelARecord, + shareID: CKRecord.ID( + recordName: "share-\(modelARecord.recordID.recordName)", + zoneID: modelARecord.recordID.zoneID + ) + ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, modelARecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshModelARecord = try syncEngine.shared.database.record(for: modelARecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshModelARecord.recordID, + rootRecord: freshModelARecord, + share: freshShare + ) + ) + + let movedModelBRecord = CKRecord( + recordType: ModelB.tableName, + recordID: ModelB.recordID(for: 1, zoneID: externalZone.zoneID) + ) + movedModelBRecord.setValue(1, forKey: "id", at: now) + movedModelBRecord.setValue(true, forKey: "isOn", at: now) + movedModelBRecord.setValue(2, forKey: "modelAID", at: now) + movedModelBRecord.parent = CKRecord.Reference( + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID), + action: .none + ) + let movedModelCRecord = CKRecord( + recordType: ModelC.tableName, + recordID: ModelC.recordID(for: 1, zoneID: externalZone.zoneID) + ) + movedModelCRecord.setValue(1, forKey: "id", at: now) + movedModelCRecord.setValue("Blob", forKey: "title", at: now) + movedModelCRecord.setValue(1, forKey: "modelBID", at: now) + movedModelCRecord.parent = CKRecord.Reference( + recordID: ModelB.recordID(for: 1, zoneID: externalZone.zoneID), + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .shared, + saving: [movedModelBRecord, movedModelCRecord] + ).notify() + try await syncEngine.modifyRecords( + scope: .private, + deleting: [ModelB.recordID(for: 1), ModelC.recordID(for: 1)] + ).notify() + + assertQuery(ModelB.all, database: userDatabase.database) { + """ + ┌───────────────┐ + │ ModelB( │ + │ id: 1, │ + │ isOn: true, │ + │ modelAID: 2 │ + │ ) │ + └───────────────┘ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + ┌──────────────────┐ + │ ModelC( │ + │ id: 1, │ + │ title: "Blob", │ + │ modelBID: 1 │ + │ ) │ + └──────────────────┘ + """ + } + assertQuery( + SyncMetadata.order { ($0.recordType, $0.recordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 42, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "2:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), │ + │ count: 1729, │ + │ id: 2 │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "2:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 1, │ + │ modelAID: 2 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelCs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelCs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelBs", │ + │ parentRecordName: "1:modelBs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ modelBID: 1, │ + │ title: "Blob" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), + count: 1729, + id: 2 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 1, + modelAID: 2 + ), + [3]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "Blob" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func movesChildRecordFromSharedParentToPrivateParent() async throws { + try await userDatabase.userWrite { db in + try db.seed { + ModelA.Draft(id: 1, count: 42) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID) + ) + modelARecord.setValue(2, forKey: "id", at: now) + modelARecord.setValue(1729, forKey: "count", at: now) + let share = CKShare( + rootRecord: modelARecord, + shareID: CKRecord.ID( + recordName: "share-\(modelARecord.recordID.recordName)", + zoneID: modelARecord.recordID.zoneID + ) + ) + let modelBRecord = CKRecord( + recordType: ModelB.tableName, + recordID: ModelB.recordID(for: 1, zoneID: externalZone.zoneID) + ) + modelBRecord.setValue(1, forKey: "id", at: now) + modelBRecord.setValue(true, forKey: "isOne", at: now) + modelBRecord.setValue(1, forKey: "modelAID", at: now) + modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) + + _ = + try syncEngine + .modifyRecords(scope: .shared, saving: [share, modelARecord, modelBRecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshModelARecord = try syncEngine.shared.database.record(for: modelARecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshModelARecord.recordID, + rootRecord: freshModelARecord, + share: freshShare + ) + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await self.userDatabase.userWrite { db in + try ModelB.find(1).update { $0.modelAID = 1 }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + } + + assertQuery(ModelB.all, database: userDatabase.database) { + """ + ┌────────────────┐ + │ ModelB( │ + │ id: 1, │ + │ isOn: false, │ + │ modelAID: 1 │ + │ ) │ + └────────────────┘ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) + assertQuery( + SyncMetadata.order { ($0.recordType, $0.recordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 42, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "2:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), │ + │ count: 1729, │ + │ id: 2 │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "1:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/zone/__defaultOwner__), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/zone/__defaultOwner__), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 0, │ + │ modelAID: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 1 │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:modelBs/zone/__defaultOwner__), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__)), + share: nil, + id: 1, + isOn: 0, + modelAID: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), + count: 1729, + id: 2 + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test + func movesChildRecordFromPrivateParentToSharedParentWhileSyncEngineStopped() async throws { + try await userDatabase.userWrite { db in + try db.seed { + ModelA.Draft(id: 1, count: 42) + ModelB.Draft(id: 1, isOn: true, modelAID: 1) + ModelC.Draft(id: 1, title: "Blob", modelBID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 2, zoneID: externalZone.zoneID) + ) + modelARecord.setValue(2, forKey: "id", at: now) + modelARecord.setValue(1729, forKey: "count", at: now) + let share = CKShare( + rootRecord: modelARecord, + shareID: CKRecord.ID( + recordName: "share-\(modelARecord.recordID.recordName)", + zoneID: modelARecord.recordID.zoneID + ) + ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, modelARecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshModelARecord = try syncEngine.shared.database.record(for: modelARecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshModelARecord.recordID, + rootRecord: freshModelARecord, + share: freshShare + ) + ) + + syncEngine.stop() + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await self.userDatabase.userWrite { db in + try ModelB.find(1).update { $0.modelAID = 2 }.execute(db) + } + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertQuery(ModelB.all, database: userDatabase.database) { + """ + ┌───────────────┐ + │ ModelB( │ + │ id: 1, │ + │ isOn: true, │ + │ modelAID: 2 │ + │ ) │ + └───────────────┘ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + ┌──────────────────┐ + │ ModelC( │ + │ id: 1, │ + │ title: "Blob", │ + │ modelBID: 1 │ + │ ) │ + └──────────────────┘ + """ + } + assertQuery( + SyncMetadata.order { ($0.recordType, $0.recordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelAs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: nil, │ + │ count: 42, │ + │ id: 1 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "modelAs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "2:modelAs", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), │ + │ recordType: "modelAs", │ + │ parent: nil, │ + │ share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), │ + │ count: 1729, │ + │ id: 2 │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ userModificationTime: 0 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelBs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelBs", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "modelAs", │ + │ parentRecordName: "2:modelAs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), │ + │ recordType: "modelBs", │ + │ parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isOn: 1, │ + │ modelAID: 2 │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 1 │ + │ ) │ + ├──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "modelCs", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:modelCs", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "modelBs", │ + │ parentRecordName: "1:modelBs", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), │ + │ recordType: "modelCs", │ + │ parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ modelBID: 1, │ + │ title: "Blob" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-2:modelAs/external.zone/external.owner)), + count: 1729, + id: 2 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(2:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 1, + modelAID: 2 + ), + [3]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "Blob" + ) + ] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift new file mode 100644 index 00000000..3da22697 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -0,0 +1,660 @@ +#if canImport(CloudKit) + import CloudKit + import DependenciesTestSupport + import InlineSnapshotTesting + import SQLiteDataTestSupport + import OrderedCollections + import SQLiteData + import SnapshotTesting + import SnapshotTestingCustomDump + import Testing + import os + + extension BaseCloudKitTests { + @Suite + struct SyncEngineLifecycleTests { + @MainActor + @Suite + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked + Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func stopAndReStart() async throws { + syncEngine.stop() + + 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)) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + 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: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" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Create list + // * Stop sync engine + // * Delete list + // * Start sync engine + // => List is deleted from CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeStopDeleteStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + 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: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Stop sync engine + // * Edit list + // * Start sync engine + // => List is updated on CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title += "!" }.execute(db) + } + } + try await Task.sleep(for: .seconds(0.5)) + + 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: [] + ) + ) + """ + } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await withDependencies { + $0.currentTime.now += 60 + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + try await Task.sleep(for: .seconds(1)) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ 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: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├───────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "external.zone", │ + │ ownerName: "external.owner", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 60 │ + │ ) │ + └───────────────────────────────────────────────────────────────────────────┘ + """ + } + + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + 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) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { remindersList } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * 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(.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)) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + 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: "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: [] + ) + ) + """ + } + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift new file mode 100644 index 00000000..da5efc9b --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -0,0 +1,102 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import DependenciesTestSupport + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class SyncEngineTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func inMemory() throws { + #expect(URL(string: "")?.isInMemory == nil) + #expect(URL(string: ":memory:")?.isInMemory == true) + #expect(URL(string: ":memory:?cache=shared")?.isInMemory == true) + #expect(URL(string: "file::memory:")?.isInMemory == true) + #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func inMemoryUserDatabase() async throws { + let syncEngine = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "test", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: DatabaseQueue()), + tables: [] + ) + + try await syncEngine.userDatabase.read { db in + try #sql( + """ + SELECT 1 FROM "sqlitedata_icloud_metadata" + """ + ) + .execute(db) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.dependency(\.context, .live)) + func inMemoryUserDatabase_LiveContext() async throws { + let error = await #expect(throws: (any Error).self) { + try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "test", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: DatabaseQueue()), + tables: [] + ) + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + InMemoryDatabase() + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func metadatabaseMismatch() async throws { + let error = await #expect(throws: SyncEngine.SchemaError.self) { + var configuration = Configuration() + configuration.prepareDatabase { db in + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") + } + let path = "/tmp/\(UUID()).sqlite" + let database = try DatabasePool( + path: path, + configuration: configuration + ) + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "iCloud.co.point-free", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + + #expect( + error?.debugDescription == """ + Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ + 'SyncEngine.init'. Are different CloudKit container identifiers being provided? + """ + ) + } + } + } + + private func databaseWithForeignKeys() throws -> any DatabaseWriter { + try DatabaseQueue() + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift new file mode 100644 index 00000000..5b24fe01 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift @@ -0,0 +1,375 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @Table("invalid:table") + struct InvalidTable { + let id: UUID + } + + @MainActor + struct SyncEngineValidationTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tableNameValidation() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [InvalidTable.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .invalidTableName("invalid:table"), + debugDescription: "Table name contains invalid character \':\'" + ) + """# + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyActionValidation_NoAction() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Child.self, Parent.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .noAction, + isNotNull: false + ) + ), + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyActionValidation_Restrict() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Parent.self, Child.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .restrict, + isNotNull: false + ) + ), + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + ) + """ + } + } + + @Table struct Child: Identifiable { + let id: Int + var parentID: Parent.ID + } + @Table struct Parent: Identifiable { + let id: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyPointsToOtherSynchronizedTable() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Child.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKey( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + isNotNull: false + ) + ), + debugDescription: #"Foreign key "childs"."parentID" references table "parents" that is not synchronized. Update 'SyncEngine.init' to synchronize "parents". "# + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func doNotValidateTriggersOnNonSyncedTables() async throws { + let database = try DatabaseQueue( + path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() + ) + try await database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "non_temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + try #sql( + """ + CREATE TEMPORARY TRIGGER "temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + } + let _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + + @Table struct ModelWithUniqueColumn { + let id: Int + let uniqueValue: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func uniquenessConstraint() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "modelWithUniqueColumns" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uniqueValue" INTEGER NOT NULL, + UNIQUE("uniqueValue") + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [ModelWithUniqueColumn.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: "Uniqueness constraints are not supported for synchronized tables." + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cycleValidation() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "recursiveTables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "recursiveTables"("id") + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [RecursiveTable.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .cycleDetected, + debugDescription: "Cycles are not currently permitted in schemas, e.g. a table that references itself." + ) + """ + } + } + } + } + + @Table struct RecursiveTable: Identifiable { + let id: Int + let parentID: RecursiveTable.ID? + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift new file mode 100644 index 00000000..d7ef5e19 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -0,0 +1,1363 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func triggers() async throws { + let triggersAfterSetUp = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) + } + #if DEBUG + assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { + #""" + [ + [0]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_sync_engine" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [1]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_user" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [2]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_sync_engine" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [3]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_user" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [4]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_sync_engine" + AFTER DELETE ON "modelAs" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [5]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_user" + AFTER DELETE ON "modelAs" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [6]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_sync_engine" + AFTER DELETE ON "modelBs" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_user" + AFTER DELETE ON "modelBs" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_sync_engine" + AFTER DELETE ON "modelCs" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [9]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_user" + AFTER DELETE ON "modelCs" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [10]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_sync_engine" + AFTER DELETE ON "parents" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_user" + AFTER DELETE ON "parents" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" + AFTER DELETE ON "reminderTags" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" + AFTER DELETE ON "reminderTags" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" + AFTER DELETE ON "remindersLists" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" + AFTER DELETE ON "remindersLists" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_sqlitedata_icloud_metadata" + AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN + SELECT "sqlitedata_icloud_didDelete"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + )), "new"."share"); + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" + AFTER DELETE ON "tags" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" + AFTER DELETE ON "tags" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), '__defaultOwner__'), "new"."parentID", 'parents' + ON CONFLICT DO NOTHING; + END + """, + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), '__defaultOwner__'), "new"."parentID", 'parents' + ON CONFLICT DO NOTHING; + END + """, + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" + AFTER INSERT ON "modelAs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [28]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" + AFTER INSERT ON "modelBs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), '__defaultOwner__'), "new"."modelAID", 'modelAs' + ON CONFLICT DO NOTHING; + END + """, + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" + AFTER INSERT ON "modelCs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), '__defaultOwner__'), "new"."modelBID", 'modelBs' + ON CONFLICT DO NOTHING; + END + """, + [30]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" + AFTER INSERT ON "reminderTags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + END + """, + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" + AFTER INSERT ON "remindersListAssets" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + END + """, + [34]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" + AFTER INSERT ON "remindersListPrivates" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + END + """, + [35]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [36]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_sqlitedata_icloud_metadata" + AFTER INSERT ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "new"."zoneName", "new"."ownerName", NULL); + END + """, + [37]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" + AFTER INSERT ON "tags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."title", 'tags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [38]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [39]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [40]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" + AFTER UPDATE OF "id" ON "modelAs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [41]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" + AFTER UPDATE OF "id" ON "modelBs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [42]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" + AFTER UPDATE OF "id" ON "modelCs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [43]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" + AFTER UPDATE OF "id" ON "parents" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [44]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" + AFTER UPDATE OF "id" ON "reminderTags" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [45]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" + AFTER UPDATE OF "id" ON "reminders" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [46]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" + AFTER UPDATE OF "id" ON "remindersListAssets" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [47]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" + AFTER UPDATE OF "id" ON "remindersListPrivates" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [48]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" + AFTER UPDATE OF "id" ON "remindersLists" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [49]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" + AFTER UPDATE OF "title" ON "tags" + FOR EACH ROW WHEN ("old"."title" <> "new"."title") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [50]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), '__defaultOwner__'), "new"."parentID", 'parents' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."parentID", "parentRecordType" = 'parents', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [51]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), '__defaultOwner__'), "new"."parentID", 'parents' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."parentID", "parentRecordType" = 'parents', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [52]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" + AFTER UPDATE ON "modelAs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [53]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" + AFTER UPDATE ON "modelBs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), '__defaultOwner__'), "new"."modelAID", 'modelAs' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."modelAID", "parentRecordType" = 'modelAs', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [54]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" + AFTER UPDATE ON "modelCs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), '__defaultOwner__'), "new"."modelBID", 'modelBs' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."modelBID", "parentRecordType" = 'modelBs', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [55]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [56]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" + AFTER UPDATE ON "reminderTags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [57]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."remindersListID", "parentRecordType" = 'remindersLists', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [58]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" + AFTER UPDATE ON "remindersListAssets" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."remindersListID", "parentRecordType" = 'remindersLists', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [59]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" + AFTER UPDATE ON "remindersListPrivates" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), 'zone'), coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce(coalesce("sqlitedata_icloud_currentZoneName"(), (SELECT "sqlitedata_icloud_metadata"."zoneName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce(coalesce("sqlitedata_icloud_currentOwnerName"(), (SELECT "sqlitedata_icloud_metadata"."ownerName" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')))), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = "new"."remindersListID", "parentRecordType" = 'remindersLists', "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [60]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [61]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_sqlitedata_icloud_metadata" + AFTER UPDATE ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "old"."zoneName", "old"."ownerName", CASE WHEN (("new"."zoneName" <> "old"."zoneName") OR ("new"."ownerName" <> "old"."ownerName")) THEN ( + WITH "descendantMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", NULL AS "parentRecordName" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName" + FROM "sqlitedata_icloud_metadata" + JOIN "descendantMetadatas" ON ("sqlitedata_icloud_metadata"."parentRecordName" = "descendantMetadatas"."recordName") + ) + SELECT json_group_array("descendantMetadatas"."recordName") + FROM "descendantMetadatas" + WHERE ("descendantMetadatas"."recordName" <> "new"."recordName") + ) END); + END + """, + [62]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" + AFTER UPDATE ON "tags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."title", 'tags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "new"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [63]: """ + CREATE TRIGGER "sqlitedata_icloud_after_zone_update_on_sqlitedata_icloud_metadata" + AFTER UPDATE OF "zoneName", "ownerName" ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN (("new"."zoneName" <> "old"."zoneName") OR ("new"."ownerName" <> "old"."ownerName")) BEGIN + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = "new"."zoneName", "ownerName" = "new"."ownerName", "lastKnownServerRecord" = NULL, "_lastKnownServerRecordAllFields" = NULL + WHERE ("sqlitedata_icloud_metadata"."recordName" IN (WITH "descendantMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", NULL AS "parentRecordName" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName" + FROM "sqlitedata_icloud_metadata" + JOIN "descendantMetadatas" ON ("sqlitedata_icloud_metadata"."parentRecordName" = "descendantMetadatas"."recordName") + ) + SELECT "descendantMetadatas"."recordName" + FROM "descendantMetadatas")); + END + """ + ] + """# + } + #endif + + try syncEngine.tearDownSyncEngine() + let triggersAfterTearDown = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { + """ + [] + """ + } + + try syncEngine.setUpSyncEngine() + try await syncEngine.start() + let triggersAfterReSetUp = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) + } + expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/UnattachedSyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UnattachedSyncEngineTests.swift new file mode 100644 index 00000000..1133fa7a --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/UnattachedSyncEngineTests.swift @@ -0,0 +1,25 @@ +import CloudKit +import CustomDump +import InlineSnapshotTesting +import SQLiteData +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class UnattachedSyncEngineTests: @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func start() async throws { + let database = try DatabasePool(path: "\(NSTemporaryDirectory())\(UUID())") + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "iCloud.co.pointfree.Testing", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + } +} diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift new file mode 100644 index 00000000..e5ad0bcd --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -0,0 +1,37 @@ +#if canImport(CloudKit) + import Foundation + import SQLiteData + import Testing + + @Suite struct UserlandTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + let database = try SQLiteDataTests.database( + containerIdentifier: "tests", + attachMetadatabase: false + ) + let syncEngine = try SyncEngine( + for: database, + tables: ModelA.self, + ModelB.self, + ModelC.self, + containerIdentifier: "tests" + ) + + try await withDependencies { + $0.defaultDatabase = database + $0.defaultSyncEngine = syncEngine + $0.currentTime.now = 1 + } operation: { + @FetchAll var modelAs: [ModelA] = [] + try await database.write { db in + try db.seed { + ModelA.Draft() + } + } + try await $modelAs.load() + #expect(modelAs == [ModelA(id: 1, isEven: true)]) + } + } + } +#endif diff --git a/Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift similarity index 91% rename from Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift rename to Tests/SQLiteDataTests/CustomFunctionTests.swift index 003e3370..3c9436e8 100644 --- a/Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,6 +1,5 @@ import Foundation -import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct CustomFunctionsTests { @@ -8,6 +7,7 @@ import Testing Date(timeIntervalSinceReferenceDate: 0) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() throws { var configuration = Configuration() configuration.prepareDatabase { db in diff --git a/Tests/SharingGRDBTests/FetchAllTests.swift b/Tests/SQLiteDataTests/FetchAllTests.swift similarity index 95% rename from Tests/SharingGRDBTests/FetchAllTests.swift rename to Tests/SQLiteDataTests/FetchAllTests.swift index 51569b75..0b787542 100644 --- a/Tests/SharingGRDBTests/FetchAllTests.swift +++ b/Tests/SQLiteDataTests/FetchAllTests.swift @@ -1,11 +1,6 @@ -import Combine -import Dependencies import DependenciesTestSupport import Foundation -import GRDB -import Sharing -import SharingGRDB -import StructuredQueries +import SQLiteData import Testing @Suite(.dependency(\.defaultDatabase, try .database())) diff --git a/Tests/SharingGRDBTests/FetchOneTests.swift b/Tests/SQLiteDataTests/FetchOneTests.swift similarity index 98% rename from Tests/SharingGRDBTests/FetchOneTests.swift rename to Tests/SQLiteDataTests/FetchOneTests.swift index 41cce897..a0a214b9 100644 --- a/Tests/SharingGRDBTests/FetchOneTests.swift +++ b/Tests/SQLiteDataTests/FetchOneTests.swift @@ -1,11 +1,6 @@ -import Combine -import Dependencies import DependenciesTestSupport import Foundation -import GRDB -import Sharing -import SharingGRDB -import StructuredQueries +import SQLiteData import Testing @Suite(.dependency(\.defaultDatabase, try .database())) struct FetchOneTests { diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SQLiteDataTests/FetchTests.swift similarity index 96% rename from Tests/SharingGRDBTests/FetchTests.swift rename to Tests/SQLiteDataTests/FetchTests.swift index 247728c4..7461e035 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SQLiteDataTests/FetchTests.swift @@ -1,9 +1,6 @@ -import Dependencies import DependenciesTestSupport -import GRDB -import Sharing -import SharingGRDB -import StructuredQueries +import Foundation +import SQLiteData import Testing @Suite(.dependency(\.defaultDatabase, try .database())) @@ -63,7 +60,7 @@ struct FetchTests { @Test func fetchOneOptional_SQL() async throws { @FetchOne(#sql("SELECT * FROM records LIMIT 1")) var record: Record? #expect(record == Record(id: 1)) - + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) @@ -74,6 +71,7 @@ struct FetchTests { private struct Record: Equatable { let id: Int } + extension DatabaseWriter where Self == DatabaseQueue { fileprivate static func database() throws -> DatabaseQueue { let database = try DatabaseQueue() diff --git a/Tests/SharingGRDBTests/IntegrationTests.swift b/Tests/SQLiteDataTests/IntegrationTests.swift similarity index 97% rename from Tests/SharingGRDBTests/IntegrationTests.swift rename to Tests/SQLiteDataTests/IntegrationTests.swift index 70787c89..8323c923 100644 --- a/Tests/SharingGRDBTests/IntegrationTests.swift +++ b/Tests/SQLiteDataTests/IntegrationTests.swift @@ -1,8 +1,6 @@ -import Dependencies import DependenciesTestSupport -import Sharing -import SharingGRDB -import StructuredQueries +import Foundation +import SQLiteData import Testing @Suite(.dependency(\.defaultDatabase, try .syncUps())) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift new file mode 100644 index 00000000..ba37e57b --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -0,0 +1,196 @@ +import CloudKit +import DependenciesTestSupport +import OrderedCollections +import SQLiteData +import SnapshotTesting +import Testing +import os + +@Suite( + .snapshots(record: .failed), + .dependencies { + $0.currentTime.now = 0 + $0.dataManager = InMemoryDataManager() + } +) +class BaseCloudKitTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable + + @Dependency(\.currentTime.now) var now + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase + ) + ) + try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: _AccountStatusScope.accountStatus, + containerIdentifier: testContainerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + _syncEngine = try await SyncEngine( + container: container, + userDatabase: self.userDatabase, + tables: [ + Reminder.self, + RemindersList.self, + RemindersListAsset.self, + Tag.self, + ReminderTag.self, + Parent.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ModelA.self, + ModelB.self, + ModelC.self, + ], + privateTables: [ + RemindersListPrivate.self + ], + startImmediately: _StartImmediatelyTrait.startImmediately + ) + if _StartImmediatelyTrait.startImmediately, + _AccountStatusScope.accountStatus == .available + { + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signOut() async { + container._accountStatus.withValue { $0 = .noAccount } + await syncEngine.handleEvent( + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), + syncEngine: syncEngine.shared + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func softSignOut() async { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signIn() async { + container._accountStatus.withValue { $0 = .available } + // NB: Emulates what CKSyncEngine does when signing in + syncEngine.private.state.removePendingChanges() + syncEngine.shared.state.removePendingChanges() + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.shared + ) + } + + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } + } else { + Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") + } + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine + } + var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine + } + static nonisolated let defaultTestZone = CKRecordZone( + zoneName: "zone" + ) + convenience init( + container: any CloudContainer, + userDatabase: UserDatabase, + tables: [any (PrimaryKeyedTable & _SendableMetatype).Type], + privateTables: [any (PrimaryKeyedTable & _SendableMetatype).Type] = [], + startImmediately: Bool = true + ) async throws { + try self.init( + container: container, + defaultZone: Self.defaultTestZone, + defaultSyncEngines: { _, syncEngine in + ( + MockSyncEngine( + database: container.privateCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ), + MockSyncEngine( + database: container.sharedCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ) + ) + }, + userDatabase: userDatabase, + logger: Logger(.disabled), + tables: tables, + privateTables: privateTables + ) + try setUpSyncEngine() + if startImmediately { + try await start() + } + } +} + +private let previousUserRecordID = CKRecord.ID( + recordName: "previousUser" +) +private let currentUserRecordID = CKRecord.ID( + recordName: "currentUser" +) diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift new file mode 100644 index 00000000..e3cc3e39 --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -0,0 +1,158 @@ +#if canImport(CloudKit) + import CustomDump + import CloudKit + import SQLiteData + + extension CKDatabase.Scope: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .public: + ".public" + case .private: + ".private" + case .shared: + ".shared" + @unknown default: + "@unknown" + } + } + } + + extension CKRecord { + @TaskLocal static var printTimestamps = false + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + let keys = encryptedValues.allKeys() + .filter { key in + CKRecord.printTimestamps + || !key.hasPrefix(CKRecord.userModificationTimeKey) + } + .sorted { lhs, rhs in + guard lhs != CKRecord.userModificationTimeKey + else { return false } + guard rhs != CKRecord.userModificationTimeKey + else { return true } + let lhsHasPrefix = lhs.hasPrefix(CKRecord.userModificationTimeKey) + let baseLHS = + lhsHasPrefix + ? String(lhs.dropFirst(CKRecord.userModificationTimeKey.count + 1)) + : lhs + let rhsHasPrefix = rhs.hasPrefix(CKRecord.userModificationTimeKey) + let baseRHS = + rhsHasPrefix + ? String(rhs.dropFirst(CKRecord.userModificationTimeKey.count + 1)) + : rhs + return (baseLHS, lhsHasPrefix ? 1 : 0) < (baseRHS, rhsHasPrefix ? 1 : 0) + } + let nonEncryptedKeys = Set(allKeys()) + .subtracting(encryptedValues.allKeys()) + .subtracting(["_recordChangeTag"]) + return Mirror( + self, + children: [ + ("recordID", recordID as Any), + ("recordType", recordType as Any), + ("parent", parent as Any), + ("share", share as Any), + ] + + keys + .map { + $0.hasPrefix(CKRecord.userModificationTimeKey) + ? ( + String($0.dropFirst(CKRecord.userModificationTimeKey.count + 1)) + "🗓️", + (self.encryptedValues[$0] as? Int64) as Any + ) + : ( + $0, + self.encryptedValues[$0] as Any + ) + } + + nonEncryptedKeys.map { + ( + $0, + self[$0] as Any + ) + }, + displayStyle: .struct + ) + } + } + + extension CKAsset: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + @Dependency(\.dataManager) var dataManager + return Mirror( + self, + children: [ + ( + "fileURL", + fileURL as Any + ), + ( + "dataString", + String(decoding: fileURL.flatMap { try? dataManager.load($0) } ?? Data(), as: UTF8.self) + ), + ], + displayStyle: .struct + ) + } + } + + extension CKRecord.Reference: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ("recordID", recordID as Any) + ], + displayStyle: .struct + ) + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("atomicByZone", atomicByZone as Any), + ( + "recordIDsToDelete", + recordIDsToDelete.sorted { lhs, rhs in + lhs.recordName < rhs.recordName + } as Any + ), + ( + "recordsToSave", + recordsToSave.sorted { lhs, rhs in + lhs.recordID.recordName < rhs.recordID.recordName + } as Any + ), + ], + displayStyle: .struct + ) + } + } + + extension CKRecord.ID: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + """ + CKRecord.ID(\ + \(recordName)/\ + \(zoneID.zoneName)/\ + \(zoneID.ownerName)\ + ) + """ + } + } + + extension CKRecordZone.ID: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + "CKRecordZone.ID(\(zoneName)/\(ownerName))" + } + } +#endif diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift new file mode 100644 index 00000000..0b924e71 --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -0,0 +1,192 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import OrderedCollections +import SQLiteData +import Testing + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + static func recordID( + for id: PrimaryKey.QueryOutput, + zoneID: CKRecordZone.ID? = nil + ) -> CKRecord.ID { + CKRecord.ID( + recordName: self.recordName(for: id), + zoneID: zoneID ?? SyncEngine.defaultTestZone.zoneID + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + struct ModifyRecordsCallback { + fileprivate let operation: @Sendable () async -> Void + func notify() async { + await operation() + } + } + + func modifyRecordZones( + scope: CKDatabase.Scope, + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( + saving: recordZonesToSave, + deleting: recordZoneIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.parentSyncEngine + .handleEvent( + .fetchedDatabaseChanges( + modifications: saveResults.values.compactMap { try? $0.get().zoneID }, + deletions: deleteResults.compactMap { zoneID, result in + ((try? result.get()) != nil) + ? (zoneID, .deleted) + : nil + } + ), + syncEngine: syncEngine + ) + } + } + + func modifyRecords( + scope: CKDatabase.Scope, + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + let recordsToDeleteByID = Dictionary( + grouping: syncEngine.database.storage.withValue { storage in + recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?[recordID] } + }, + by: \.recordID + ) + .compactMapValues(\.first) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.parentSyncEngine.handleEvent( + .fetchedRecordZoneChanges( + modifications: saveResults.values.compactMap { try? $0.get() }, + deletions: deleteResults.compactMap { recordID, result in + syncEngine.database.storage.withValue { storage in + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil + ? (recordID, recordType) + : nil + } + } + } + ), + syncEngine: syncEngine + ) + } + } + + func processPendingDatabaseChanges( + scope: CKDatabase.Scope, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + let syncEngine = syncEngine(for: scope) + guard !syncEngine.state.pendingDatabaseChanges.isEmpty + else { + Issue.record( + "Processing empty set of database changes.", + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + return + } + guard try await container.accountStatus() == .available + else { + Issue.record( + """ + User must be logged in to process pending changes. + """, + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + return + } + + var zonesToSave: [CKRecordZone] = [] + var zoneIDsToDelete: [CKRecordZone.ID] = [] + for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges { + switch pendingDatabaseChange { + case .saveZone(let zone): + zonesToSave.append(zone) + case .deleteZone(let zoneID): + zoneIDsToDelete.append(zoneID) + @unknown default: + fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)") + } + } + let results: + ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) = try syncEngine.database.modifyRecordZones( + saving: zonesToSave, + deleting: zoneIDsToDelete + ) + var savedZones: [CKRecordZone] = [] + var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] + var deletedZoneIDs: [CKRecordZone.ID] = [] + var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:] + for (zoneID, saveResult) in results.saveResults { + switch saveResult { + case .success(let zone): + savedZones.append(zone) + case .failure(let error as CKError): + failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error)) + case .failure(let error): + reportIssue("Error thrown not CKError: \(error)") + } + } + for (zoneID, deleteResult) in results.deleteResults { + switch deleteResult { + case .success: + deletedZoneIDs.append(zoneID) + case .failure(let error as CKError): + failedZoneDeletes[zoneID] = error + case .failure(let error): + reportIssue("Error thrown not CKError: \(error)") + } + } + + syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) + syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) + + await syncEngine.parentSyncEngine + .handleEvent( + .sentDatabaseChanges( + savedZones: savedZones, + failedZoneSaves: failedZoneSaves, + deletedZoneIDs: deletedZoneIDs, + failedZoneDeletes: failedZoneDeletes + ), + syncEngine: syncEngine + ) + } +} diff --git a/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift b/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift new file mode 100644 index 00000000..067815c6 --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift @@ -0,0 +1,28 @@ +#if canImport(CloudKit) + import CloudKit + import Testing + + struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { + let printTimestamps: Bool + init(_ printTimestamps: Bool = true) { + self.printTimestamps = printTimestamps + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await CKRecord.$printTimestamps.withValue(true) { + try await function() + } + } + } + + extension Trait where Self == _PrintTimestampsScope { + static var printTimestamps: Self { Self() } + static func printTimestamps(_ printTimestamps: Bool) -> Self { + Self(printTimestamps) + } + } +#endif diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift new file mode 100644 index 00000000..3d11f0a7 --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -0,0 +1,231 @@ +import Foundation +import SQLiteData + +// NB: The IDs in this schema are integers for ease of testing. You should _not_ use integer IDs +// in a production application. + +@Table struct Reminder: Equatable, Identifiable { + let id: Int + var dueDate: Date? + var isCompleted = false + var priority: Int? + var title = "" + var remindersListID: RemindersList.ID +} +@Table struct RemindersList: Equatable, Identifiable { + let id: Int + var title = "" +} +@Table struct RemindersListAsset: Equatable, Identifiable { + let id: Int + var coverImage: Data? + var remindersListID: RemindersList.ID +} +@Table struct RemindersListPrivate: Equatable, Identifiable { + let id: Int + var position = 0 + var remindersListID: RemindersList.ID +} +@Table struct Tag: Equatable, Identifiable { + @Column(primaryKey: true) + let title: String + var id: String { title } +} +@Table struct ReminderTag: Equatable, Identifiable { + let id: Int + var reminderID: Reminder.ID + var tagID: Tag.ID +} +@Table struct Parent: Equatable, Identifiable { + let id: Int +} +@Table struct ChildWithOnDeleteSetNull: Equatable, Identifiable { + let id: Int + let parentID: Parent.ID? +} +@Table struct ChildWithOnDeleteSetDefault: Equatable, Identifiable { + let id: Int + let parentID: Parent.ID +} +@Table struct LocalUser: Equatable, Identifiable { + let id: Int + var name = "" + var parentID: LocalUser.ID? +} +@Table struct ModelA: Equatable, Identifiable { + let id: Int + var count = 0 + @Column(generated: .virtual) + let isEven: Bool +} +@Table struct ModelB: Equatable, Identifiable { + let id: Int + var isOn = false + var modelAID: ModelA.ID +} +@Table struct ModelC: Equatable, Identifiable { + let id: Int + var title = "" + var modelBID: ModelB.ID +} +@Table struct UnsyncedModel: Equatable, Identifiable { + let id: Int +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +func database( + containerIdentifier: String, + attachMetadatabase: Bool +) throws -> DatabasePool { + var configuration = Configuration() + configuration.prepareDatabase { db in + if attachMetadatabase { + try db.attachMetadatabase(containerIdentifier: containerIdentifier) + } + // db.trace { + // print($0.expandedDescription) + // } + } + let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") + let database = try DatabasePool(path: url.path(), configuration: configuration) + try database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coverImage" BLOB NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersListPrivates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "remindersListID" INTEGER NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "tags" ( + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminderTags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "parents"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "localUsers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "modelAs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "modelBs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "modelCs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "unsyncedModels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) + """ + ) + .execute(db) + } + return database +} diff --git a/Tests/SQLiteDataTests/Internal/TestScopes.swift b/Tests/SQLiteDataTests/Internal/TestScopes.swift new file mode 100644 index 00000000..3608458b --- /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 { + Self(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 { + Self(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 { Self(attachMetadatabase: true) } + static func attachMetadatabase(_ attachMetadatabase: Bool) -> Self { + Self(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 { Self() } + static func accountStatus(_ accountStatus: CKAccountStatus) -> Self { + Self(accountStatus) + } + } +#endif diff --git a/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift new file mode 100644 index 00000000..710c6b07 --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift @@ -0,0 +1,25 @@ +import GRDB +import SQLiteData + +extension UserDatabase { + func userWrite( + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + try await write { db in + try $_isSynchronizingChanges.withValue(false) { + try updates(db) + } + } + } + + @_disfavoredOverload + func userWrite( + _ updates: (Database) throws -> T + ) throws -> T { + try write { db in + try $_isSynchronizingChanges.withValue(false) { + try updates(db) + } + } + } +} diff --git a/Tests/StructuredQueriesGRDBTests/MigrationTests.swift b/Tests/SQLiteDataTests/MigrationTests.swift similarity index 95% rename from Tests/StructuredQueriesGRDBTests/MigrationTests.swift rename to Tests/SQLiteDataTests/MigrationTests.swift index 51f1b116..42e5f24b 100644 --- a/Tests/StructuredQueriesGRDBTests/MigrationTests.swift +++ b/Tests/SQLiteDataTests/MigrationTests.swift @@ -1,6 +1,5 @@ import Foundation -import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct MigrationTests { diff --git a/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift b/Tests/SQLiteDataTests/QueryCursorTests.swift similarity index 94% rename from Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift rename to Tests/SQLiteDataTests/QueryCursorTests.swift index 9e07b462..1e608a34 100644 --- a/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift +++ b/Tests/SQLiteDataTests/QueryCursorTests.swift @@ -1,5 +1,4 @@ -import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct QueryCursorTests { diff --git a/Tests/SharingGRDBTests/SharingGRDBTests.swift b/Tests/SharingGRDBTests/SharingGRDBTests.swift deleted file mode 100644 index 59bc0487..00000000 --- a/Tests/SharingGRDBTests/SharingGRDBTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Dependencies -import DependenciesTestSupport -import GRDB -import Sharing -import SharingGRDB -import StructuredQueries -import SwiftUI -import Testing - -@Suite struct GRDBSharingTests { - @Test - func fetchOne() throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @FetchOne(#sql("SELECT 1")) var bool = false - #expect(bool) - #expect($bool.loadError == nil) - } - } - - @Test - func fetchOneOptional() async throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @SharedReader(.fetchOne(sql: "SELECT NULL")) var bool: Bool? - #expect(bool == nil) - } - } - - @Test func fetchSyntaxError() throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @FetchOne(#sql("SELEC 1")) var bool = false - #expect(bool == false) - #expect($bool.loadError is DatabaseError?) - let error = try #require($bool.loadError as? DatabaseError) - #expect(error.message == #"near "SELEC": syntax error"#) - } - } - - @Test func fetchWithTwoDatabaseConnections() async throws { - let name = #function - try await withDependencies { - $0.defaultDatabase = try .database(named: name) - } operation: { - @SharedReader(.fetchAll(sql: "SELECT * FROM records")) var records1: [Record] = [] - #expect(records1.map(\.id) == [1, 2, 3]) - - try await withDependencies { - $0.defaultDatabase = try .database(named: name) - } operation: { - @Dependency(\.defaultDatabase) var database2 - @SharedReader(.fetchAll(sql: "SELECT * FROM records")) var records2: [Record] = [] - #expect(records2.map(\.id) == [1, 2, 3]) - try await database2.write { db in - _ = try Record.deleteOne(db, key: 1) - } - try await $records2.load() - #expect(records1.map(\.id) == [1, 2, 3]) - #expect(records2.map(\.id) == [2, 3]) - } - - try await $records1.load() - #expect(records1.map(\.id) == [2, 3]) - } - } - - @Test(.dependency(\.defaultDatabase, try .database())) - func fetchIDHashValue() async throws { - let fetchKey1: some SharedReaderKey = .fetch(Fetch1()) - let fetchKey2: some SharedReaderKey = .fetch(Fetch2()) - #expect(fetchKey1.id.hashValue != fetchKey2.id.hashValue) - } - - @Test(.dependency(\.defaultDatabase, try .database())) - func fetchAnimationHashValue() async throws { - let fetchKey1: some SharedReaderKey = .fetch(Fetch1()) - let fetchKey2: some SharedReaderKey = .fetch(Fetch2(), animation: .default) - #expect(fetchKey1.id.hashValue != fetchKey2.id.hashValue) - } -} - -private struct Fetch1: FetchKeyRequest { - func fetch(_ db: Database) throws { - } -} -private struct Fetch2: FetchKeyRequest { - func fetch(_ db: Database) throws { - } -} - -private struct Record: Codable, Equatable, FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "records" - let id: Int -} -extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func database(named name: String? = nil) throws -> DatabaseQueue { - let database: DatabaseQueue - if let name { - database = try DatabaseQueue(named: name) - } else { - database = try DatabaseQueue() - } - var migrator = DatabaseMigrator() - migrator.registerMigration("Up") { db in - try #sql( - """ - CREATE TABLE "records" ("id" INTEGER PRIMARY KEY AUTOINCREMENT) - """ - ) - .execute(db) - for index in 1...3 { - _ = try Record(id: index).inserted(db) - } - } - try migrator.migrate(database) - return database - } -}