Android 11 デベロッパー プレビュー 2 が公開されました。ぜひお試しのうえ、フィードバックをお寄せください

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

アプリ内の機能の追加や変更を行う場合、そのような変更を反映するようにエンティティ クラスを編集する必要があります。各ユーザーがアプリを最新バージョンに更新する際、リモート サーバーからデータを復元できない場合は特に、既存のデータをすべて残しておいた方が安全です。

Room 永続ライブラリでは、Migration クラスを記述することで、上記のようにユーザーデータを保持することができます。各 Migration クラスは、startVersionendVersion を指定します。Room は実行時に各 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 が問題を検出した場合、不一致情報を含む例外がスローされます。

移行をテストする

移行の記述は簡単ではなく、正しく記述しないとアプリ内にクラッシュ ループが発生することがあります。アプリの安定性を維持するには、事前に移行をテストする必要があります。Room には、このテストプロセスを支援する testing Maven アーティファクトが用意されています。ただし、このアーティファクトを機能させるには、データベースのスキーマをエクスポートする必要があります。

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

Room はコンパイル時、データベースのスキーマ情報を JSON ファイルにエクスポートします。スキーマをエクスポートするには、build.gradle ファイル内に room.schemaLocation アノテーション プロセッサ プロパティを設定します。次のコード スニペットをご覧ください。

build.gradle

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

データベースのスキーマ履歴を示すエクスポート済み JSON ファイルをバージョン管理システム内に保存する必要があります。これにより、Room は、テスト目的でデータベースの古いバージョンを作成できるようになります。

移行をテストするには、Room の android.arch.persistence.room:testing Maven アーティファクトをテスト依存関係に追加して、スキーマの場所をアセット フォルダとして追加します。次のコード スニペットをご覧ください。

build.gradle

    android {
        ...
        sourceSets {
            androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
        }
    }
    

テスト パッケージには、このようなスキーマ ファイルを読み取る 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};
    }
    

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

データベースのスキーマを更新した後も、一部のオンデバイス データベースが以前のスキーマ バージョンを使用している場合があります。そのようなデバイスのデータベースを古いバージョンから現在のバージョンにアップグレードするための移行ルールが見つからなかった場合、IllegalStateException が発生します。

この状況が発生したときにアプリがクラッシュするのを防ぐには、データベースの作成時に fallbackToDestructiveMigration() ビルダー メソッドを呼び出します。

Kotlin

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

Java

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

この句をアプリのデータベース構築ロジックに含めることで、スキーマ バージョン間の移行パスが欠落している場合にアプリのデータベース テーブルを破壊的に再作成するよう Room に指示できます。

破壊的再作成の代替ロジックとしては、次のような選択肢があります。

  • スキーマ履歴の特定のバージョンでエラーが発生し、移行パスで解決できない場合は、fallbackToDestructiveMigrationFrom() を使用します。この方法の場合、問題のあるバージョンからデータベース移行が試行された場合に限り、代替ロジックを使用するよう Room に指示できます。
  • スキーマのダウングレード時に限り破壊的再作成を実行する場合は、代わりに fallbackToDestructiveMigrationOnDowngrade() 使用します。

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

Room 2.2.0 以降、@ColumnInfo(defaultValue = "...") 経由で列のデフォルト値を定義できるようになりました。列のデフォルト値は、データベース スキーマとエンティティにとって重要な部分であり、移行中に Room によって検証されます。2.2.0 より前のバージョンの Room でデータベースを作成していた場合、追加済みのデフォルト値を新しいバージョンの Room が認識できず、デフォルト値の移行の指定が必要になることがあります。

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

Song エンティティ、DB バージョン 1、Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
    }
    
同じデータベースのバージョン 2 で、新しい NOT NULL 列を追加しました。

Song エンティティ、DB バージョン 2、Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String,
        val tag: String // added in version 2
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
        @NonNull
        final String tag; // added in version 2
    }
    
バージョン 1 からバージョン 2 への移行は次のようになります。

1 から 2 への移行、Room 2.1.0

Kotlin

    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

    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 ''");
        }
    };
    
このタイプの移行は 2.2.0 より前のバージョンの Room では無害ですが、Room がアップグレードされ、@ColumnInfo 経由のデフォルト値が同じ列に追加された場合は問題が発生します。ALTER TABLE を使用すると、Song エンティティは新しい tag 列を格納するだけでなく、デフォルト値も格納するように変更されます。ただし、2.2.0 より前のバージョンの Room は、このような変更を認識できないため、アプリを新規にインストールしたユーザーと、バージョン 1 からバージョン 2 に移行したユーザーとの間で、スキーマが一致しなくなります。具体的には、新しく作成されたバージョン 2 のデータベースに、デフォルト値が含まれなくなります。

Room 2.2.0 以降、エンティティ クラス内で定義されているデフォルト値が検証されるようになったため、上記のような状況になった場合は、データベース スキーマがアプリユーザー間で一貫するように移行を指定する必要があります。必要となる移行のタイプは次のとおりです。

  • @ColumnInfo を使用してエンティティ クラス内でデフォルト値を宣言します。
  • データベースのバージョンを 1 つ上げます。
  • すでに作成済み列にデフォルト値を追加できる削除&再作成戦略を実装する移行を指定します。

Song テーブルを削除して再作成する移行実装の例を次のコード スニペットに示します。

2 から 3 への移行、Room 2.2.0

Kotlin

    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

    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");
        }
    };