diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index a74e54b5..4a945c38 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -185,34 +185,30 @@ let availablePermissions: UICloudSharingController.PermissionOptions let didFinish: (Result) -> Void let didStopSharing: () -> Void - public init( - sharedRecord: SharedRecord, - availablePermissions: UICloudSharingController.PermissionOptions = [] - ) { - self.init( - sharedRecord: sharedRecord, - availablePermissions: availablePermissions, - didFinish: { _ in }, - didStopSharing: {} - ) - } + let syncEngine: SyncEngine public init( sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = [], - didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> Void + 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 + didStopSharing: didStopSharing, + syncEngine: syncEngine ) } @@ -238,14 +234,17 @@ let share: CKShare let didFinish: (Result) -> Void let didStopSharing: () -> Void + let syncEngine: SyncEngine init( share: CKShare, didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> 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? { @@ -261,7 +260,6 @@ } public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - @Dependency(\.defaultSyncEngine) var syncEngine withErrorReporting(.sqliteDataCloudKitFailure) { try syncEngine.deleteShare(recordID: share.recordID) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 296ccabf..f78e26dc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -158,21 +158,17 @@ tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { - let allTables = try userDatabase.read { db in - try SQLQueryExpression( - """ - SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' - """, - as: String.self - ) - .fetchAll(db) - } + 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 ( - table, - try ForeignKey.all(table).fetchAll(db) + table.tableName, + try ForeignKey.all(table.tableName).fetchAll(db) ) } } @@ -189,16 +185,11 @@ containerIdentifier: container.containerIdentifier ) ) - let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) - .map(\.type) - self.tables = tables - self.privateTables = privateTables - self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = foreignKeysByTableName tablesByOrder = try SharingGRDBCore.tablesByOrder( userDatabase: userDatabase, - tables: tables, + tables: allTables, tablesByName: tablesByName ) try validateSchema() @@ -1755,10 +1746,14 @@ /// } /// ``` /// + /// 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. + /// - 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 @@ -1823,6 +1818,7 @@ case noCloudKitContainer case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) case unknown + case uniquenessConstraint } let reason: Reason let debugDescription: String @@ -1873,60 +1869,28 @@ } for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } + let columnsWithUniqueConstraints = + try SQLQueryExpression( + """ + SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + WHERE "unique" = 1 AND "origin" <> 'pk' + """, + as: String.self + ) + .fetchAll(db) + if !columnsWithUniqueConstraints.isEmpty { + throw SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: """ + Uniqueness constraints are not supported for synchronized tables. + """ + ) + } } } } } - // TODO: Private, opaque error - // public struct UniqueConstraintDisallowed: Error { - // let localizedDescription: String - // init(table: any PrimaryKeyedTable.Type, columns: [String]) { - // localizedDescription = """ - // Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ - // constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - // """ - // } - // } - - // TODO: Private, opaque error - // public struct NonNullColumnMustHaveDefault: Error { - // let localizedDescription: String - // init(table: any PrimaryKeyedTable.Type, columns: [String]) { - // localizedDescription = """ - // Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ - // default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - // """ - // } - // } - private struct HashablePrimaryKeyedTableType: Hashable { let type: any PrimaryKeyedTable.Type init(_ type: any PrimaryKeyedTable.Type) { diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 98d0c80e..986fadb9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -183,6 +183,9 @@ CREATE TABLE "reminders" ( ) ``` +> Tip: If you want the database to generate random UUID's in a deterministic fashion for tests +> you can register a custom database function to be used. + #### Primary keys on every table > TL;DR: Each synchronized table must have a single, non-compound primary key to aid in @@ -204,24 +207,6 @@ CREATE TABLE "reminderTags" ( Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. - +when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be +thrown. #### Foreign key relationships @@ -289,7 +273,7 @@ has been added to the schema, it will populate the table with the cached records #### Adding columns -> TL;DR: When adding columns to a table that has already been deployed to user's devices, you will +> 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 it can be `NOT NULL` but a default value must be provided with an `ON CONFLICT REPLACE` clause. @@ -491,7 +475,8 @@ 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`. +``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. @@ -506,6 +491,7 @@ let lastKnownServerRecord = try database.read { db in .metadata(for: remindersListID) .select(\.lastKnownServerRecord) .fetchOne(db) + ?? nil } guard let lastKnownServerRecord else { return } @@ -544,10 +530,32 @@ let ckRecord = try await container.sharedCloudDatabase 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.recordName.eq($1.recordName) } + .select { + Row.Columns( + remindersList: $0, + isShared: $1.isShared ?? false + ) + } +) +var rows +``` + +Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/recordName`` helper that +is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. ## How SharingGRDB handles distributed schema scenarios @@ -555,6 +563,62 @@ TODO: finish ## 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 diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index c9b68901..19d8efd1 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -15,7 +15,17 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic -TODO: ToC + - [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 @@ -353,7 +363,51 @@ it is also the primary key of the table it enforces that at most one cover image ## Sharing permissions -TODO: finish +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 +associationed 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 diff --git a/Sources/SharingGRDBCore/Internal/Exports.swift b/Sources/SharingGRDBCore/Internal/Exports.swift index 591037e3..df2a2de6 100644 --- a/Sources/SharingGRDBCore/Internal/Exports.swift +++ b/Sources/SharingGRDBCore/Internal/Exports.swift @@ -2,11 +2,12 @@ @_exported import Sharing @_exported import StructuredQueriesGRDBCore +@_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 -@_exported import struct GRDB.Configuration -@_exported import struct GRDB.DatabaseMigrator diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 2c741ae0..528ec81f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -49,9 +49,7 @@ extension BaseCloudKitTests { @Test func foreignKeyActionValidation_NoAction() async throws { let error = try #require( await #expect(throws: (any Error).self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ @@ -63,7 +61,7 @@ extension BaseCloudKitTests { .execute(db) try #sql( """ - CREATE TABLE "children" ( + CREATE TABLE "childs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION ) STRICT @@ -78,7 +76,7 @@ extension BaseCloudKitTests { sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), userDatabase: UserDatabase(database: database), - tables: [] + tables: [Child.self, Parent.self] ) } ) @@ -100,7 +98,7 @@ extension BaseCloudKitTests { notnull: false ) ), - debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) """ } @@ -110,9 +108,7 @@ extension BaseCloudKitTests { @Test func foreignKeyActionValidation_Restrict() async throws { let error = try #require( await #expect(throws: (any Error).self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ @@ -124,7 +120,7 @@ extension BaseCloudKitTests { .execute(db) try #sql( """ - CREATE TABLE "children" ( + CREATE TABLE "childs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT ) STRICT @@ -139,7 +135,7 @@ extension BaseCloudKitTests { sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), userDatabase: UserDatabase(database: database), - tables: [] + tables: [Parent.self, Child.self] ) } ) @@ -161,7 +157,7 @@ extension BaseCloudKitTests { notnull: false ) ), - debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) """ } @@ -279,5 +275,52 @@ extension BaseCloudKitTests { 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." + ) + """ + } + } } }