关于 axiom-database-migration
面向 Swift 应用开发者的数据库迁移技能,提供生产环境安全演进 SQLite、GRDB 和 SwiftData 模式的指导。重点是迁移保持增量、幂等且发布后不可修改,并通过测试保护现有数据。
主要功能
- 01安全添加列与外键
- 02模式变更的分步迁移
- 03幂等与事务规则
- 04新装及升级路径测试
- 05GRDB 与 SwiftData 模式
适用场景
- 01为已上线应用增加用户偏好列
- 02将列类型变更为新类型
- 03修复已发布迁移的问题
- 04验证新装与旧版本升级
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-database-migration安装后在 Claude Code 中输入/axiom-database-migration确认已加载。
详细介绍
数据库迁移
概述
为生产应用安全地演进数据库架构,同时保护用户数据。核心原则:发布后,迁移不可更改。采用增量式、幂等的方式,并进行全面测试。
示例提示词
以下是真实开发者会提出的问题,本技能旨在解答这些问题:
1. “我需要添加一个新列来存储用户偏好,但应用已经上线并有用户数据了。怎样才能安全地完成这项操作?”
→ 本技能介绍了安全添加列的增量式模式,确保不会丢失现有数据,并包括幂等性检查。
2. “尝试迁移时出现 ‘cannot add NOT NULL column’ 错误。这是什么意思?该如何修复?”
→ 本技能解释了为什么在已有数据行时添加 NOT NULL 列会失败,并展示了安全模式(先允许 NULL,再回填数据)。
3. “我需要将一列的类型从文本改为整数。可以直接 ALTER 该列的类型吗?”
→ 本技能演示了安全模式:添加新列 → 迁移数据 → 弃用旧列(绝不删除)。
4. “我正在表之间添加外键关系。怎样才能在不破坏现有数据的情况下添加这种关系?”
→ 本技能介绍了安全的外键模式:添加列 → 填充数据 → 添加索引(并解释 SQLite 的限制)。
5. “用户反映上次更新后应用会崩溃。我修改了一项迁移,但应用已经在生产环境中运行了。我该怎么办?”
→ 本技能说明迁移在发布后不可更改;并展示如何创建新迁移来修复问题,而不是修改旧迁移。
⛔ 绝不要执行以下操作(有数据丢失风险)
以下操作会破坏生产环境中的用户数据
❌ 绝不要对含有用户数据的表使用 DROP TABLE ❌ 绝不要修改已发布的迁移(应改为创建新迁移) ❌ 绝不要通过重建表来更改架构(会丢失数据) ❌ 绝不要添加没有 DEFAULT 值的 NOT NULL 列 ❌ 绝不要删除列(SQLite 不支持安全地执行 DROP COLUMN)
如果你正打算执行以上任一操作,请停下来,改用下方的安全模式。
强制规则
始终遵循以下规则
- 仅采用增量式变更 添加新列或新表,绝不删除
- 幂等 创建前检查是否已存在(重复运行也安全)
- 使用事务 将整个迁移封装在单个事务中
- 测试两种路径 全新安装,以及从先前版本迁移
- 先允许 NULL 添加列时设为 NULL,需要时再回填数据
- 不可变 一旦发布给用户,迁移就不能更改
安全模式
添加列(最常见)
// ✅ Safe pattern
func migration00X_AddNewColumn() throws {
try database.write { db in
// 1. Check if column exists (idempotency)
let hasColumn = try db.columns(in: "tableName")
.contains { $0.name == "newColumn" }
if !hasColumn {
// 2. Add as nullable (works with existing rows)
try db.execute(sql: """
ALTER TABLE tableName
ADD COLUMN newColumn TEXT
""")
}
}
}
这样做有效的原因
- 可为 NULL 的列不需要 DEFAULT 值
- 现有行会自动获得 NULL 值
- 无需转换数据
- 对从旧版本升级的用户而言安全
添加带默认值的列
// ✅ Safe pattern with default
func migration00X_AddColumnWithDefault() throws {
try database.write { db in
let hasColumn = try db.columns(in: "tracks")
.contains { $0.name == "playCount" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN playCount INTEGER DEFAULT 0
""")
}
}
}
更改列类型(高级)
模式:添加新列 → 迁移数据 → 弃用旧列(绝不删除)
// ✅ Safe pattern for type change
func migration00X_ChangeColumnType() throws {
try database.write { db in
// Step 1: Add new column with new type
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN age_new INTEGER
""")
// Step 2: Migrate existing data
try db.execute(sql: """
UPDATE users
SET age_new = CAST(age_old AS INTEGER)
WHERE age_old IS NOT NULL
""")
// Step 3: Application code uses age_new going forward
// (Never delete age_old column - just stop using it)
}
}
添加外键约束
// ✅ Safe pattern for foreign keys
func migration00X_AddForeignKey() throws {
try database.write { db in
// Step 1: Add new column (nullable initially)
try db.execute(sql: """
ALTER TABLE tracks
ADD COLUMN album_id TEXT
""")
// Step 2: Populate the data
try db.execute(sql: """
UPDATE tracks
SET album_id = (
SELECT id FROM albums
WHERE albums.title = tracks.album_name
)
""")
// Step 3: Add index (helps query performance)
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_tracks_album_id
ON tracks(album_id)
""")
// Note: SQLite doesn't allow adding FK constraints to existing tables
// The foreign key relationship is enforced at the application level
}
}
复杂的架构重构
模式:拆分为多项迁移
// Migration 1: Add new structure
func migration010_AddNewTable() throws {
try database.write { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS new_structure (
id TEXT PRIMARY KEY,
data TEXT
)
""")
}
}
// Migration 2: Copy data
func migration011_MigrateData() throws {
try database.write { db in
try db.execute(sql: """
INSERT INTO new_structure (id, data)
SELECT id, data FROM old_structure
""")
}
}
// Migration 3: Add indexes
func migration012_AddIndexes() throws {
try database.write { db in
try db.execute(sql: """
CREATE INDEX IF NOT EXISTS idx_new_structure_data
ON new_structure(data)
""")
}
}
// Old structure stays around (deprecated in code)
测试清单
部署任何迁移之前
// Test 1: Migration path (CRITICAL - tests data preservation)
@Test func migrationFromV1ToV2Succeeds() async throws {
let db = try Database(inMemory: true)
// Simulate v1 schema
try db.write { db in
try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)")
try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')")
}
// Run v2 migration
try db.runMigrations()
// Verify data survived + new column exists
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 1) // Data preserved
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("newColumn")) // New column exists
}
}
测试 2 全新安装(运行所有迁移,验证最终架构)
@Test func freshInstallCreatesCorrectSchema() async throws {
let db = try Database(inMemory: true)
// Run all migrations
try db.runMigrations()
// Verify final schema
try db.read { db in
let tables = try db.tables()
#expect(tables.contains("tableName"))
let columns = try db.columns(in: "tableName").map { $0.name }
#expect(columns.contains("id"))
#expect(columns.contains("newColumn"))
}
}
测试 3 幂等性(将迁移运行两次,不应抛出异常)
@Test func migrationsAreIdempotent() async throws {
let db = try Database(inMemory: true)
// Run migrations twice
try db.runMigrations()
try db.runMigrations() // Should not throw
// Verify still correct
try db.read { db in
let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
#expect(count == 0) // No duplicate data
}
}
手动测试(TestFlight 之前)
- 在设备上安装 v(n-1) 版本构建 → 添加真实用户数据
- 安装 v(n) 版本构建(包含新迁移)
- 验证:应用能启动、数据可见、没有崩溃
决策树
What are you trying to do?
├─ Add new column?
│ └─ ALTER TABLE ADD COLUMN (nullable) → Done
├─ Add column with default?
│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done
├─ Change column type?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Delete column?
│ └─ Mark as deprecated in code → Never delete from schema → Done
├─ Rename column?
│ └─ Add new column → Migrate data → Deprecate old → Done
├─ Add foreign key?
│ └─ Add column → Populate data → Add index → Done
└─ Complex refactor?
└─ Break into multiple migrations → Test each step → Done
常见错误
| 错误 | 修复方法 |
|---|---|
FOREIGN KEY constraint failed | 检查父行是否存在,或暂时禁用外键 |
no such column: columnName | 添加迁移以创建该列 |
cannot add NOT NULL column | 先使用可为 NULL 的列,再在单独的迁移中回填数据 |
table tableName already exists | 添加 IF NOT EXISTS 子句 |
duplicate column name | 添加前检查列是否已存在(幂等性) |
常见错误
❌ 添加 NOT NULL 列但不设置 DEFAULT
// ❌ Fails on existing data
ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL
✅ 正确:先添加为可空列
ALTER TABLE albums ADD COLUMN rating INTEGER // NULL allowed
// Backfill in separate migration if needed
UPDATE albums SET rating = 0 WHERE rating IS NULL
❌ 忘记检查是否已存在 — 始终添加 IF NOT EXISTS 或手动检查
❌ 修改已发布的迁移 — 应改为创建新迁移
❌ 未测试迁移路径 — 始终测试从上一版本升级
GRDB 特定模式
DatabaseMigrator 设置
var migrator = DatabaseMigrator()
// Migration 1
migrator.registerMigration("v1") { db in
try db.execute(sql: """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
""")
}
// Migration 2
migrator.registerMigration("v2") { db in
let hasColumn = try db.columns(in: "users")
.contains { $0.name == "email" }
if !hasColumn {
try db.execute(sql: """
ALTER TABLE users
ADD COLUMN email TEXT
""")
}
}
// Apply migrations
try migrator.migrate(dbQueue)
检查迁移状态
// Check which migrations have been applied
let appliedMigrations = try dbQueue.read { db in
try migrator.appliedMigrations(db)
}
print("Applied migrations: \(appliedMigrations)")
// Check if migrations are needed
let hasBeenMigrated = try dbQueue.read { db in
try migrator.hasBeenMigrated(db)
}
SwiftData 迁移
对于 SwiftData(iOS 17+),请使用 VersionedSchema 和 SchemaMigrationPlan:
// Define schema versions
enum MyAppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self]
}
}
enum MyAppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Track.self, Album.self, Playlist.self] // Added Playlist
}
}
// Define migration plan
enum MyAppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[MyAppSchemaV1.self, MyAppSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: MyAppSchemaV1.self,
toVersion: MyAppSchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// Custom migration logic here
}
)
}
实际影响
之前 开发者添加 NOT NULL 列 → 50% 的用户迁移失败 → 紧急回滚 → 数据不一致
之后 开发者添加可空列 → 测试两种路径 → 顺利部署 → 在 v2 中回填数据
关键要点 迁移在生产环境中无法回滚。通过充分测试,确保一次就做好。
tvOS
tvOS 迁移可能会在全新数据库上运行。 系统会在存储空间紧张时删除本地存储,因此应用启动时可能完全没有数据库。迁移必须妥善处理这种情况——实际上,它们同时也是“创建”和“升级”操作。
重要影响:
- 迁移必须具备幂等性(这本来就是最佳实践,但在此处尤为重要)
- 回填操作不要假设之前的数据存在
- 测试“全新安装”路径的频率应不低于“升级”路径
有关 tvOS 存储限制的完整信息,请参阅 axiom-tvos。
最后更新:2025-11-28 框架:SQLite、GRDB、SwiftData 状态:适用于安全演进数据库架构的生产就绪模式