mcpField
A
axiom-database-migration

axiom-database-migration

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

关于 axiom-database-migration

面向 Swift 应用开发者的数据库迁移技能,提供生产环境安全演进 SQLite、GRDB 和 SwiftData 模式的指导。重点是迁移保持增量、幂等且发布后不可修改,并通过测试保护现有数据。

主要功能

  1. 01安全添加列与外键
  2. 02模式变更的分步迁移
  3. 03幂等与事务规则
  4. 04新装及升级路径测试
  5. 05GRDB 与 SwiftData 模式

适用场景

  1. 01为已上线应用增加用户偏好列
  2. 02将列类型变更为新类型
  3. 03修复已发布迁移的问题
  4. 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)

如果你正打算执行以上任一操作,请停下来,改用下方的安全模式。

强制规则

始终遵循以下规则
  1. 仅采用增量式变更 添加新列或新表,绝不删除
  2. 幂等 创建前检查是否已存在(重复运行也安全)
  3. 使用事务 将整个迁移封装在单个事务中
  4. 测试两种路径 全新安装,以及从先前版本迁移
  5. 先允许 NULL 添加列时设为 NULL,需要时再回填数据
  6. 不可变 一旦发布给用户,迁移就不能更改

安全模式

添加列(最常见)
// ✅ 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 之前)
  1. 在设备上安装 v(n-1) 版本构建 → 添加真实用户数据
  2. 安装 v(n) 版本构建(包含新迁移)
  3. 验证:应用能启动、数据可见、没有崩溃

决策树

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 状态:适用于安全演进数据库架构的生产就绪模式

评论

支持 Markdown