axiom-sqlitedata by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-sqlitedata使用 Point-Free 的 SQLiteData (pointfreeco/sqlite-data) 实现类型安全的 SQLite 持久化。这是一个基于 GRDB (groue/GRDB.swift) 和 StructuredQueries (pointfreeco/swift-structured-queries) 构建的、支持 CloudKit 同步的快速、轻量级 SwiftData 替代方案。
核心原则: 值类型 (struct) + @Table 宏 + 用于所有变更的 database.write { } 代码块。
对于高级模式 (CTEs、视图、自定义聚合、模式组合),请参阅 axiom-sqlitedata-ref 参考技能。
要求: iOS 17+, Swift 6 严格并发性 许可证: MIT
在以下情况选择 SQLiteData:
在以下情况使用 SwiftData:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
@Model 类而非结构体在以下情况使用原生 GRDB:
// 模型
@Table nonisolated struct Item: Identifiable {
let id: UUID // 第一个 let 属性 = 自动主键
var title = "" // 默认值 = 非空列
var notes: String? // 可选属性 = 可空列
@Column(as: Color.Hex.self)
var color: Color = .blue // 自定义表示
@Ephemeral var isSelected = false // 不持久化
}
// 设置
prepareDependencies { $0.defaultDatabase = try! appDatabase() }
@Dependency(\.defaultDatabase) var database
// 获取
@FetchAll var items: [Item]
@FetchAll(Item.order(by: \.title).where(\.isInStock)) var items
@FetchOne(Item.count()) var count = 0
// 获取 (静态辅助方法 - v1.4.0+)
try Item.fetchAll(db) // 对比 Item.all.fetchAll(db)
try Item.find(db, key: id) // 返回非可选的 Item
// 插入
try database.write { db in
try Item.insert { Item.Draft(title: "New") }.execute(db)
}
// 更新 (单个)
try database.write { db in
try Item.find(id).update { $0.title = #bind("Updated") }.execute(db)
}
// 更新 (批量)
try database.write { db in
try Item.where(\.isInStock).update { $0.notes = #bind("") }.execute(db)
}
// 删除
try database.write { db in
try Item.find(id).delete().execute(db)
try Item.where { $0.id.in(ids) }.delete().execute(db) // 批量
}
// 查询
Item.where(\.isActive) // 键路径 (简单)
Item.where { $0.title.contains("phone") } // 闭包 (复杂)
Item.where { $0.status.eq(#bind(.done)) } // 枚举比较
Item.order(by: \.title) // 排序
Item.order { $0.createdAt.desc() } // 降序排序
Item.limit(10).offset(20) // 分页
// 原生 SQL (#sql 宏)
#sql("SELECT * FROM items WHERE price > 100") // 类型安全的原生 SQL
#sql("coalesce(date(\(dueDate)) = date(\(now)), 0)") // 自定义表达式
// CLOUDKIT (v1.2-1.4+)
prepareDependencies {
$0.defaultSyncEngine = try SyncEngine(
for: $0.defaultDatabase,
tables: Item.self
)
}
@Dependency(\.defaultSyncEngine) var syncEngine
// 手动同步控制 (v1.3.0+)
try await syncEngine.fetchChanges() // 从 CloudKit 拉取
try await syncEngine.sendChanges() // 推送到 CloudKit
try await syncEngine.syncChanges() // 双向同步
// 同步状态观察 (v1.2.0+)
syncEngine.isSendingChanges // 上传时为 true
syncEngine.isFetchingChanges // 下载时为 true
syncEngine.isSynchronizing // 发送或获取时为 true
==// 错误 — 已在 StructuredQueries 0.31+ 中移除 (编译器错误)
.where { $0.status == .completed }
// 正确 — 使用比较方法
.where { $0.status.eq(#bind(.completed)) }
#bind (StructuredQueries 0.31+)// 错误 — StructuredQueries 0.31+ 中的编译器错误
Item.find(id).update { $0.title = "New" }.execute(db)
// 正确 — 使用 #bind 包装字面值
Item.find(id).update { $0.title = #bind("New") }.execute(db)
// 注意:复合运算符 (+=, -=) 不需要 #bind — 它们会自动绑定
Item.find(id).update { $0.title += "!" }.execute(db) // 正确
// 错误 — .update 在 .where 之前
Item.update { $0.title = #bind("X") }.where { $0.id.eq(#bind(id)) }
// 正确 — 单个使用 .find(),批量使用 .where() 在 .update() 之前
Item.find(id).update { $0.title = #bind("X") }.execute(db)
Item.where(\.isOld).update { $0.archived = #bind(true) }.execute(db)
// 错误 — 没有实例插入方法
let item = Item(id: UUID(), title: "Test")
try item.insert(db)
// 正确 — 使用 .Draft 的静态插入
try Item.insert { Item.Draft(title: "Test") }.execute(db)
nonisolated// 错误 — Swift 6 并发性警告
@Table struct Item { ... }
// 正确
@Table nonisolated struct Item { ... }
// 错误 — 写入块是同步的
try await database.write { db in ... }
// 正确 — 块内不使用 await
try database.write { db in
try Item.insert { ... }.execute(db)
}
.execute(db)// 错误 — 构建了查询但未执行
try database.write { db in
Item.insert { Item.Draft(title: "X") } // 什么都不做!
}
// 正确
try database.write { db in
try Item.insert { Item.Draft(title: "X") }.execute(db)
}
import SQLiteData
@Table
nonisolated struct Item: Identifiable {
let id: UUID // 第一个 `let` = 自动主键
var title = ""
var isInStock = true
var notes = ""
}
关键模式:
struct,而非 class (值类型)nonisolatedlet 属性自动成为主键= "", = true)String?) 映射到可空的 SQL 列@Table
nonisolated struct Tag: Hashable, Identifiable {
@Column(primaryKey: true)
var title: String // 自定义主键
var id: String { title }
}
@Table
nonisolated struct RemindersList: Hashable, Identifiable {
let id: UUID
@Column(as: Color.HexRepresentation.self) // 自定义类型表示
var color: Color = .blue
var position = 0
var title = ""
}
@Table
nonisolated struct Reminder: Hashable, Identifiable {
let id: UUID
var title = ""
var remindersListID: RemindersList.ID // 外键 (显式列)
}
@Table
nonisolated struct Attendee: Hashable, Identifiable {
let id: UUID
var name = ""
var syncUpID: SyncUp.ID // 引用父表
}
注意: SQLiteData 使用显式的外键列。关系通过连接表达,而非 @Relationship 宏。
不要在 Swift 中获取所有记录再过滤 — 将过滤推送到数据库:
// ❌ 反模式:获取所有,在 Swift 中过滤
let allReminders = try database.read { try Reminder.all.fetch($0) }
let filtered = allReminders.filter { $0.remindersListID == listID }
// ✅ 在数据库层面过滤
let filtered = try database.read {
try Reminder.all
.filter { $0.remindersListID.eq(#bind(listID)) }
.fetch($0)
}
// ✅ 跨表连接并过滤
let remindersWithList = try database.read {
try Reminder.all
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.filter { $1.name.eq(#bind("Shopping")) }
.fetch($0)
}
// ✅ 左连接 (即使没有列表也包含提醒)
let allWithOptionalList = try database.read {
try Reminder.all
.leftJoin(RemindersList.all) { $0.remindersListID.eq($1.id) }
.fetch($0)
}
对于跨 4 个以上表的复杂连接,请降级使用原生 GRDB (参见 axiom-grdb)。
标记存在于 Swift 但不在数据库中的属性:
@Table
nonisolated struct Item: Identifiable {
let id: UUID
var title = ""
var price: Decimal = 0
@Ephemeral
var isSelected = false // 不存储在数据库中
@Ephemeral
var formattedPrice: String { // 计算属性,不存储
"$\(price)"
}
}
使用场景:
重要: @Ephemeral 属性必须有默认值,因为它们不会从数据库填充。
import Dependencies
import SQLiteData
import GRDB
func appDatabase() throws -> any DatabaseWriter {
var configuration = Configuration()
configuration.prepareDatabase { db in
// 配置数据库行为
db.trace { print("SQL: \($0)") } // 可选的 SQL 日志记录
}
let database = try DatabaseQueue(configuration: configuration)
var migrator = DatabaseMigrator()
// 注册迁移
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
}
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()
}()
}
// 在应用初始化或 @main 中
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
}
在 SwiftUI 中观察数据库变更的主要方式:
struct ItemsList: View {
@FetchAll(Item.order(by: \.title)) var items
var body: some View {
List(items) { item in
Text(item.title)
}
}
}
关键行为:
Item 变更时更新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)")
}
}
使用 .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) {
// 当视图消失或 searchQuery 改变时自动取消
try? await $items.load(
Item.where { $0.title.contains(searchQuery) }
.order(by: \.title)
).task // ← .task 用于自动取消
}
}
}
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 // 自动取消
}
// 简单的键路径过滤
let active = Item.where(\.isActive)
// 复杂的闭包过滤
let recent = Item.where { $0.createdAt > lastWeek && !$0.isArchived }
// 包含/前缀/后缀
let matches = Item.where { $0.title.contains("phone") }
let starts = Item.where { $0.title.hasPrefix("iPhone") }
// 单列
let sorted = Item.order(by: \.title)
// 降序
let descending = Item.order { $0.createdAt.desc() }
// 多列
let multiSort = Item.order { ($0.priority, $0.createdAt.desc()) }
更简洁的获取语法:
// 旧 (冗长)
let items = try Item.all.fetchAll(db)
let item = try Item.find(id).fetchOne(db) // 返回 Optional<Item>
// 新 (简洁)
let items = try Item.fetchAll(db)
let item = try Item.find(db, key: id) // 返回 Item (非可选)
// 也适用于 where 子句
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)
}
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)
}
// 删除单个
try database.write { db in
try Item.find(id).delete().execute(db)
}
// 删除多个
try database.write { db in
try Item.where { $0.createdAt < cutoffDate }
.delete()
.execute(db)
}
SQLite 的 UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) 在一个语句中表达“如果缺失则插入,否则更新”。
try database.write { db in
try Item.insert {
item
} onConflict: { cols in
(cols.libraryID, cols.remoteID) // 冲突目标列
} doUpdate: { row, excluded in
row.name = excluded.name // 合并语义
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) // 匹配部分索引条件
} 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)
}
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])
// 使用 >= 处理时间戳平局 (最后到达者获胜)
// 错误 — 没有索引来应对冲突
onConflict: { ($0.libraryID, $0.remoteID) }
// 但表没有 UNIQUE(libraryID, remoteID)
// 错误 — REPLACE 先删除再插入,会破坏外键关系
try db.execute(sql: "INSERT OR REPLACE INTO items ...")
// 正确 — 使用 ON CONFLICT 进行真正的更新插入
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
// 这些操作要么全部成功,要么全部失败
try Item.insert { ... }.execute(db)
try Item.find(id).update { ... }.execute(db)
try OtherTable.find(otherId).delete().execute(db)
}
如果任何操作抛出错误,整个事务将回滚。
当你需要超出类型安全查询构建器的自定义 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)")
}
}
// 在查询中使用
let overdue = try Item.where { $0.isPastDue }.fetchAll(db)
// 带参数插值的直接 SQL
try #sql("SELECT * FROM items WHERE price > \(minPrice)").execute(db)
// 使用 \(raw:) 处理字面值
let tableName = "items"
try #sql("SELECT * FROM \(raw: tableName)").execute(db)
对于模式创建 (CREATE TABLE, 迁移),请参阅 axiom-sqlitedata-ref 参考技能以获取完整示例。
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, // 私有数据库
startImmediately: true
)
}()
}
// 在应用初始化中
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
$0.defaultSyncEngine = try! SyncEngine(
for: $0.defaultDatabase,
tables: Item.self
)
}
控制同步发生的时间,而非自动后台同步:
@Dependency(\.defaultSyncEngine) var syncEngine
// 从 CloudKit 拉取变更
try await syncEngine.fetchChanges()
// 将本地变更推送到 CloudKit
try await syncEngine.sendChanges()
// 双向同步
try await syncEngine.syncChanges()
使用场景:
在同步期间显示 UI 反馈:
struct SyncStatusView: View {
@Dependency(\.defaultSyncEngine) var syncEngine
var body: some View {
HStack {
if syncEngine.isSynchronizing {
ProgressView()
if syncEngine.isSendingChanges {
Text("上传中...")
} else if syncEngine.isFetchingChanges {
Text("下载中...")
}
} else {
Image(systemName: "checkmark.circle")
Text("已同步")
}
}
}
}
可观察属性:
isSendingChanges: Bool — CloudKit 上传期间为 trueisFetchingChanges: Bool — CloudKit 下载期间为 trueisSynchronizing: Bool — 发送或获取时为 trueisRunning: Bool — 同步引擎激活时为 true访问记录的 CloudKit 同步信息:
import CloudKit
// 获取记录的同步元数据
let metadata = try SyncMetadata.find(item.syncMetadataID).fetchOne(db)
// 将项目与其同步元数据连接
let itemsWithSync = try Item.all
.leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
.select { (item: $0, metadata: $1) }
.fetchAll(db)
// 检查记录是否已共享
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
)
SQLiteData 构建于 GRDB 之上。在以下情况使用原生 GRDB:
参见 axiom-grdb 以了解原生 SQL 模式、ValueObservation 和 DatabaseMigrator 的用法。
带 CloudKit SyncEngine 的 SQLiteData 是 推荐的 tvOS 数据解决方案。tvOS 没有持久化的本地存储 — 系统会在存储压力下删除缓存 (包括 Application Support)。使用 SyncEngine,iCloud 是你的持久化存储,本地数据库只是一个缓存,在删除后会自动重建。参见 axiom-tvos 以了解完整的 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 日志了解变更
每周安装量
105
代码仓库
GitHub 星标数
601
首次出现
2026年1月21日
安全审计
安装于
opencode87
codex82
gemini-cli78
cursor77
claude-code75
github-copilot72
Type-safe SQLite persistence using SQLiteData (pointfreeco/sqlite-data) by Point-Free. A fast, lightweight replacement for SwiftData with CloudKit synchronization support, built on GRDB (groue/GRDB.swift) and StructuredQueries (pointfreeco/swift-structured-queries).
Core principle: Value types (struct) + @Table macro + database.write { } blocks for all mutations.
For advanced patterns (CTEs, views, custom aggregates, schema composition), see the axiom-sqlitedata-ref reference skill.
Requires: iOS 17+, Swift 6 strict concurrency License: MIT
Choose SQLiteData when you need:
Use SwiftData instead when:
@Model classes over structsUse raw GRDB when:
// 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
== in predicates// WRONG — removed in StructuredQueries 0.31+ (compiler error)
.where { $0.status == .completed }
// CORRECT — use comparison methods
.where { $0.status.eq(#bind(.completed)) }
#bind in update assignments (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 { ... }
// 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)
}
import SQLiteData
@Table
nonisolated struct Item: Identifiable {
let id: UUID // First `let` = auto primary key
var title = ""
var isInStock = true
var notes = ""
}
Key patterns:
struct, not class (value types)nonisolated for Swift 6 concurrencylet property is automatically the primary key= "", = true) for non-nullable columnsString?) map to nullable SQL columns@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
}
Note: SQLiteData uses explicit foreign key columns. Relationships are expressed through joins, not @Relationship macros.
Don't fetch all records and filter in Swift — push filtering to the database:
// ❌ 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)
}
For complex joins across 4+ tables, drop down to raw GRDB (see axiom-grdb).
Mark properties that exist in Swift but not in the database:
@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)"
}
}
Use cases:
Important: @Ephemeral properties must have default values since they won't be populated from the database.
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
}
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()
}
The primary way to observe database changes in SwiftUI:
struct ItemsList: View {
@FetchAll(Item.order(by: \.title)) var items
var body: some View {
List(items) { item in
Text(item.title)
}
}
}
Key behaviors:
Item changesstruct 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)")
}
}
Use .task to automatically cancel observation when view disappears:
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
}
}
}
Before v1.4.0 (manual cleanup):
.task {
try? await $items.load(query)
}
.onDisappear {
Task { try await $items.load(Item.none) }
}
With v1.4.0 (automatic):
.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()) }
Cleaner syntax for fetching:
// 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)
Key improvement: .find(db, key:) returns non-optional, throwing an error if not found.
try database.write { db in
try Item.insert {
Item.Draft(title: "New Item", isInStock: true)
}
.execute(db)
}
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)
}
SQLite's UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) expresses "insert if missing, otherwise update" in one statement.
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: — Columns defining "same row" (must match UNIQUE constraint/index)doUpdate: — What to update on conflict
row = existing database rowexcluded = proposed insert values (SQLite's excluded table)When your UNIQUE index has a WHERE clause, add a conflict filter:
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)
}
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)
// WRONG — no index to conflict against
onConflict: { ($0.libraryID, $0.remoteID) }
// but table has no UNIQUE(libraryID, remoteID)
// 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)
}
All mutations inside database.write { } are wrapped in a transaction:
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)
}
If any operation throws, the entire transaction rolls back.
When you need custom SQL expressions beyond the type-safe query builder, use the #sql macro from StructuredQueries:
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)
// 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)
For schema creation (CREATE TABLE, migrations), see the axiom-sqlitedata-ref reference skill for complete examples.
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
)
}
Control when sync happens instead of automatic background sync:
@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()
Use cases:
Show UI feedback during sync:
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")
}
}
}
}
Observable properties:
isSendingChanges: Bool — True during CloudKit uploadisFetchingChanges: Bool — True during CloudKit downloadisSynchronizing: Bool — True if either sending or fetchingisRunning: Bool — True if sync engine is activeAccess CloudKit sync information for records:
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)
Migrate primary keys when switching sync strategies:
try await syncEngine.migratePrimaryKeys(
from: OldItem.self,
to: NewItem.self
)
SQLiteData is built on GRDB. Use raw GRDB when you need:
See axiom-grdb for raw SQL patterns, ValueObservation, and DatabaseMigrator usage.
SQLiteData with CloudKit SyncEngine is the recommended tvOS data solution. tvOS has no persistent local storage — the system deletes Caches (including Application Support) under storage pressure. With SyncEngine, iCloud is your persistent store and the local database is just a cache that rebuilds automatically after deletion. See axiom-tvos for full tvOS storage constraints.
GitHub : pointfreeco/sqlite-data, pointfreeco/swift-structured-queries, groue/GRDB.swift
Skills : axiom-sqlitedata-ref, axiom-sqlitedata-migration, axiom-database-migration, axiom-grdb
Targets: iOS 17+, Swift 6 Framework: SQLiteData 1.4+ History: See git log for changes
Weekly Installs
105
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode87
codex82
gemini-cli78
cursor77
claude-code75
github-copilot72
Kotlin Exposed ORM 模式指南:DSL查询、DAO、事务管理与生产配置
913 周安装