SQLiteData Documentation

repository·main·Indexed 23 days ago

https://github.com/pointfreeco/sqlite-data

A fast, lightweight replacement for SwiftData powered by SQLite and built on top of GRDB. It features the @Table macro for mapping structs to tables, @FetchAll and @FetchOne for reactive data observation in SwiftUI, and CloudKit synchronization via SyncEngine. The library supports advanced SQLite querying, database transactions, and integration with both SwiftUI and UIKit.

Tokens
19.8K
Snippets
59
Records
104
Agent score
78%

What's inside SQLiteData

  1. Understand which records can be shared via CloudKit

    main

    In SQLiteData, only root records can be directly shared. A root record is defined as a table that has no foreign keys.

    If you attempt to call SyncEngine/share(record:configure:) with a non-root record (a record that contains foreign keys pointing to other tables), the system will throw an error. This restriction exists because sharing a child record without sharing its parent would create logical inconsistencies in data ownership and synchronization.

    Examples:

    • Root Record: A RemindersList table with only an id and title.
    • Non-Root Record: A Reminder table that contains a remindersListID foreign key.

    To share data associated with a non-root record, you must share its parent root record instead.

    @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 // This makes Reminder a non-root record
    }
  2. How SQLiteData works: Core Abstractions

    main

    SQLiteData is built on two primary pillars:

    1. GRDB: Used for the low-level interaction with SQLite, managing connections, transactions, and database observation.
    2. StructuredQueries: A library used for building type-safe, expressive SQL queries and performing high-performance decoding of SQLite rows into Swift types.

    This combination allows SQLiteData to provide a SwiftData-like experience (automatic SwiftUI updates) while maintaining the performance and flexibility of direct SQL access.

  3. Manage dynamic query state locally with @State @FetchAll

    main

    When using @FetchAll, @FetchOne, or @Fetch directly inside a SwiftUI View, a parent view refresh can overwrite your dynamically updated query with the initial value provided by the parent.

    To prevent this and keep the dynamic query state local to the current view, use the combination of @State and the property wrapper: @State @FetchAll. To access the actual data stored within this local state, use the .wrappedValue property.

  4. Advanced querying with SQLite's group_concat in Reminders example

    main
    The Reminders example demonstrates how to perform advanced SQLite queries that are not possible using SwiftData. Specifically, it shows how to use the group_concat function to fetch entities (like reminders) along with a comma-separated list of related entities (like tags) in a single query. This approach allows for efficient data retrieval of many-to-many relationships without needing to perform multiple separate fetches or complex Swift-side mapping.
  5. Ensure every synchronized table has a single primary key

    main

    Every table participating in synchronization must have exactly one single, non-compound primary key. This rule applies even to join tables used for many-to-many relationships. While the primary key might not be required for your application's business logic, it is mandatory for the SyncEngine to facilitate CloudKit synchronization.

    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
  6. Use IdentifierStringConvertible for type-safe identifiers

    main

    The IdentifierStringConvertible protocol allows types to be converted into a string representation suitable for use as identifiers within SQLiteData. This is useful when you want to use custom types (like UUID or Tagged types) as primary keys or foreign keys that map to string columns in the database.

    By conforming to this protocol, you ensure that your domain models can be seamlessly translated to and from the string-based identifier format required by the underlying storage layer.

  7. How record conflicts are handled in CloudKit sync

    main

    Conflicts between record edits are handled automatically using a field-wise last edit wins strategy.

    When a column is edited, the library tracks the timestamp for that specific column. During a merge of two conflicting records, the library compares the timestamps for each column individually; the column with the most recent edit wins. This is not a CRDT-based synchronization, but a per-column timestamp-based merge.

  8. How foreign key relationships are shared

    main

    When you share a root record, associated records are automatically synchronized if they follow a specific relationship pattern.

    An associated record is shared only if it has exactly one foreign key pointing to the shared root record (either directly or through a chain of records that each have only one foreign key).

    Supported Relationship Types:

    1. One-to-many: The simplest and most common. If RemindersList is shared, and Reminder has exactly one foreign key pointing to RemindersList, the Reminder is shared. This extends recursively (e.g., ChildReminder pointing to Reminder).
    2. One-to-"at most one": Modeled by making the foreign key also the Primary Key of the child table. This ensures a single association (like a CoverImage for a RemindersList) is synchronized.

    Unsupported Relationship Types:

    1. Many-to-many: Tables using a join table (e.g., ReminderTag with both reminderID and tagID) cannot be shared. Because these join records have multiple foreign keys, they fail the requirement for synchronization. To support this, you must refactor the relationship into a one-to-many model where each child belongs to exactly one parent.
    // Example of a One-to-many relationship that WILL be shared
    @Table
    struct Tag: Identifiable {
      let id: UUID
      var title = ""
      var reminderID: Reminder.ID // Only one foreign key
    }
    
    // Example of a Many-to-many relationship that WILL NOT be shared
    @Table
    struct ReminderTag: Identifiable {
      let id: UUID
      var reminderID: Reminder.ID
      var tagID: Tag.ID // Two foreign keys prevent sharing
    }
  9. Avoid uniqueness constraints in synchronized tables

    main

    Tables cannot have UNIQUE constraints on columns other than the primary key. Distributed creation of records makes uniqueness constraints problematic; if two devices create a record with the same 'unique' value simultaneously, a conflict occurs that cannot be resolved by simply discarding one record.

    If you need a column to be unique, consider making that column the primary key of the table.

    @Table
    struct RemindersListAsset {
      @Column(primaryKey: true)
      let remindersListID: RemindersList.ID
      let image: Data
    }
  10. Use `isSynchronizing` to customize SQL triggers

    main

    When using SyncEngine, you may want certain database triggers to behave differently depending on whether the write originates from your app's code or from the synchronization process (e.g., CloudKit).

    To achieve this, use the SyncEngine.$isSynchronizing SQL expression. This expression returns true if the write originates from the sync engine.

    For example, if you want a trigger to run only when the app (not the sync engine) performs an action, use WHEN NOT (SyncEngine.$isSynchronizing) in your SQL definition or !SyncEngine.$isSynchronizing when using StructuredQueries.

    #sql(
      """
      CREATE TEMPORARY TRIGGER "…"
      AFTER DELETE ON "…"
      FOR EACH ROW WHEN NOT \(SyncEngine.$isSynchronizing)
      BEGIN
        …
      END
      """
    )
  11. Manage large binary data using BLOBs and CKAssets

    main

    The library automatically converts all BLOB columns in a table into CKAssets for seamless CloudKit synchronization.

    Best Practice: To avoid performance issues (such as loading large amounts of data into memory during frequent queries or slowing down SQLite row access), store large binary blobs in a separate, related table rather than the main data table. Use a one-to-one relationship where the primary key of the blob table is a foreign key to the main table.

    @Table
    struct RemindersList: Identifiable {
      let id: UUID
      var title = ""
    }
    
    @Table
    struct RemindersListCoverImage {
      @Column(primaryKey: true)
      let remindersListID: RemindersList.ID
      var image: Data
    }
  12. Understand FetchSubscription lifecycle management

    main

    Starting with version 1.4, the load method for @FetchAll, @FetchOne, and @Fetch returns a FetchSubscription.

    This subscription ties the database subscription lifecycle to the surrounding async context. By awaiting the FetchSubscription (or its .task property), you ensure that the database subscription is automatically managed alongside the lifecycle of the async task, which allows views to automatically unsubscribe from the database when they are no longer visible.