Memigrasikan database Room

Tetap teratur dengan koleksi Simpan dan kategorikan konten berdasarkan preferensi Anda.

Saat menambahkan dan mengubah fitur dalam aplikasi, Anda harus mengubah class entity Room dan tabel database pokok untuk mencerminkan perubahan ini. Penting untuk menyimpan data pengguna yang sudah ada dalam database pada perangkat saat update aplikasi mengubah skema database.

Room mendukung opsi otomatis dan manual untuk migrasi inkremental. Migrasi otomatis berfungsi untuk sebagian besar perubahan skema dasar, tetapi Anda mungkin perlu menentukan jalur migrasi secara manual untuk perubahan yang lebih kompleks.

Migrasi otomatis

Untuk mendeklarasikan migrasi otomatis antara dua versi database, tambahkan anotasi @AutoMigration ke properti autoMigrations di @Database:

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

Spesifikasi migrasi otomatis

Jika Room mendeteksi adanya perubahan skema yang ambigu dan tidak dapat menghasilkan paket migrasi tanpa input lain, Room akan menampilkan error waktu kompilasi dan meminta Anda untuk mengimplementasikan AutoMigrationSpec. Masalah ini paling sering terjadi saat migrasi melibatkan salah satu dari hal berikut:

  • Menghapus atau mengganti nama tabel.
  • Menghapus atau mengganti nama kolom.

Anda dapat menggunakan AutoMigrationSpec untuk memberi informasi tambahan yang diperlukan Room untuk membuat jalur migrasi dengan benar. Tentukan class statis yang mengimplementasikan AutoMigrationSpec di class RoomDatabase Anda, lalu menganotasinya dengan satu atau beberapa hal berikut:

Agar dapat menggunakan implementasi AutoMigrationSpec untuk migrasi otomatis, tetapkan properti spec dalam anotasi @AutoMigration yang sesuai:

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

  ...
}

Jika aplikasi perlu melakukan lebih banyak hal setelah migrasi otomatis selesai, Anda dapat mengimplementasikan onPostMigrate(). Jika Anda mengimplementasikan metode ini di AutoMigrationSpec, Room akan memanggilnya setelah migrasi otomatis selesai.

Migrasi manual

Jika migrasi melibatkan perubahan skema yang kompleks, Room mungkin tidak dapat menghasilkan jalur migrasi yang sesuai secara otomatis. Misalnya, jika Anda memutuskan membagi data dalam tabel menjadi dua tabel, Room tidak dapat mengetahui cara semestinya untuk melakukan pembagian ini. Dalam kasus seperti ini, Anda harus menentukan jalur migrasi secara manual dengan mengimplementasikan class Migration.

Setiap class Migration secara eksplisit menentukan jalur migrasi antara startVersion dan endVersion dengan mengganti metode Migration.migrate(). Tambahkan class Migration yang telah ditentukan ke builder database menggunakan metode addMigrations():

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

Saat menentukan jalur migrasi, Anda dapat menggunakan migrasi otomatis untuk sebagian versi dan juga migrasi manual untuk sebagian versi lainnya. Jika Anda menentukan migrasi otomatis dan migrasi manual untuk versi yang sama, Room akan memilih menggunakan migrasi manual.

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 membantu proses pengujian migrasi otomatis dan manual. 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

Groovy

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

Kotlin

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
            }
        }
    }
}

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

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

Kotlin

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

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

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"

    @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 {
            // 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)

    @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 will validate 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 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, anggap saja versi 1 database mendefinisikan entity 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 jalur 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 entity 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 entity 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");
    }
};