axiom-sqlitedata について
axiom-sqlitedataは、SwiftアプリでSQLiteDataを使った型安全なSQLite永続化を行うためのスキルです。値型とコンパイラ検査付きクエリを使う開発者向けに、モデル、クエリ、監視、変更のパターンを解説します。
主な機能
- 01型安全なコンパイラ検査付きクエリ
- 02@Tableによる値型モデル定義
- 03SwiftUIでのデータベース変更監視
- 04挿入・更新・削除・upsertのパターン
- 05CloudKit同期のガイダンス
活用シーン
- 01アプリの永続化データモデル作成
- 02SwiftUIでのデータベース変更監視
- 03絞り込み・並べ替えクエリの作成
- 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 へのアップロード中は TrueisFetchingChanges: Bool— CloudKit からのダウンロード中は TrueisSynchronizing: Bool— 送信中または取得中の場合は TrueisRunning: 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 を参照してください