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
1 change: 0 additions & 1 deletion Examples/SyncUpTests/SyncUpFormTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Dependencies
import DependenciesTestSupport
import Foundation
import GRDB
import StructuredQueries
import Testing

Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#if canImport(CloudKit)
import CloudKit
import Dependencies
import GRDB

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension DependencyValues {
Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import ConcurrencyExtras
import CustomDump
import Dependencies
import GRDB
import OrderedCollections
import OSLog
import Observation
Expand Down
186 changes: 186 additions & 0 deletions Sources/SQLiteData/Documentation.docc/Articles/AddingToGRDB.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# Adding to an existing GRDB application

Learn how to add SQLiteData to an existing app that uses GRDB.

## Overview

[GRDB] is a powerful SQLite library for Swift applications, and it is what is used by SQLiteData
to interact with SQLite under the hood, such as performing queries and observing changes to the
database. If you have an existing application using GRDB, and would like to use the tools of this
library, such as [`@FetchAll`](<doc:FetchAll>), the SQL query builder, and
[CloudKit synchronization](<doc:CloudKit>), then there are a few steps you must take.

## Replace PersistableRecord and FetchableRecord with @Table

The `PersistableRecord` and `FetchableRecord` protocols in GRDB facilitate saving data to the
database and querying for data in the database. In SQLiteData, the `@Table` macro is responsible
for this functionality.

```diff
-struct Reminder: MutablePersistableRecord, Encodable {
+@Table("reminder")
+struct Reminder {
}
```

> Note: The `"reminder"` argument is provided to `@Table` due to a naming convention difference
> between SQLiteData and GRDB. More details below.

> Tip: For an incremental migration you can use all 3 of `PersistableRecord`, `FetchableRecord`
> _and_ `@Table`. That will allow you to use the query building tools from both GRDB and SQLiteData
> as you transition.

Once that is done you will be able to make use of the type-safe and schema-safe query building
tools of this library:

```swift
RemindersList
.group(by: \.id)
.leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) }
.select {
($0.title, $1.count())
}
}
```

And you can use the various property wrappers for fetching data from the database in your views
and observable models:

```swift
@Observable
class RemindersModel {
@ObservationIgnored
@FetchAll(Reminder.order(by: \.isCompleted)) var reminders
}
```

> Note: Due to the fact that macros and property wrappers do not play nicely together, we are forced
> to use `@ObservationIgnored`. However, [`@FetchAll`](<doc:FetchAll>) handles all of its own
> observation internally and so this does not affect observation.

There are 3 main things to be aware of when applying `@Table` to an existing schema:

* The `@Table` macro infers the name of the SQL table from the name of the type by lowercasing the
first letter and attempting to pluralize the type. This differs from GRDB's naming conventions,
which only lowercases the first letter of the type name. So, you will need to override `@Table`'s
default behavior by providing a string argument to the macro:

```swift
@Table("reminder")
struct Reminder {
// ...
}
@Table("remindersList")
struct RemindersList {
// ...
}
```

* If the column names of your SQLite table do not match the name of the fields in your Swift type,
then you can provide custom names _via_ the `@Column` macro:

```swift
@Table
struct Reminder {
let id: UUID
var title = ""
@Column("is_completed")
var isCompleted = false
}
```

* If your tables use UUID then you will need to add an extra decoration to your Swift data type
to make it compatible with SQLiteData. This is due to the fact that by default GRDB encodes UUIDs
as bytes whereas SQLiteData encodes UUIDs as text. To keep this compatibility you will need to use
`@Column(as:)` on any fields holding UUIDs:

```swift
@Table
struct Reminder {
@Column(as: UUID.BytesRepresentation.self)
let id: UUID
// ...
}
```

And if your table has an optional UUID, then you will handle that similarly:

```swift
@Table
struct ChildReminder {
@Column(as: UUID?.BytesRepresentation.self)
let parentID: UUID?
// ...
}
```

## Non-optional primary keys

Some of your data types may have an optional primary key and a `didInsert` callback for setting the
ID after insert:

```swift
struct Reminder: MutablePersistableRecord, Encodable {
var id: Int?
var title = ""
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
}
```

These can be updated to use non-optional types for the primary key, and the field can be bound as
an immutable `let`:

```swift
@Table
struct Reminder {
let id: Int
var title = ""
}
```

The `@Table` macro automatically generates a `Draft` type that can be used when you want to be
able to construct a value without the ID specified:

```swift
let draft = Reminder.Draft(title: "Get milk")
```

Then when this draft value is inserted its ID will be determined by the database:

```swift
try Reminder.insert {
Reminder.Draft(title: "Get milk")
}
.execute(db)
```

You can even use a `RETURNING` clause to grab the ID of the freshly inserted record:

```swift
try Reminder.insert {
Reminder.Draft(title: "Get milk")
}
.returning(\.id)
.fetchOne(db)
```

## CloudKit synchronization

The library's [CloudKit](<doc:CloudKit>) synchronization tools require that the tables being
synchronized have a primary key, and this is enforced through the `PrimaryKeyedTable` protocol.
The `@Table` macro automatically applies this protocol for you when your type has an `id` field,
but if you use a different name for your primary key you will need to use the `@Column` macro
to specify that:

```swift
@Table struct Reminder {
@Column(primaryKey: true)
let identifier: String
}
```

[GRDB]: http://github.com/groue/GRDB.swift
64 changes: 42 additions & 22 deletions Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ 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.
> TL;DR: Primary keys must be globally unique identifiers, such as UUID, and cannot be an
> autoincrementing integer. Further, a `NOT NULL` constraint must be specified with an
> `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
Expand Down Expand Up @@ -187,6 +188,18 @@ CREATE TABLE "reminders" (
Registering custom database functions for ID generation also makes it possible to generate
deterministic IDs for tests, making it easier to test your queries.

> 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

#### Primary keys on every table

> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in
Expand All @@ -213,6 +226,25 @@ facilitate synchronizing to CloudKit.
> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to
> cascade deletions.

Foreign keys are a SQL feature that allow one to express relationships between tables. This library
uses that information to correctly implement synchronization behavior, such as knowing what order
to syncrhonize records (parent first, then children), and knowing what associated records to
share when sharing a root record.

To express a foreign key relationship between tables you use the `REFERENCES` clause in the table's
schema, along with optional `ON DELETE` and `ON UPDATE` qualifiers:

```sql
CREATE TABLE "reminders"(
"id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
"title" TEXT NOT NULL DEFAULT '',
"remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE
) STRICT
```

> Tip: See SQLite's documentation on [foreign keys] for more information.
[foreign keys]: https://sqlite.org/foreignkeys.html

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,
Expand Down Expand Up @@ -241,31 +273,19 @@ when a ``SyncEngine`` is first created. If a uniqueness constraint is detected a
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:
your table. For example, if you wanted to associate a `RemindersListAsset` type to a
`RemindersList` type, you can make the primary key of the former also act as the foreign key:

```swift
@Table struct Tag {
let title: String
@Table
struct RemindersListAsset {
@Column(primaryKey: true)
let remindersListID: RemindersList.ID
let image: Data
}
// 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
This will make it so that at least one asset can be associated with a reminders list.

## Backwards compatible migrations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ so like this:
struct RemindersListView: View {
let remindersList: RemindersList
@State var sharedRecord: SharedRecord?
@Dependency(\.defaultSyncEngine) syncEngine

var body: some View {
Form {
Expand Down
1 change: 1 addition & 0 deletions Sources/SQLiteData/Documentation.docc/SQLiteData.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ with SQLite to take full advantage of GRDB and SQLiteData.
- <doc:Observing>
- <doc:PreparingDatabase>
- <doc:DynamicQueries>
- <doc:AddingToGRDB>
- <doc:ComparisonWithSwiftData>

### Database configuration and access
Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#if canImport(SwiftUI)
import GRDB
import Sharing
import SwiftUI

Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteData/Internal/UserDatabase.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Dependencies
import GRDB

package struct UserDatabase {
package let database: any DatabaseWriter
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import GRDB
import GRDBSQLite

extension Database {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Dependencies
import Foundation
import GRDB

/// Prepares a context-sensitive database writer.
///
Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Dependencies
import GRDB
import StructuredQueriesCore

extension Database {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import GRDB
import GRDBSQLite
import StructuredQueriesCore

extension StructuredQueriesCore.Statement {
Expand Down
1 change: 0 additions & 1 deletion Sources/SQLiteDataTestSupport/AssertQuery.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import CustomDump
import Dependencies
import Foundation
import GRDB
import InlineSnapshotTesting
import SQLiteData
import StructuredQueriesCore
Expand Down
Loading