Room データベースを移行する

アプリの機能を追加または変更する場合、Room エンティティ クラスと基になるデータベース テーブルを編集して、そうした変更を反映させる必要があります。アプリのアップデートによってデータベース スキーマが変更される場合は、デバイス上のデータベースにある既存のユーザーデータを保持することが重要です。

Room は、増分移行について自動と手動の両方のオプションをサポートしています。自動移行はほとんどの基本的なスキーマ変更に対応しますが、より複雑な変更については手動で移行パスを定義する必要があります。

自動移行

2 つのデータベース バージョン間の自動移行を宣言するには、@DatabaseautoMigrations プロパティに @AutoMigration アノテーションを追加します。

Kotlin

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (from = 1, to = 2)
  ]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

Java

// Database class before the version update.
@Database(
  version = 1,
  entities = {User.class}
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = {User.class},
  autoMigrations = {
    @AutoMigration (from = 1, to = 2)
  }
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

自動移行の仕様

Room があいまいなスキーマ変更を検出し、さらに入力しないと移行計画を生成できない場合、コンパイル時エラーがスローされ、AutoMigrationSpec を実装するよう求められます。これは通常、移行に次のいずれかが伴う場合に発生します。

  • テーブルの削除または名前変更
  • 列の削除または名前変更

AutoMigrationSpec を使用すると、移行パスを正しく生成するために必要な追加情報を Room に提供できます。RoomDatabase クラスで AutoMigrationSpec を実装する静的クラスを定義し、次のうち 1 つ以上のアノテーションを付けます。

自動移行に AutoMigrationSpec 実装を使用するには、対応する @AutoMigration アノテーションで spec プロパティを設定します。

Kotlin

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  class MyAutoMigration : AutoMigrationSpec
  ...
}

Java

@Database(
  version = 2,
  entities = {AppUser.class},
  autoMigrations = {
    @AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration.class
    )
  }
)
public abstract class AppDatabase extends RoomDatabase {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  static class MyAutoMigration implements AutoMigrationSpec { }
  ...
}

自動移行の完了後にアプリでさらに作業を行う必要がある場合は、onPostMigrate() を実装できます。AutoMigrationSpec に実装すると、このメソッドは自動移行の完了後に Room によって呼び出されます。

手動移行

移行に複雑なスキーマ変更が伴う場合、Room は適切な移行パスを自動的に生成できないことがあります。たとえば、1 つのテーブルのデータを 2 つのテーブルに分割する場合、Room にはこの分割の実行方法を判断できません。このような場合は、Migration クラスを実装して移行パスを手動で定義する必要があります。

Migration クラスは、Migration.migrate() メソッドをオーバーライドして、startVersionendVersion の間の移行パスを明示的に定義します。addMigrations() メソッドを使用して、Migration クラスをデータベース ビルダーに追加します。

Kotlin

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

Java

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
      + "`name` TEXT, PRIMARY KEY(`id`))");
  }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("ALTER TABLE Book "
      + " ADD COLUMN pub_year INTEGER");
  }
};

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

移行パスを定義するとき、あるバージョンでは自動移行を使用して、他のバージョンでは手動移行を使用できます。同じバージョンに自動移行と手動移行の両方を定義した場合、Room は手動移行を使用します。

移行をテストする

移行は多くの場合複雑なので、移行が正しく定義されていないとアプリがクラッシュする可能性があります。アプリの安定性を維持するには、移行をテストします。Room には、自動移行と手動移行の両方のテストプロセスを支援する room-testing Maven アーティファクトが用意されています。このアーティファクトを機能させるには、まずデータベースのスキーマをエクスポートする必要があります。

スキーマをエクスポートする

Room では、コンパイル時にデータベースのスキーマ情報を JSON ファイルにエクスポートできます。エクスポートされた JSON ファイルは、データベースのスキーマ履歴を表します。このファイルはバージョン管理システムに保存してください。そうすれば、Room で下位のバージョンのデータベースをテスト用に作成でき、自動移行の生成が可能になります。

Room Gradle プラグインを使用してスキーマの場所を設定する

Room バージョン 2.6.0 以降を使用している場合は、Room Gradle プラグインを適用し、room 拡張機能を使用してスキーマ ディレクトリを指定できます。

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

room {
  schemaDirectory("$projectDir/schemas")
}

バリアント、フレーバー、ビルドタイプによってデータベース スキーマが異なる場合は、variantMatchName を最初の引数として、schemaDirectory() 設定を複数回使用して、別の場所を指定する必要があります。各構成は、バリアント名との単純な比較に基づいて、1 つ以上のバリアントと照合できます。

すべてのバリエーションを網羅するとともに、また、variantMatchName のない schemaDirectory() を含めると、他のどの構成にも一致しないバリアントを処理できます。たとえば、2 つのビルド フレーバー demofull と、2 つのビルドタイプ debugrelease を使用するアプリでは、次のような構成が有効です。

Groovy

room {
  // Applies to 'demoDebug' only
  schemaLocation "demoDebug", "$projectDir/schemas/demoDebug"

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation "demo", "$projectDir/schemas/demo"

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation "debug", "$projectDir/schemas/debug"

  // Applies to variants that aren't matched by other configurations.
  schemaLocation "$projectDir/schemas"
}

Kotlin

room {
  // Applies to 'demoDebug' only
  schemaLocation("demoDebug", "$projectDir/schemas/demoDebug")

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation("demo", "$projectDir/schemas/demo")

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation("debug", "$projectDir/schemas/debug")

  // Applies to variants that aren't matched by other configurations.
  schemaLocation("$projectDir/schemas")
}

アノテーション プロセッサ オプションを使用してスキーマの場所を設定する

バージョン 2.5.2 以前の Room を使用している場合、または Room Gradle プラグインを使用していない場合は、room.schemaLocation アノテーション プロセッサ オプションを使用してスキーマの場所を設定します。

このディレクトリ内のファイルは、一部の Gradle タスクの入力と出力として使用されます。 増分ビルドとキャッシュ ビルドの正確性とパフォーマンスを確保するには、Gradle の CommandLineArgumentProvider を使用して、このディレクトリについて Gradle に通知する必要があります。

まず、以下に示す RoomSchemaArgProvider クラスをモジュールの Gradle ビルドファイルにコピーします。サンプルクラスの asArguments() メソッドは、room.schemaLocation=${schemaDir.path}KSP に渡します。KAPTjavac を使用している場合は、代わりにこの値を -Aroom.schemaLocation=${schemaDir.path} に変更します。

Groovy

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    // Note: If you're using KAPT and javac, change the line below to
    // return ["-Aroom.schemaLocation=${schemaDir.path}".toString()].
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    // Note: If you're using KAPT and javac, change the line below to
    // return listOf("-Aroom.schemaLocation=${schemaDir.path}").
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

次に、指定したスキーマ ディレクトリで RoomSchemaArgProvider を使用するようにコンパイル オプションを構成します。

Groovy

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

Kotlin

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

単一の移行をテストする

移行をテストする前に、Room の androidx.room:room-testing Maven アーティファクトをテストの依存関係に追加し、エクスポートしたスキーマの場所をアセット フォルダとして追加します。

build.gradle

Groovy

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room:room-testing:2.6.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room:room-testing:2.6.1")
}

テスト パッケージには、エクスポートされたスキーマ ファイルを読み取ることができる MigrationTestHelper クラスが含まれています。このパッケージは JUnit4 TestRule インターフェースも実装しているため、作成されたデータベースを管理できます。

次の例は、単一の移行をテストする方法を示しています。

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

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

    @Test
    @Throws(IOException::class)
    fun migrate1To2() {
        var db = helper.createDatabase(TEST_DB, 1).apply {
            // Database has schema version 1. Insert some data using SQL queries.
            // You can't use DAO classes because they expect the latest schema.
            execSQL(...)

            // Prepare for the next version.
            close()
        }

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                MigrationDb.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrate1To2() throws IOException {
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        db.execSQL(...);

        // Prepare for the next version.
        db.close();

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

すべての移行をテストする

単一の増分移行をテストすることもできますが、アプリのデータベースに定義されているすべての移行をテストすることをおすすめします。そのようなテストにより、最近作成されたデータベース インスタンスと、定義済みの移行パスに沿って作成された古いインスタンスの間の不一致を解消できます。

次の例は、すべての定義済みの移行をテストする方法を示しています。

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

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

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
        // Create earliest version of the database.
        helper.createDatabase(TEST_DB, 1).apply {
            close()
        }

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.close()
        }
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                AppDatabase.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrateAll() throws IOException {
        // Create earliest version of the database.
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
        db.close();

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        AppDatabase appDb = Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB)
                .addMigrations(ALL_MIGRATIONS).build();
        appDb.getOpenHelper().getWritableDatabase();
        appDb.close();
    }

    // Array of all migrations.
    private static final Migration[] ALL_MIGRATIONS = new Migration[]{
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
}

移行パスの欠落を適切に処理する

デバイス上の既存のデータベースを現在のバージョンにアップグレードするための移行パスを Room が見つけられなかった場合、IllegalStateException が発生します。移行パスがない場合に既存のデータが失われることを許容できるのであれば、データベースの作成時に fallbackToDestructiveMigration() ビルダー メソッドを呼び出します。

Kotlin

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Java

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

このメソッドは、増分移行を実行する必要があり、移行パスが定義されていない場合に、アプリのデータベース内のテーブルを破壊的に再作成するよう Room に指示します。

特定の状況でのみ代替手段として破壊的再作成を行いたい場合は、fallbackToDestructiveMigration() の代わりとして以下の選択肢があります。

  • 移行パスで解決できないエラーの原因がスキーマ履歴の特定のバージョンにある場合は、fallbackToDestructiveMigrationFrom() を使用します。このメソッドは、特定のバージョンから移行する場合にのみ代替手段として破壊的再作成を行うよう Room に指示します。
  • データベースの上位のバージョンから下位のバージョンに移行する場合にのみ、代替手段として破壊的再作成を行うよう Room に指示するには、fallbackToDestructiveMigrationOnDowngrade() を使用します。

Room 2.2.0 にアップグレードする際の列のデフォルト値の処理

Room 2.2.0 以上では、@ColumnInfo(defaultValue = "...") アノテーションを使用して列のデフォルト値を定義できます。2.2.0 より下位のバージョンでは、列のデフォルト値を定義する唯一の方法は、実行する SQL ステートメント内で Room が認識していないデフォルト値を直接定義して作成することでした。つまり、2.2.0 より下位のバージョンの Room で作成されたデータベースでは、Room 2.2.0 を使用するようにアプリをアップグレードする場合、Room API を使用せずに定義した既存のデフォルト値に対して、特別な移行パスを用意する必要が生じることがあります。

たとえば、データベースのバージョン 1 で、次のように Song エンティティを定義したとします。

Kotlin

// Song entity, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song entity, database version 1, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
}

次に、同じデータベースのバージョン 2 で、新しい NOT NULL 列を追加し、バージョン 1 からバージョン 2 への移行パスを定義したとします。

Kotlin

// Song entity, database version 2, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String,
    val tag: String // Added in version 2.
)

// Migration from 1 to 2, Room 2.1.0.
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''")
    }
}

Java

// Song entity, database version 2, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
    @NonNull
    final String tag; // Added in version 2.
}


// Migration from 1 to 2, Room 2.1.0.
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''");
    }
};

これにより、アプリの更新版と新規インストールの間で、基盤となるテーブルに不一致が生じます。tag 列のデフォルト値はバージョン 1 からバージョン 2 への移行パスでのみ宣言されているため、ユーザーがバージョン 2 以降のアプリをインストールした場合、データベース スキーマには tag のデフォルト値が存在しません。

Room のバージョンが 2.2.0 より前であれば、この不一致は特に問題にはなりません。しかし、その後アプリが Room 2.2.0 以上を使用するようにアップグレードされ、Song エンティティ クラスが @ColumnInfo アノテーションを使用して tag のデフォルト値を含むように変更されると、Room はこの不一致を認識できるようになります。その結果、スキーマ検証が失敗します。

以前の移行パスで列のデフォルト値が宣言されている場合に、データベース スキーマがすべてのユーザー間で矛盾しないようにするには、アプリをアップグレードして Room 2.2.0 以上を初めて使用する際に、次の手順を実施します。

  1. @ColumnInfo アノテーションを使用して、列のデフォルト値をそれぞれのエンティティ クラスで宣言します。
  2. データベースのバージョン番号を 1 つ上げます。
  3. 「削除と再作成」戦略を実装する新しいバージョンへの移行パスを定義して、必要なデフォルト値を既存の列に追加します。

次の例は、このプロセスを示しています。

Kotlin

// Migration from 2 to 3, Room 2.2.0.
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
                CREATE TABLE new_Song (
                    id INTEGER PRIMARY KEY NOT NULL,
                    name TEXT,
                    tag TEXT NOT NULL DEFAULT ''
                )
                """.trimIndent())
        database.execSQL("""
                INSERT INTO new_Song (id, name, tag)
                SELECT id, name, tag FROM Song
                """.trimIndent())
        database.execSQL("DROP TABLE Song")
        database.execSQL("ALTER TABLE new_Song RENAME TO Song")
    }
}

Java

// Migration from 2 to 3, Room 2.2.0.
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE new_Song (" +
                "id INTEGER PRIMARY KEY NOT NULL," +
                "name TEXT," +
                "tag TEXT NOT NULL DEFAULT '')");
        database.execSQL("INSERT INTO new_Song (id, name, tag) " +
                "SELECT id, name, tag FROM Song");
        database.execSQL("DROP TABLE Song");
        database.execSQL("ALTER TABLE new_Song RENAME TO Song");
    }
};