Memigrasikan database Room

Saat menambahkan dan mengubah fitur dalam aplikasi, Anda perlu memodifikasi class entitas Room untuk mencerminkan perubahan ini. Namun, penting untuk menyimpan data pengguna yang sudah ada dalam database pada perangkat saat update aplikasi mengubah skema database.

Library tetap Room mendukung migrasi inkremental dengan class Migration untuk memenuhi kebutuhan ini. Setiap subclass Migration menentukan jalur migrasi antara startVersion dan endVersion dengan mengganti metode Migration.migrate(). Saat update aplikasi memerlukan upgrade versi database, Room menjalankan metode migrate() dari satu atau beberapa subclass Migration untuk memigrasikan database ke versi terbaru pada waktu proses:

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();

Perhatian: Agar logika migrasi tetap berfungsi seperti yang diharapkan, Anda perlu menggunakan kueri penuh, bukan mereferensikan konstanta yang merepresentasikan kueri.

Setelah proses migrasi selesai, Room akan memvalidasi skema untuk memastikan bahwa migrasi berhasil. Jika menemukan masalah, Room akan menampilkan pengecualian yang berisi informasi yang tidak cocok.

Untuk informasi selengkapnya, lihat Sampel Migrasi Room di GitHub.

Menguji migrasi

Migrasi sering kali rumit, dan migrasi yang tidak ditetapkan dengan benar dapat menyebabkan aplikasi Anda error. Untuk mempertahankan stabilitas aplikasi, Anda harus menguji migrasi. Room menyediakan artefak Maven room-testing untuk mempermudah proses pengujian. Namun, agar artefak ini berfungsi dengan baik, Anda harus mengekspor skema database terlebih dahulu.

Mengekspor skema

Room dapat mengekspor informasi skema database Anda ke dalam file JSON pada waktu kompilasi. Untuk mengekspor skema, tetapkan properti pemroses anotasi room.schemaLocation dalam file app/build.gradle Anda:

build.gradle

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

File JSON yang diekspor merepresentasikan histori skema database Anda. Anda harus menyimpan file ini di sistem kontrol versi karena memungkinkan Room membuat versi lama database untuk tujuan pengujian.

Menguji satu migrasi

Sebelum menguji migrasi, Anda harus menambahkan artefak Maven androidx.room:room-testing dari Room ke dependensi pengujian Anda, lalu tambahkan lokasi skema yang diekspor sebagai folder aset:

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

Paket pengujian menyediakan class MigrationTestHelper, yang dapat membaca file skema yang diekspor. Paket ini juga mengimplementasikan antarmuka TestRule JUnit4, sehingga dapat mengelola database yang dibuat.

Contoh berikut menunjukkan pengujian untuk satu migrasi:

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.
    }
}

Menguji semua migrasi

Meskipun mungkin untuk menguji satu migrasi inkremental, sebaiknya Anda menyertakan pengujian yang mencakup semua migrasi yang ditentukan untuk database aplikasi Anda. Hal ini memastikan agar tidak ada perbedaan antara instance database yang baru dibuat dan instance lama yang mengikuti jalur migrasi yang ditentukan.

Contoh berikut menunjukkan pengujian untuk semua migrasi yang ditentukan:

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

Menangani jalur migrasi yang hilang dengan tepat

Jika Room tidak dapat menemukan jalur migrasi untuk mengupgrade database yang sudah ada di perangkat ke versi saat ini, IllegalStateException akan muncul. Jika data yang sudah ada hilang ketika jalur migrasi hilang, panggil metode builder fallbackToDestructiveMigration() saat Anda membuat database:

Kotlin

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

Java

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

Metode ini akan memberi tahu Room untuk membuat ulang tabel secara destruktif di database aplikasi Anda ketika perlu melakukan migrasi inkremental jika tidak ada jalur migrasi yang ditentukan.

Jika Anda hanya ingin Room menggunakan mode pembuatan ulang yang destruktif dalam situasi tertentu, ada beberapa alternatif untuk fallbackToDestructiveMigration():

  • Jika versi tertentu histori skema menyebabkan error yang tidak dapat Anda atasi dengan jalur migrasi, gunakan fallbackToDestructiveMigrationFrom() sebagai gantinya. Metode ini menunjukkan bahwa Anda ingin agar Room menggunakan mode pembuatan ulang yang destruktif hanya saat melakukan migrasi dari versi tertentu.
  • Jika Anda ingin Room menggunakan mode pembuatan ulang yang destruktif hanya saat melakukan migrasi dari versi database yang lebih baru ke versi yang lebih lama, gunakan fallbackToDestructiveMigrationOnDowngrade() sebagai gantinya.

Menangani nilai default kolom saat upgrade ke Room 2.2.0

Di Room 2.2.0 dan versi yang lebih tinggi, Anda dapat menentukan nilai default untuk kolom menggunakan anotasi @ColumnInfo(defaultValue = "..."). Pada versi yang lebih lama dari 2.2.0, satu-satunya cara untuk menentukan nilai default untuk kolom adalah dengan menentukannya secara langsung dalam pernyataan SQL yang dijalankan, yang menghasilkan nilai default yang tidak diketahui oleh Room. Hal ini berarti bahwa jika database awalnya dibuat oleh versi Room sebelum 2.2.0, melakukan upgrade aplikasi untuk menggunakan Room 2.2.0 mungkin memerlukan jalur migrasi khusus untuk nilai default yang sudah ada, yang Anda tetapkan tanpa menggunakan Room API.

Misalnya, versi 1 database mendefinisikan entitas 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;
}

Misalkan versi 2 dari database yang sama menambahkan kolom NOT NULL baru dan menentukan lokasi migrasi dari versi 1 ke versi 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 ''");
    }
};

Hal ini menyebabkan perbedaan dalam tabel yang mendasari antara update dan penginstalan aplikasi yang baru. Karena nilai default untuk kolom tag hanya dideklarasikan di jalur migrasi dari versi 1 hingga versi 2, setiap pengguna yang menginstal aplikasi mulai dari versi 2 tidak memiliki nilai default untuk tag dalam skema databasenya.

Pada versi Room yang lebih lama dari 2.2.0, perbedaan ini tidak berbahaya. Namun, jika kemudian aplikasi diupgrade untuk menggunakan Room 2.2.0 atau versi yang lebih tinggi dan mengubah class entitas Song agar menyertakan nilai default untuk tag menggunakan anotasi @ColumnInfo, Room kini dapat melihat perbedaan ini. Hal ini menyebabkan kegagalan validasi skema.

Untuk memastikan skema database konsisten di semua pengguna ketika nilai default kolom dideklarasikan dalam jalur migrasi sebelumnya, lakukan hal berikut saat pertama kali Anda mengupgrade aplikasi untuk menggunakan Room versi 2.2.0 atau versi yang lebih baru:

  1. Deklarasikan nilai default kolom di class entitas masing-masing menggunakan anotasi @ColumnInfo.
  2. Tingkatkan nomor versi database satu tingkat.
  3. Tentukan jalur migrasi ke versi baru yang mengimplementasikan strategi penurunan dan pembuatan ulang untuk menambahkan nilai default yang diperlukan ke kolom yang ada.

Contoh berikut menunjukkan proses ini:

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