Google は、黒人コミュニティに対する人種平等の促進に取り組んでいます。取り組みを見る

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

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

Room 永続ライブラリは、このニーズに応えるために、Migration クラスを使用した増分移行をサポートします。個々の Migration サブクラスは、Migration.migrate() メソッドをオーバーライドして、startVersion から endVersion への移行パスを定義します。アプリのアップデートでデータベース バージョンのアップグレードが必要になる場合、Room は 1 つ以上の Migration サブクラスから migrate() メソッドを実行して、実行時にデータベースを最新バージョンに移行します。

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 が問題を検出した場合、不一致の情報を含む例外がスローされます。

詳細については、GitHub の Room 移行サンプルをご覧ください。

移行をテストする

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

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

Room では、コンパイル時にデータベースのスキーマ情報を JSON ファイルにエクスポートできます。スキーマをエクスポートするには、app/build.gradle ファイル内に room.schemaLocation アノテーション プロセッサ プロパティを設定します。

build.gradle

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

エクスポートされた JSON ファイルは、データベースのスキーマ履歴を表します。このファイルはバージョン管理システムに保存してください。そうすれば、Room で古いバージョンのデータベースをテスト用に作成できます。

単一の移行をテストする

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

build.gradle

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

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

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

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

Kotlin

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

    @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 {
            // db has schema version 1. insert some data using SQL queries.
            // You cannot 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);

        // db has schema version 1. insert some data using SQL queries.
        // You cannot 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)

    @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 will validate the schema
        // once all migrations execute.
        Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            getOpenHelper().getWritableDatabase()
            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 will validate 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, DB Version 1, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song Entity, DB 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, DB 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, DB 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");
    }
};