mcpField
A
axiom-sqlitedata

axiom-sqlitedata

github.com/charleswiltgen数据与数据库1.2k203 次安装今天更新
代码仓库

关于 axiom-sqlitedata

axiom-sqlitedata 是一项技能,介绍如何在 Swift 应用中使用 SQLiteData 构建类型安全的 SQLite 持久化。内容涵盖模型、查询、观察与数据变更模式,适用于采用值类型和编译器检查查询的开发者。

主要功能

  1. 01类型安全且经编译器检查的查询
  2. 02使用 @Table 定义值类型模型
  3. 03在 SwiftUI 中观察数据库变更
  4. 04插入、更新、删除与 upsert 模式
  5. 05CloudKit 同步指南

适用场景

  1. 01设计应用持久化数据模型
  2. 02在 SwiftUI 中观察数据库变更
  3. 03编写筛选和排序查询
  4. 04通过 CloudKit 同步记录

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/axiom-sqlitedata确认已加载。

详细介绍

SQLiteData

概述

使用 Point-Free 的 SQLiteData(pointfreeco/sqlite-data)实现类型安全的 SQLite 持久化。它是 SwiftData 的快速、轻量级替代方案,支持 CloudKit 同步,基于 GRDB(groue/GRDB.swift)和 StructuredQueries(pointfreeco/swift-structured-queries)构建。

核心原则: 所有变更都使用值类型(struct)+ @Table 宏 + database.write { } 代码块。

高级模式(CTE、视图、自定义聚合、架构组合),请参阅 axiom-sqlitedata-ref 参考技能。

要求: iOS 17+、Swift 6 严格并发 许可证: MIT

何时使用 SQLiteData

在以下需求下选择 SQLiteData:

  • 类型安全的 SQLite,查询经过编译器检查
  • 支持记录共享的 CloudKit 同步
  • 大型数据集(50k+ 条记录)且性能接近原生 SQLite
  • 使用值类型(struct),而非类
  • 支持 Swift 6 严格并发

以下情况改用 SwiftData:

  • 需要简单的 CRUD 和 Apple 原生集成
  • 更倾向于使用 @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 { ... }
❌ 在写入代码块中使用 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 = ""
}

关键模式:

  • 使用 struct,而非 class(值类型)
  • 为 Swift 6 并发添加 nonisolated
  • 第一个 let 属性会自动成为主键
  • 对不可空列使用默认值(= ""、= true)
  • 可选属性(String?)会映射为可空的 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)可通过一条语句实现“如果不存在则插入,否则更新”。

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 索引带有 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)
}
最后写入者胜出(原始 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 宏执行原始 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)
原始 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 等)
  • 对性能要求极高,且经过分析确认查询构建器是瓶颈的路径

请参阅 axiom-grdb,了解原始 SQL 模式、ValueObservation 和 DatabaseMigrator 的用法。


tvOS

SQLiteData 搭配 CloudKit SyncEngine 是推荐的 tvOS 数据解决方案。tvOS 没有持久化的本地存储空间——存储压力过大时,系统会删除缓存(包括 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 了解变更

评论

支持 Markdown