mcpField
A
axiom-sqlitedata

axiom-sqlitedata

github.com/charleswiltgenデータ・データベース1.2k203 回インストール今日更新
リポジトリ

axiom-sqlitedata について

axiom-sqlitedataは、SwiftアプリでSQLiteDataを使った型安全なSQLite永続化を行うためのスキルです。値型とコンパイラ検査付きクエリを使う開発者向けに、モデル、クエリ、監視、変更のパターンを解説します。

主な機能

  1. 01型安全なコンパイラ検査付きクエリ
  2. 02@Tableによる値型モデル定義
  3. 03SwiftUIでのデータベース変更監視
  4. 04挿入・更新・削除・upsertのパターン
  5. 05CloudKit同期のガイダンス

活用シーン

  1. 01アプリの永続化データモデル作成
  2. 02SwiftUIでのデータベース変更監視
  3. 03絞り込み・並べ替えクエリの作成
  4. 04CloudKitを使ったレコード同期

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-sqlitedata

インストール後、Claude Code で/axiom-sqlitedataを入力して読み込まれたか確認します。

詳細

SQLiteData

概要

Point-Free による SQLiteData(pointfreeco/sqlite-data)を使用した、型安全な SQLite 永続化です。GRDB(groue/GRDB.swift)と StructuredQueries(pointfreeco/swift-structured-queries)を基盤とする、CloudKit 同期に対応した高速かつ軽量な SwiftData の代替です。

基本原則: 値型(struct)+@Table マクロ+すべての変更に対する database.write { } ブロックです。

高度なパターン(CTE、ビュー、カスタム集計、スキーマ構成)については、axiom-sqlitedata-ref リファレンススキルを参照してください。

要件: iOS 17 以降、Swift 6 の厳格な並行処理 ライセンス: MIT

SQLiteData を使用する場面

次の要件がある場合は SQLiteData を選択してください:

  • コンパイラによるクエリチェックに対応した型安全な SQLite
  • レコード共有に対応した CloudKit 同期
  • SQLite の生の性能に近いパフォーマンスを備えた大規模データセット(5 万件以上)
  • クラスではなく値型(構造体)
  • Swift 6 の厳格な並行処理への対応

次の場合は SwiftData を使用してください:

  • Apple のネイティブ機能と連携するシンプルな CRUD
  • 構造体よりも @Model クラスを使いたい
  • CloudKit のレコード共有が不要

次の場合は生の GRDB を使用してください:

  • 4 つ以上のテーブルにまたがる複雑な SQL 結合
  • スキーマ変更の範囲を超えるカスタムマイグレーションロジック
  • 手動の SQL が必要な、パフォーマンスが重要な処理

クイックリファレンス

// MODEL
@Table nonisolated struct Item: Identifiable {
    let id: UUID                    // First let = auto primary key
    var title = ""                  // Default = non-nullable
    var notes: String?              // Optional = nullable
    @Column(as: Color.Hex.self)
    var color: Color = .blue        // Custom representation
    @Ephemeral var isSelected = false  // Not persisted
}

// SETUP
prepareDependencies { $0.defaultDatabase = try! appDatabase() }
@Dependency(\.defaultDatabase) var database

// FETCH
@FetchAll var items: [Item]
@FetchAll(Item.order(by: \.title).where(\.isInStock)) var items
@FetchOne(Item.count()) var count = 0

// FETCH (static helpers - v1.4.0+)
try Item.fetchAll(db)              // vs Item.all.fetchAll(db)
try Item.find(db, key: id)         // returns non-optional Item

// INSERT
try database.write { db in
    try Item.insert { Item.Draft(title: "New") }.execute(db)
}

// UPDATE (single)
try database.write { db in
    try Item.find(id).update { $0.title = #bind("Updated") }.execute(db)
}

// UPDATE (bulk)
try database.write { db in
    try Item.where(\.isInStock).update { $0.notes = #bind("") }.execute(db)
}

// DELETE
try database.write { db in
    try Item.find(id).delete().execute(db)
    try Item.where { $0.id.in(ids) }.delete().execute(db)  // bulk
}

// QUERY
Item.where(\.isActive)                     // Keypath (simple)
Item.where { $0.title.contains("phone") }  // Closure (complex)
Item.where { $0.status.eq(#bind(.done)) }  // Enum comparison
Item.order(by: \.title)                    // Sort
Item.order { $0.createdAt.desc() }         // Sort descending
Item.limit(10).offset(20)                  // Pagination

// RAW SQL (#sql macro)
#sql("SELECT * FROM items WHERE price > 100")  // Type-safe raw SQL
#sql("coalesce(date(\(dueDate)) = date(\(now)), 0)")  // Custom expressions

// CLOUDKIT (v1.2-1.4+)
prepareDependencies {
    $0.defaultSyncEngine = try SyncEngine(
        for: $0.defaultDatabase,
        tables: Item.self
    )
}
@Dependency(\.defaultSyncEngine) var syncEngine

// Manual sync control (v1.3.0+)
try await syncEngine.fetchChanges()  // Pull from CloudKit
try await syncEngine.sendChanges()   // Push to CloudKit
try await syncEngine.syncChanges()   // Bidirectional

// Sync state observation (v1.2.0+)
syncEngine.isSendingChanges    // true during upload
syncEngine.isFetchingChanges   // true during download
syncEngine.isSynchronizing     // either sending or fetching

アンチパターン(よくある間違い)

❌ 述語で == を使用する
// WRONG — removed in StructuredQueries 0.31+ (compiler error)
.where { $0.status == .completed }

// CORRECT — use comparison methods
.where { $0.status.eq(#bind(.completed)) }
❌ 更新代入で #bind が抜けている(StructuredQueries 0.31 以降)
// WRONG — compiler error in StructuredQueries 0.31+
Item.find(id).update { $0.title = "New" }.execute(db)

// CORRECT — wrap literal values with #bind
Item.find(id).update { $0.title = #bind("New") }.execute(db)

// NOTE: Compound operators (+=, -=) don't need #bind — they auto-bind
Item.find(id).update { $0.title += "!" }.execute(db)  // OK
❌ 更新順序が間違っている
// WRONG — .update before .where
Item.update { $0.title = #bind("X") }.where { $0.id.eq(#bind(id)) }

// CORRECT — .find() for single, .where() before .update() for bulk
Item.find(id).update { $0.title = #bind("X") }.execute(db)
Item.where(\.isOld).update { $0.archived = #bind(true) }.execute(db)
❌ インスタンスメソッドを使って挿入する
// WRONG — no instance insert method
let item = Item(id: UUID(), title: "Test")
try item.insert(db)

// CORRECT — static insert with .Draft
try Item.insert { Item.Draft(title: "Test") }.execute(db)
❌ nonisolated が抜けている
// WRONG — Swift 6 concurrency warning
@Table struct Item { ... }

// CORRECT
@Table nonisolated struct Item { ... }
❌ write ブロック内で await する
// WRONG — write block is synchronous
try await database.write { db in ... }

// CORRECT — no await inside the block
try database.write { db in
    try Item.insert { ... }.execute(db)
}
❌ .execute(db) を忘れている
// WRONG — builds query but doesn't run it
try database.write { db in
    Item.insert { Item.Draft(title: "X") }  // Does nothing!
}

// CORRECT
try database.write { db in
    try Item.insert { Item.Draft(title: "X") }.execute(db)
}

@Table モデル定義

基本的なテーブル
import SQLiteData

@Table
nonisolated struct Item: Identifiable {
    let id: UUID           // First `let` = auto primary key
    var title = ""
    var isInStock = true
    var notes = ""
}

主なパターン:

  • class ではなく struct を使用します(値型)
  • Swift 6 の並行処理に対応するため nonisolated を追加します
  • 最初の let プロパティは自動的に主キーになります
  • NULL 非許容の列にはデフォルト値(= ""、= true)を使用します
  • オプショナルプロパティ(String?)は NULL 許容の SQL 列に対応します
カスタム主キー
@Table
nonisolated struct Tag: Hashable, Identifiable {
    @Column(primaryKey: true)
    var title: String      // Custom primary key
    var id: String { title }
}
列のカスタマイズ
@Table
nonisolated struct RemindersList: Hashable, Identifiable {
    let id: UUID

    @Column(as: Color.HexRepresentation.self)  // Custom type representation
    var color: Color = .blue

    var position = 0
    var title = ""
}
外部キー
@Table
nonisolated struct Reminder: Hashable, Identifiable {
    let id: UUID
    var title = ""
    var remindersListID: RemindersList.ID  // Foreign key (explicit column)
}

@Table
nonisolated struct Attendee: Hashable, Identifiable {
    let id: UUID
    var name = ""
    var syncUpID: SyncUp.ID  // References parent
}

注: SQLiteData では、明示的な外部キー列を使用します。リレーションシップは @Relationship マクロではなく、結合で表現します。

関連テーブルのクエリ(結合)

すべてのレコードを取得して Swift で絞り込まないでください — 絞り込み処理はデータベースに任せます:

// ❌ Anti-pattern: Fetch all, filter in Swift
let allReminders = try database.read { try Reminder.all.fetch($0) }
let filtered = allReminders.filter { $0.remindersListID == listID }

// ✅ Filter at database level
let filtered = try database.read {
    try Reminder.all
        .filter { $0.remindersListID.eq(#bind(listID)) }
        .fetch($0)
}

// ✅ Join across tables with filtering
let remindersWithList = try database.read {
    try Reminder.all
        .join(RemindersList.all) { $0.remindersListID.eq($1.id) }
        .filter { $1.name.eq(#bind("Shopping")) }
        .fetch($0)
}

// ✅ Left join (include reminders even if no list)
let allWithOptionalList = try database.read {
    try Reminder.all
        .leftJoin(RemindersList.all) { $0.remindersListID.eq($1.id) }
        .fetch($0)
}

4 つ以上のテーブルにまたがる複雑な結合には、生の GRDB を使用してください(axiom-grdb を参照)。

@Ephemeral — 永続化されないプロパティ

Swift には存在するものの、データベースには保存されないプロパティに指定します:

@Table
nonisolated struct Item: Identifiable {
    let id: UUID
    var title = ""
    var price: Decimal = 0

    @Ephemeral
    var isSelected = false  // Not stored in database

    @Ephemeral
    var formattedPrice: String {  // Computed, not stored
        "$\(price)"
    }
}

用途:

  • UI の状態(選択、展開、ホバー)
  • 保存された列から導出される計算プロパティ
  • ビジネスロジック用の一時的なフラグ
  • スキーマにまだないプロパティのデフォルト値

重要: @Ephemeral プロパティはデータベースから設定されないため、デフォルト値を指定する必要があります。


データベースのセットアップ

データベースの作成
import Dependencies
import SQLiteData
import GRDB

func appDatabase() throws -> any DatabaseWriter {
    var configuration = Configuration()
    configuration.prepareDatabase { db in
        // Configure database behavior
        db.trace { print("SQL: \($0)") }  // Optional SQL logging
    }

    let database = try DatabaseQueue(configuration: configuration)

    var migrator = DatabaseMigrator()

    // Register migrations
    migrator.registerMigration("v1") { db in
        try #sql(
            """
            CREATE TABLE "items" (
                "id" TEXT PRIMARY KEY NOT NULL DEFAULT (uuid()),
                "title" TEXT NOT NULL DEFAULT '',
                "isInStock" INTEGER NOT NULL DEFAULT 1,
                "notes" TEXT NOT NULL DEFAULT ''
            ) STRICT
            """
        )
        .execute(db)
    }

    try migrator.migrate(database)
    return database
}
Dependencies への登録
extension DependencyValues {
    var defaultDatabase: any DatabaseWriter {
        get { self[DefaultDatabaseKey.self] }
        set { self[DefaultDatabaseKey.self] = newValue }
    }
}

private enum DefaultDatabaseKey: DependencyKey {
    static let liveValue: any DatabaseWriter = {
        try! appDatabase()
    }()
}

// In app init or @main
prepareDependencies {
    $0.defaultDatabase = try! appDatabase()
}

クエリパターン

プロパティラッパー(@FetchAll、@FetchOne)

SwiftUI でデータベースの変更を監視する主な方法です:

struct ItemsList: View {
    @FetchAll(Item.order(by: \.title)) var items

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
    }
}

主な動作:

  • データベースの変更を自動的に購読します
  • Item が変更されると更新します
  • メインスレッドで実行されます
  • ビューが消えると監視をキャンセルします(iOS 17 以降)
集計用の @FetchOne
struct StatsView: View {
    @FetchOne(Item.count()) var totalCount = 0
    @FetchOne(Item.where(\.isInStock).count()) var inStockCount = 0

    var body: some View {
        Text("Total: \(totalCount), In Stock: \(inStockCount)")
    }
}
ライフサイクルに応じたフェッチ(v1.4.0 以降)

ビューが消えると監視を自動的にキャンセルするために .task を使用します:

struct ItemsList: View {
    @Fetch(Item.all, animation: .default)
    private var items = [Item]()

    @State var searchQuery = ""

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .searchable(text: $searchQuery)
        .task(id: searchQuery) {
            // Automatically cancels when view disappears or searchQuery changes
            try? await $items.load(
                Item.where { $0.title.contains(searchQuery) }
                    .order(by: \.title)
            ).task  // ← .task for auto-cancellation
        }
    }
}

v1.4.0 より前(手動クリーンアップ):

.task {
    try? await $items.load(query)
}
.onDisappear {
    Task { try await $items.load(Item.none) }
}

v1.4.0 以降(自動):

.task {
    try? await $items.load(query).task  // Auto-cancels
}
絞り込み
// Simple keypath filter
let active = Item.where(\.isActive)

// Complex closure filter
let recent = Item.where { $0.createdAt > lastWeek && !$0.isArchived }

// Contains/prefix/suffix
let matches = Item.where { $0.title.contains("phone") }
let starts = Item.where { $0.title.hasPrefix("iPhone") }
並べ替え
// Single column
let sorted = Item.order(by: \.title)

// Descending
let descending = Item.order { $0.createdAt.desc() }

// Multiple columns
let multiSort = Item.order { ($0.priority, $0.createdAt.desc()) }
静的フェッチヘルパー(v1.4.0 以降)

より簡潔なフェッチ構文です:

// OLD (verbose)
let items = try Item.all.fetchAll(db)
let item = try Item.find(id).fetchOne(db)  // returns Optional<Item>

// NEW (concise)
let items = try Item.fetchAll(db)
let item = try Item.find(db, key: id)      // returns Item (non-optional)

// Works with where clauses too
let active = try Item.where(\.isActive).find(db, key: id)

主な改善点: .find(db, key:) は非オプショナル値を返し、見つからない場合はエラーをスローします。


挿入/更新/削除

挿入
try database.write { db in
    try Item.insert {
        Item.Draft(title: "New Item", isInStock: true)
    }
    .execute(db)
}
RETURNING を使った挿入(生成された ID を取得)
let newId = try database.write { db in
    try Item.insert {
        Item.Draft(title: "New Item")
    }
    .returning(\.id)
    .fetchOne(db)
}
単一レコードの更新
try database.write { db in
    try Item.find(itemId)
        .update { $0.title = #bind("Updated Title") }
        .execute(db)
}
複数レコードの更新
try database.write { db in
    try Item.where(\.isArchived)
        .update { $0.isDeleted = #bind(true) }
        .execute(db)
}
削除
// Delete single
try database.write { db in
    try Item.find(id).delete().execute(db)
}

// Delete multiple
try database.write { db in
    try Item.where { $0.createdAt < cutoffDate }
        .delete()
        .execute(db)
}
Upsert(挿入または更新)

SQLite の UPSERT(INSERT ... ON CONFLICT ... DO UPDATE)を使うと、「存在しない場合は挿入し、存在する場合は更新する」という処理を 1 つのステートメントで実行できます。

try database.write { db in
    try Item.insert {
        item
    } onConflict: { cols in
        (cols.libraryID, cols.remoteID)   // Conflict target columns
    } doUpdate: { row, excluded in
        row.name = excluded.name           // Merge semantics
        row.notes = excluded.notes
    }
    .execute(db)
}
パラメーター
  • onConflict: — 「同じ行」を定義する列(UNIQUE 制約/インデックスと一致している必要があります)
  • doUpdate: — 競合時に更新する内容
    • row = データベース内の既存の行
    • excluded = 挿入候補の値(SQLite の excluded テーブル)
部分 UNIQUE インデックスの場合

UNIQUE インデックスに WHERE 句がある場合は、競合フィルターを追加します。

try Item.insert {
    item
} onConflict: { cols in
    (cols.libraryID, cols.remoteID)
} where: { cols in
    cols.remoteID.isNot(nil)          // Match partial index condition
} doUpdate: { row, excluded in
    row.name = excluded.name
}
.execute(db)
スキーマ要件
CREATE UNIQUE INDEX idx_items_sync_identity
ON items (libraryID, remoteID)
WHERE remoteID IS NOT NULL
マージ戦略
すべての変更可能なフィールドを置き換える(同期ミラー)
doUpdate: { row, excluded in
    row.name = excluded.name
    row.notes = excluded.notes
    row.updatedAt = excluded.updatedAt
}
既存の値を上書きせずにマージする
doUpdate: { row, excluded in
    row.name = excluded.name.ifnull(row.name)
    row.notes = excluded.notes.ifnull(row.notes)
}
最終書き込み優先(Raw SQL)
try db.execute(sql: """
    INSERT INTO items (id, name, updatedAt) VALUES (?, ?, ?)
    ON CONFLICT(id) DO UPDATE SET
        name = excluded.name,
        updatedAt = excluded.updatedAt
    WHERE excluded.updatedAt >= items.updatedAt
    """, arguments: [item.id, item.name, item.updatedAt])
// Use >= to handle timestamp ties (last arrival wins)
❌ よくある Upsert の誤り
UNIQUE 制約がない
// WRONG — no index to conflict against
onConflict: { ($0.libraryID, $0.remoteID) }
// but table has no UNIQUE(libraryID, remoteID)
INSERT OR REPLACE を使用する
// WRONG — REPLACE deletes then inserts, breaking FK relationships
try db.execute(sql: "INSERT OR REPLACE INTO items ...")

// CORRECT — use ON CONFLICT for true upsert
try Item.insert { ... } onConflict: { ... } doUpdate: { ... }

バッチ操作

バッチ挿入
try database.write { db in
    try Item.insert {
        ($0.title, $0.isInStock)
    } values: {
        items.map { ($0.title, $0.isInStock) }
    }
    .execute(db)
}
トランザクションの安全性

database.write { } 内のすべての変更処理は、トランザクションでラップされます。

try database.write { db in
    // These all succeed or all fail together
    try Item.insert { ... }.execute(db)
    try Item.find(id).update { ... }.execute(db)
    try OtherTable.find(otherId).delete().execute(db)
}

いずれかの操作でエラーが発生すると、トランザクション全体がロールバックされます。


#sql マクロによる Raw SQL

型安全なクエリビルダーでは扱えないカスタム SQL 式が必要な場合は、StructuredQueries の #sql マクロを使用します。

カスタムクエリ式
nonisolated extension Item.TableColumns {
    var isPastDue: some QueryExpression<Bool> {
        @Dependency(\.date.now) var now
        return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)")
    }
}

// Use in queries
let overdue = try Item.where { $0.isPastDue }.fetchAll(db)
Raw SQL クエリ
// Direct SQL with parameter interpolation
try #sql("SELECT * FROM items WHERE price > \(minPrice)").execute(db)

// Using \(raw:) for literal values
let tableName = "items"
try #sql("SELECT * FROM \(raw: tableName)").execute(db)
#sql を使う理由
  • 型安全なパラメーターバインディング(SQL インジェクションを防止)
  • コンパイル時の構文チェック
  • クエリビルダーとのシームレスな統合
  • パラメーターの補間時に値を自動的にエスケープ

スキーマの作成(CREATE TABLE、マイグレーション)については、完全な例を記載した axiom-sqlitedata-ref リファレンススキルを参照してください。


CloudKit 同期

基本設定
import CloudKit

extension DependencyValues {
    var defaultSyncEngine: SyncEngine {
        get { self[DefaultSyncEngineKey.self] }
        set { self[DefaultSyncEngineKey.self] = newValue }
    }
}

private enum DefaultSyncEngineKey: DependencyKey {
    static let liveValue = {
        @Dependency(\.defaultDatabase) var database
        return try! SyncEngine(
            for: database,
            tables: Item.self,
            privateTables: SensitiveItem.self,  // Private database
            startImmediately: true
        )
    }()
}

// In app init
prepareDependencies {
    $0.defaultDatabase = try! appDatabase()
    $0.defaultSyncEngine = try! SyncEngine(
        for: $0.defaultDatabase,
        tables: Item.self
    )
}
手動同期制御(v1.3.0+)

自動バックグラウンド同期ではなく、同期を実行するタイミングを制御できます。

@Dependency(\.defaultSyncEngine) var syncEngine

// Pull changes from CloudKit
try await syncEngine.fetchChanges()

// Push local changes to CloudKit
try await syncEngine.sendChanges()

// Bidirectional sync
try await syncEngine.syncChanges()

ユースケース:

  • ユーザーが操作する「更新」ボタン
  • 重要な操作の後に同期
  • カスタムの同期スケジュール
  • 同期動作のテスト
同期状態の監視(v1.2.0+)

同期中に UI フィードバックを表示します。

struct SyncStatusView: View {
    @Dependency(\.defaultSyncEngine) var syncEngine

    var body: some View {
        HStack {
            if syncEngine.isSynchronizing {
                ProgressView()
                if syncEngine.isSendingChanges {
                    Text("Uploading...")
                } else if syncEngine.isFetchingChanges {
                    Text("Downloading...")
                }
            } else {
                Image(systemName: "checkmark.circle")
                Text("Synced")
            }
        }
    }
}

監視可能なプロパティ:

  • isSendingChanges: Bool — CloudKit へのアップロード中は True
  • isFetchingChanges: Bool — CloudKit からのダウンロード中は True
  • isSynchronizing: Bool — 送信中または取得中の場合は True
  • isRunning: Bool — 同期エンジンがアクティブな場合は True
同期メタデータのクエリ(v1.3.0+)

レコードの CloudKit 同期情報にアクセスします。

import CloudKit

// Get sync metadata for a record
let metadata = try SyncMetadata.find(item.syncMetadataID).fetchOne(db)

// Join items with their sync metadata
let itemsWithSync = try Item.all
    .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
    .select { (item: $0, metadata: $1) }
    .fetchAll(db)

// Check if record is shared
let sharedItems = try Item.all
    .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
    .where { $1.isShared }
    .fetchAll(db)
マイグレーションヘルパー

同期戦略を切り替える際に、プライマリーキーをマイグレーションします。

try await syncEngine.migratePrimaryKeys(
    from: OldItem.self,
    to: NewItem.self
)

GRDB に切り替える場合

SQLiteData は GRDB を基盤としています。次の場合は、GRDB を直接使用してください。

  • 4 つ以上のテーブルにまたがる複雑な結合
  • ウィンドウ関数(ROW_NUMBER、RANK など)
  • プロファイリングによってクエリビルダーがボトルネックだと確認できた、パフォーマンスが重要な処理

Raw SQL のパターン、ValueObservation、DatabaseMigrator の使用方法については、axiom-grdb を参照してください。


tvOS

CloudKit SyncEngine を使用する SQLiteData は、推奨される tvOS のデータソリューションです。tvOS には永続的なローカルストレージがありません。ストレージの逼迫時に、システムが Caches(Application Support を含む)を削除するためです。SyncEngine を使うと、iCloud が永続ストアとなり、ローカルデータベースはキャッシュとして機能します。削除後も自動的に再構築されます。tvOS のストレージ制約の詳細については、axiom-tvos を参照してください。


リソース

GitHub:pointfreeco/sqlite-data、pointfreeco/swift-structured-queries、groue/GRDB.swift

スキル:axiom-sqlitedata-ref、axiom-sqlitedata-migration、axiom-database-migration、axiom-grdb


ターゲット: iOS 17+、Swift 6 フレームワーク: SQLiteData 1.4+ 履歴: 変更内容については git log を参照してください

コメント