axiom-database-migration について
本番環境で SQLite、GRDB、SwiftData のスキーマを進化させる、Swift 開発者向けのデータベース移行スキルです。既存データを守るため、追加型で冪等な移行、リリース後の不変性、テストを重視します。
主な機能
- 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. 「カラムの型を text から integer に変更する必要があります。単に ALTER でカラムの型を変更できますか?」
→ このスキルでは、新しいカラムを追加 → データを移行 → 古いカラムを非推奨にする(決して削除しない)という安全なパターンを説明します。
4. 「テーブル間に外部キーのリレーションシップを追加します。既存のデータを壊さずにリレーションシップを追加するにはどうすればよいですか?」
→ このスキルでは、安全な外部キーのパターンを説明します。カラムを追加 → データを入力 → インデックスを追加します(SQLite の制限についても説明します)。
5. 「前回のアップデート後にクラッシュが発生するとユーザーから報告されています。マイグレーションを変更しましたが、アプリはすでに本番環境にあります。どうすればよいですか?」
→ このスキルでは、マイグレーションはリリース後に変更できないことを説明し、古いものを変更するのではなく、新しいマイグレーションを作成して問題を修正する方法を示します。
⛔ 絶対に行ってはいけないこと(データ損失のリスク)
これらの操作は本番環境のユーザーデータを破壊します
❌ ユーザーデータのあるテーブルに DROP TABLE を絶対に使用しないでください ❌ リリース済みのマイグレーションを絶対に変更しないでください(代わりに新しいものを作成します) ❌ スキーマ変更のためにテーブルを絶対に再作成しないでください(データが失われます) ❌ DEFAULT 値なしで NOT NULL カラムを絶対に追加しないでください ❌ カラムを絶対に削除しないでください(SQLite は安全な DROP COLUMN をサポートしていません)
これらのいずれかを行いたくなったら、いったん止めて、以下の安全なパターンを使用してください。
必須ルール
必ず次のルールに従ってください
- 追加のみ 新しいカラムやテーブルを追加し、決して削除しないでください
- べき等性 作成前に存在を確認してください(2 回実行しても安全です)
- トランザクション マイグレーション全体を単一のトランザクションで囲んでください
- 両方のパスをテスト 新規インストールと、以前のバージョンからのマイグレーションの両方をテストしてください
- 最初は 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 べき等性(マイグレーションを 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 の前)
- デバイスに 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 | 親行が存在するか確認するか、一時的に FK を無効にします |
no such column: columnName | カラムを作成するマイグレーションを追加します |
cannot add NOT NULL column | 最初は NULL 許容カラムを使用し、別のマイグレーションでバックフィルします |
table tableName already exists | IF 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 ステータス:安全なスキーマ進化のための、本番環境対応パターン