mcpField
A
axiom-database-migration

axiom-database-migration

github.com/charleswiltgenデータ・データベース1.2k173 回インストール今日更新
リポジトリ

axiom-database-migration について

本番環境で SQLite、GRDB、SwiftData のスキーマを進化させる、Swift 開発者向けのデータベース移行スキルです。既存データを守るため、追加型で冪等な移行、リリース後の不変性、テストを重視します。

主な機能

  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. 「カラムの型を text から integer に変更する必要があります。単に ALTER でカラムの型を変更できますか?」

→ このスキルでは、新しいカラムを追加 → データを移行 → 古いカラムを非推奨にする(決して削除しない)という安全なパターンを説明します。

4. 「テーブル間に外部キーのリレーションシップを追加します。既存のデータを壊さずにリレーションシップを追加するにはどうすればよいですか?」

→ このスキルでは、安全な外部キーのパターンを説明します。カラムを追加 → データを入力 → インデックスを追加します(SQLite の制限についても説明します)。

5. 「前回のアップデート後にクラッシュが発生するとユーザーから報告されています。マイグレーションを変更しましたが、アプリはすでに本番環境にあります。どうすればよいですか?」

→ このスキルでは、マイグレーションはリリース後に変更できないことを説明し、古いものを変更するのではなく、新しいマイグレーションを作成して問題を修正する方法を示します。


⛔ 絶対に行ってはいけないこと(データ損失のリスク)

これらの操作は本番環境のユーザーデータを破壊します

❌ ユーザーデータのあるテーブルに DROP TABLE を絶対に使用しないでください ❌ リリース済みのマイグレーションを絶対に変更しないでください(代わりに新しいものを作成します) ❌ スキーマ変更のためにテーブルを絶対に再作成しないでください(データが失われます) ❌ DEFAULT 値なしで NOT NULL カラムを絶対に追加しないでください ❌ カラムを絶対に削除しないでください(SQLite は安全な DROP COLUMN をサポートしていません)

これらのいずれかを行いたくなったら、いったん止めて、以下の安全なパターンを使用してください。

必須ルール

必ず次のルールに従ってください
  1. 追加のみ 新しいカラムやテーブルを追加し、決して削除しないでください
  2. べき等性 作成前に存在を確認してください(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 べき等性(マイグレーションを 2 回実行し、エラーが発生しないことを確認する)

@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親行が存在するか確認するか、一時的に FK を無効にします
no such column: columnNameカラムを作成するマイグレーションを追加します
cannot add NOT NULL column最初は NULL 許容カラムを使用し、別のマイグレーションでバックフィルします
table tableName already existsIF NOT EXISTS 句を追加します
duplicate column name追加する前にカラムが存在するか確認します(べき等性)

よくある間違い

❌ DEFAULT を指定せずに NOT NULL を追加する

// ❌ Fails on existing data
ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL

✅ 正しい方法:まず 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% でマイグレーションが失敗 → 緊急ロールバック → データの不整合

変更後 開発者が NULL 許容のカラムを追加 → 両方の経路をテスト → スムーズにデプロイ → v2 でデータをバックフィル

重要なポイント 本番環境ではマイグレーションをロールバックできません。入念なテストを行い、最初から正しく実施してください。

tvOS

tvOS のマイグレーションは、新規データベースに対して実行される場合があります。 システムはストレージが逼迫するとローカルストレージを削除するため、アプリがデータベースのない状態で起動することがあります。マイグレーションはこれを適切に処理する必要があります。実質的に、「作成」と「アップグレード」の両方の処理になります。

主な影響:

  • マイグレーションは冪等である必要があります(すでにベストプラクティスですが、ここでは特に重要です)
  • バックフィル処理で、以前のデータが存在すると想定しないでください
  • 「アップグレード」の経路と同じくらい頻繁に「新規インストール」の経路をテストしてください

tvOS のストレージ制約について詳しくは、axiom-tvos を参照してください。


最終更新日:2025-11-28 フレームワーク:SQLite、GRDB、SwiftData ステータス:安全なスキーマ進化のための、本番環境対応パターン

コメント