Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,34 +185,30 @@
let availablePermissions: UICloudSharingController.PermissionOptions
let didFinish: (Result<Void, Error>) -> 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, Error>) -> Void,
didStopSharing: @escaping () -> Void
didFinish: @escaping (Result<Void, Error>) -> 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
)
}

Expand All @@ -238,14 +234,17 @@
let share: CKShare
let didFinish: (Result<Void, Error>) -> Void
let didStopSharing: () -> Void
let syncEngine: SyncEngine
init(
share: CKShare,
didFinish: @escaping (Result<Void, Error>) -> 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? {
Expand All @@ -261,7 +260,6 @@
}

public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) {
@Dependency(\.defaultSyncEngine) var syncEngine
withErrorReporting(.sqliteDataCloudKitFailure) {
try syncEngine.deleteShare(recordID: share.recordID)
}
Expand Down
100 changes: 32 additions & 68 deletions Sources/SharingGRDBCore/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
Expand All @@ -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()
Expand Down Expand Up @@ -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 <doc:PreparingDatabase> 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
Expand Down Expand Up @@ -1823,6 +1818,7 @@
case noCloudKitContainer
case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String])
case unknown
case uniquenessConstraint
}
let reason: Reason
let debugDescription: String
Expand Down Expand Up @@ -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) {
Expand Down
118 changes: 91 additions & 27 deletions Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

<!--
TODO: think more about this

#### Default values for columns
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do allow for non-default columns, but only for the first version of a table. migrations must be nullable or specify a default, and we have that all documented below.


> TL;DR: All columns must have a default in order to allow for multiple devices to run your
> app with different versions of the schema.

Your tables' schemas should be defined to provide a default for every non-null column. To see why
this is necessary, consider if device A is running with a schema in which `Reminder` has an
`isFlagged` column and device B is running with a schema that does not. When device B creates a
record without the `isFlagged` value, and that record is synchronized to device A, it will fail to
insert into the database because there is not value for `isFlagged`.

For this reason all columns in your schema must have a default value, and this will be validated
when a ``SyncEngine`` is first created. If a non-null column without a default is detected,
a ``NonNullColumnMustHaveDefault`` error will be thrown.

#### Unique constraints

> TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow
Expand All @@ -235,9 +220,8 @@ they will have a conflict on the uniqueness constraint, but it would not be corr
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 a
``UniqueConstraintDisallowed`` error will be thrown.
-->
when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be
thrown.

#### Foreign key relationships

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
<doc:CloudKit#Setting-up-a-SyncEngine> 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.
Expand All @@ -506,6 +491,7 @@ let lastKnownServerRecord = try database.read { db in
.metadata(for: remindersListID)
.select(\.lastKnownServerRecord)
.fetchOne(db)
?? nil
}
guard let lastKnownServerRecord
else { return }
Expand Down Expand Up @@ -544,17 +530,95 @@ 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.

<!--
TODO: finish
* show example of joining tables to SyncMetadata
-->
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

<!-- 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

<!-- todo: finish -->
Expand Down
Loading
Loading