Migracja bazy danych sal

Gdy dodajesz funkcje do aplikacji i je modyfikujesz, musisz odpowiednio zmodyfikować klasy obiektów Room i podstawowe tabele bazy danych. Gdy aktualizacja aplikacji zmienia schemat bazy danych, ważne jest, aby zachować dane użytkownika, które są już w bazie danych na urządzeniu.

Room obsługuje zarówno opcje automatyczne, jak i ręczne do stopniowej migracji. Automatyczne migracje działają w przypadku większości podstawowych zmian schematu, ale w przypadku bardziej złożonych zmian może być konieczne ręczne zdefiniowanie ścieżek migracji.

Automatyczne migracje

Aby zadeklarować automatyczną migrację między 2 wersjami bazy danych, dodaj adnotację @AutoMigration do właściwości autoMigrations w pliku @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 {
  ...
}

Specyfikacje automatycznej migracji

Jeśli Room wykryje niejednoznaczne zmiany schematu i nie będzie można wygenerować planu migracji bez dodatkowych danych wejściowych, pojawi się błąd w czasie kompilacji i zostanie wyświetlony komunikat z prośbą o wdrożenie AutoMigrationSpec. Najczęściej dzieje się tak, gdy migracja obejmuje jedną z tych czynności:

  • usuwanie tabeli lub zmiana jej nazwy.
  • usuwanie lub zmiana nazwy kolumny.

Możesz użyć AutoMigrationSpec, aby przekazać Room dodatkowe informacje, których potrzebuje do prawidłowego wygenerowania ścieżek migracji. Zdefiniuj stałą klasę, która implementuje AutoMigrationSpec w klasie RoomDatabase, i opatrz ją co najmniej jednym z tych elementów:

Aby zastosować implementację AutoMigrationSpec do automatycznej migracji, ustaw właściwość spec w odpowiedniej adnotacji @AutoMigration:

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

Jeśli po zakończeniu automatycznej migracji Twoja aplikacja musi wykonać więcej pracy, możesz wdrożyć onPostMigrate(). Jeśli wdrożysz tę metodę w AutoMigrationSpec, Room wywoła ją po zakończeniu automatycznej migracji.

Migracje ręczne

W przypadku migracji, która wymaga złożonych zmian schematu, Room może nie być w stanie automatycznie wygenerować odpowiedniej ścieżki migracji. Jeśli np. zdecydujesz się podzielić dane w tabeli na 2 tabele, Room nie będzie wiedzieć, jak tego dokonać. W takich przypadkach musisz ręcznie zdefiniować ścieżkę migracji, wdrażając klasę Migration.

Klasa Migration wyraźnie definiuje ścieżkę migracji między klasą startVersion a klasą endVersion przez zastąpienie metody Migration.migrate(). Dodaj klasy Migration do kreatora bazy danych za pomocą metody 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();

Podczas definiowania ścieżek migracji możesz użyć migracji automatycznej w przypadku niektórych wersji, a w przypadku innych – migracji ręcznej. Jeśli zdefiniujesz automatyczną migrację i ręczną migrację tej samej wersji, Room użyje ręcznej migracji.

Testowanie migracji

Migracje są często skomplikowane, a nieprawidłowo zdefiniowana migracja może spowodować awarię aplikacji. Aby zachować stabilność aplikacji, przetestuj migracje. Room udostępnia artefakt Mavena room-testing, który ułatwia proces testowania zarówno migracji automatycznej, jak i ręcznej. Aby ten element działał, musisz najpierw wyeksportować schemat bazy danych.

Eksportowanie schematów

W czasie kompilacji Room może wyeksportować informacje o schemacie bazy danych do pliku JSON. Wyeksportowane pliki JSON reprezentują historię schematu bazy danych. Przechowuj te pliki w systemie kontroli wersji, aby Room mógł tworzyć starsze wersje bazy danych na potrzeby testowania i generowania automatycznej migracji.

Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle

Jeśli używasz Room w wersji 2.6.0 lub nowszej, możesz zastosować wtyczkę Gradle Room i użyć rozszerzenia room, aby określić katalog schematu.

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

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

Jeśli schemat bazy danych różni się w zależności od wersji, odmiany lub typu kompilacji, musisz określić różne lokalizacje, używając konfiguracji schemaDirectory() kilka razy, przy czym każdy z nich musi mieć jako pierwszy argument variantMatchName. Każda konfiguracja może pasować do co najmniej 1 wariantu na podstawie prostego porównania z nazwą wariantu.

Upewnij się, że są one wyczerpujące i obejmują wszystkie odmiany. Możesz też uwzględnić schemaDirectory() bez variantMatchName, aby obsłużyć warianty, które nie pasują do żadnej z innych konfiguracji. Na przykład w przypadku aplikacji z 2 wersjami demofull oraz 2 typami wersji debugrelease prawidłowe są te konfiguracje:

Groovy

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

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

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

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

Kotlin

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

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

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

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

Ustawianie lokalizacji schematu za pomocą opcji procesora adnotacji

Jeśli używasz wersji Room 2.5.2 lub starszej albo nie używasz wtyczki Room Gradle, ustaw lokalizację schematu za pomocą opcji procesora adnotacji room.schemaLocation.

Pliki w tym katalogu są używane jako dane wejściowe i wyjściowe w przypadku niektórych zadań Gradle. Aby zapewnić poprawność i wydajność kompilacji przyrostowych i kompilacji z wykorzystaniem pamięci podręcznej, musisz użyć opcji CommandLineArgumentProvider w Gradle, aby poinformować Gradle o tym katalogu.

Najpierw skopiuj klasę RoomSchemaArgProvider pokazaną poniżej do pliku build.gradle modułu. Metoda asArguments() w próbnej klasie przekazuje zmienną room.schemaLocation=${schemaDir.path} do zmiennej KSP. Jeśli używasz wartości KAPTjavac, zmień tę wartość na -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}")
  }
}

Następnie skonfiguruj opcje kompilacji, aby używać RoomSchemaArgProvider z określonym katalogiem schematu:

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

Testowanie pojedynczej migracji

Zanim przetestujesz migracje, dodaj artefakt Maven z Room do zależności testowych i dodaj lokalizację wyeksportowanego schematu jako folder zasobów:androidx.room:room-testing

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

Pakiet testów zawiera klasę MigrationTestHelper, która może odczytywać wyeksportowane pliki schematu. Pakiet implementuje też interfejs JUnit4 TestRule, aby można było zarządzać utworzonymi bazami danych.

Ten przykład pokazuje test pojedynczej migracji:

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

Testowanie wszystkich migracji

Chociaż można przetestować pojedynczą migrację przyrostową, zalecamy uwzględnienie testu obejmującego wszystkie migracje zdefiniowane dla bazy danych aplikacji. Dzięki temu nie będzie rozbieżności między utworzoną niedawno instancją bazy danych a starszą instancją, która przeszła określone ścieżki migracji.

Ten przykład pokazuje test wszystkich zdefiniowanych migracji:

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

Obsługa brakujących ścieżek migracji

Jeśli Room nie znajdzie ścieżki migracji, która pozwoliłaby zaktualizować istniejącą bazę danych na urządzeniu do bieżącej wersji, wystąpi błąd IllegalStateException. Jeśli akceptujesz utratę dotychczasowych danych, gdy brakuje ścieżki migracji, podczas tworzenia bazy danych wywołaj metodę fallbackToDestructiveMigration() w budującym:

Kotlin

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

Java

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

Ta metoda informuje Room, aby w przypadku konieczności wykonania przyrostowej migracji bez zdefiniowanej ścieżki migracji ponownie utworzyć tabele w bazie danych aplikacji.

Jeśli chcesz, aby Room używał metody destruktywnej tylko w określonych sytuacjach, możesz użyć jednej z tych alternatyw dla fallbackToDestructiveMigration():

  • Jeśli konkretne wersje historii schematu powodują błędy, których nie możesz rozwiązać za pomocą ścieżek migracji, użyj zamiast tego ścieżki fallbackToDestructiveMigrationFrom(). Ta metoda wskazuje, że chcesz, aby Room stosował metodę odtwarzania z zastąpieniem tylko podczas migracji z określonych wersji.
  • Jeśli chcesz, aby Room stosował odtwarzanie destruktywne tylko podczas migracji z wyższej wersji bazy danych na niższą, użyj zamiast tego opcji fallbackToDestructiveMigrationOnDowngrade().

Obsługa wartości domyślnych kolumn podczas aktualizacji do wersji Room 2.2.0

W wersji Room 2.2.0 lub nowszej możesz zdefiniować domyślną wartość kolumny za pomocą adnotacji @ColumnInfo(defaultValue = "..."). W wersjach starszych niż 2.2.0 jedynym sposobem zdefiniowania wartości domyślnej kolumny jest zdefiniowanie jej bezpośrednio w wykonywanym poleceniu SQL, co powoduje utworzenie wartości domyślnej, o której Room nie wie. Oznacza to, że jeśli baza danych została pierwotnie utworzona przez wersję Room niższą niż 2.2.0, uaktualnienie aplikacji do wersji Room 2.2.0 może wymagać podania specjalnej ścieżki migracji dla dotychczasowych wartości domyślnych zdefiniowanych bez użycia interfejsów Room API.

Załóżmy na przykład, że w wersji 1 bazy danych zdefiniowano element 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;
}

Załóżmy też, że wersja 2 tej samej bazy danych zawiera nową kolumnę NOT NULL i określa ścieżkę migracji z wersji 1 do wersji 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 ''");
    }
};

Powoduje to rozbieżność w tabeli podstawowej między aktualizacjami a nowymi instalacjami aplikacji. Ponieważ domyślna wartość kolumny tag jest deklarowana tylko na ścieżce migracji z wersji 1 na wersję 2, użytkownicy, którzy zainstalowali aplikację od wersji 2, nie mają domyślnej wartości tag w schemacie bazy danych.

W wersjach Room niższych niż 2.2.0 ta rozbieżność jest nieszkodliwa. Jeśli jednak aplikacja zostanie później zaktualizowana do wersji Room 2.2.0 lub nowszej i zmieni klasę encji Song, aby uwzględnić wartość domyślną dla tag za pomocą adnotacji @ColumnInfo, Room może zauważyć tę rozbieżność. Powoduje to nieudane walidacje schematu.

Aby zapewnić spójność schematu bazy danych dla wszystkich użytkowników, gdy wartości domyślne kolumn są deklarowane w danych wcześniejszych ścieżkach migracji, podczas pierwszego uaktualniania aplikacji do wersji Room 2.2.0 lub nowszej wykonaj te czynności:

  1. Zadeklaruj domyślne wartości kolumn w odpowiednich klasach jednostek za pomocą adnotacji @ColumnInfo.
  2. Zwiększ numer wersji bazy danych o 1.
  3. Określ ścieżkę migracji do nowej wersji, która wdraża strategię usuwania i ponownego tworzenia, aby dodać do dotychczasowych kolumn niezbędne wartości domyślne.

Ten proces ilustruje poniższy przykład:

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