本文へスキップ
すべての記事

2026年のRoomデータベースマイグレーション: 1行もデータを失わずにスキーマ変更を出荷する

AndroidのRoomデータベースマイグレーションに関する実践ガイド — AutoMigration、手書きのMigrationオブジェクト、そしてユーザーがバグを見つける前にマイグレーションをテストする方法。

MFKAPPS 1 分で読めます

ローカルファーストのアプリはいずれ、バージョン1では持っていなかったスキーマを必要とするようになります。新しい列、名前が変わったテーブル、かつて String だったのに Int に変える必要がある項目。それが起きた瞬間、うっかりしたアノテーション一つで、次のアップデートで全ユーザーのデータを削除してしまう距離にいます。Roomのマイグレーションはそれを防ぐために存在し、多くの人がそこで味わう痛みは、任意に見えて実はそうではない部分を省略することから来ています。

罠: fallbackToDestructiveMigration()

@Database のバージョン番号が対応するマイグレーションパスなしに上がった瞬間、Roomは IllegalStateException を投げます。あらゆるStack Overflowの回答に出てくる修正はこの一行です。

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .fallbackToDestructiveMigration()
    .build()

これはコンパイルが通り、デプロイでき、完璧に動作します — デバッグビルドで、自分の端末で、テストデータを失っても構わない場所では。本番環境では、こういう意味になります。次にスキーマバージョンを上げたとき、Roomはすべてのテーブルを削除し、空の状態で作り直します。Granyn にとってそれは、誰かの2年分の家計簿の入力が、本人が望んでもいないアプリ更新でひっそりと消えることを意味します。ダイアログもなく、警告もなく、元に戻す手段もありません。次にアプリが開かれたとき、静かに起こるだけです。このコードがデバッグ以外の設定に存在するなら、それは便利機能ではなくバグとして扱ってください。

AutoMigrationは思われているより多くをカバーする

よくあるケース — デフォルト値付きの列を追加する、テーブルを追加・削除する、列の名前を変える — であれば、Roomの @AutoMigration は2つのスキーマスナップショットからマイグレーションを自動生成できます。

@Database(
    version = 2,
    entities = [Entry::class],
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase()

これはスキーマのエクスポートを有効にしている場合(Gradle設定の room.schemaLocation)にのみ機能し、Roomが比較対象とする各バージョンのJSONスナップショットを持てるようにする必要があります。この設定を省略すると、AutoMigrationには比較するものが何もなくなり、それに気づくのは深夜2時のクラッシュレポートではなく、ビルド時になります。列の名前変更や削除については、Roomに新旧の名前を伝える小さな @RenameColumn@DeleteColumn の指定クラスも必要です — 差分だけから意図を推測することはできません。

AutoMigrationが役に立たなくなる場所

既存データの再構成が必要なもの — 一つの列を二つに分ける、保存された String の金額をセント単位の Int に変換する、新しい必須項目を他の行から埋め戻す — はAutoMigrationの範囲外です。これは回避すべき制約ではなく、本物のSQLを含む本物の Migration オブジェクトが必要だというサインです。

マイグレーションを手書きする

手書きの Migration は単に、バージョンのペアと、あなたが責任を持つSQLのブロックです。

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE entries ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'"
        )
        db.execSQL(
            "UPDATE entries SET currency = (SELECT default_currency FROM user_prefs LIMIT 1)"
        )
    }
}

データベースビルダーの .addMigrations(MIGRATION_2_3) で、あれば AutoMigration と並べて登録します。SQLiteの ALTER TABLE は制限されており — 列の削除も、既存の列の型をその場で変えることもできません — そのため列の追加を超える作業は、たいてい古典的な三段階の手順が必要になります。望む形の新しいテーブルを作り、INSERT INTO ... SELECT でデータをコピーし、古いテーブルを削除し、新しいテーブルの名前を変える。書きたいと思うより多くのSQLで、しかも正確でなければならないSQLです。なぜならそれは各端末の次回起動時に、一度だけ、静かに実行されるからです。

最終的なスキーマだけでなく、マイグレーションそのものをテストする

ここで最も多くのバグを生む間違いはSQL自体ではなく、実際に古くなったデータベースに対して一度も実行していないことです。Roomの MigrationTestHelper はまさにこのために存在します。

@get:Rule
val helper = MigrationTestHelper(
    InstrumentationRegistry.getInstrumentation(),
    AppDatabase::class.java
)

@Test
fun migrate2To3_preservesExistingRows() {
    helper.createDatabase(TEST_DB, 2).apply {
        execSQL("INSERT INTO entries (id, amount) VALUES (1, 4200)")
        close()
    }
    helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3)
}

これはバージョン2のデータベースを構築し、実際のユーザーの端末がそうであるようにデータを投入し、そのマイグレーションを実行して、結果のスキーマがRoomの期待と一致するかを検証します。本番で実際に起きる2つの失敗モードを捕捉します。空のデータベースでは動くのに行があるデータベースでは例外を投げるマイグレーションと、SQL自体は問題ないのに結果のスキーマがエンティティ定義と一致しないマイグレーション(Roomは列の順序やデフォルト値まで厳密にチェックします)です。

チェックリスト

スキーマバージョンの引き上げを出荷する前に: スキーマのエクスポートが有効になっている、すべてのバージョンの飛躍に AutoMigration または登録済みの手書き Migration のどちらかがある、fallbackToDestructiveMigration() がテストコード以外のどこにも現れない、そして少なくとも一つの MigrationTestHelper テストが実データを投入してそれが生き残ることを検証している。これらはどれも派手な仕事ではありません。しかしそれこそが、誰も気づかないスキーマ変更と、リリース翌朝に「データが消えました」で埋まるサポート受信箱との違いです。