Migracja bazy danych sal

Gdy dodajesz i zmieniasz funkcje w aplikacji, musisz odpowiednio zmodyfikować klasy encji pokoju i podstawowe tabele bazy danych, aby odzwierciedlić te zmiany. Gdy aktualizacja aplikacji zmieni schemat bazy danych, należy zachować dane użytkownika, które są już w bazie danych na urządzeniu.

Sala obsługuje zarówno automatyczne, jak i ręczne opcje migracji przyrostowej. Migracje automatyczne sprawdzają się 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.

Zautomatyzowane migracje

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

Jeśli funkcja Room wykryje niejednoznaczne zmiany schematu i nie będzie w stanie wygenerować planu migracji bez dodatkowych danych wejściowych, zgłosi błąd podczas kompilacji i poprosi o implementację AutoMigrationSpec. Najczęściej dzieje się tak, gdy migracja obejmuje jeden z tych warunków:

  • usunięcie tabeli lub zmodyfikowanie jej nazwy;
  • usunięcie kolumny lub zmiana jej nazwy;

Za pomocą właściwości AutoMigrationSpec możesz przekazać do sali dodatkowe informacje potrzebne do prawidłowego wygenerowania ścieżek migracji. Zdefiniuj klasę statyczną, która implementuje element AutoMigrationSpec w klasie RoomDatabase, i dodaj do niej adnotacje z co najmniej jednym z tych elementów:

Aby użyć implementacji AutoMigrationSpec w przypadku migracji automatycznej, 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 wymaga więcej pracy, możesz wdrożyć metodę onPostMigrate(). Jeśli wdrożysz tę metodę w AutoMigrationSpec, usługa Room wywołuje ją po zakończeniu automatycznej migracji.

Migracje ręczne

Jeśli migracja obejmuje złożone zmiany schematu, automatyczne wygenerowanie odpowiedniej ścieżki migracji może nie być możliwe. Jeśli np. zdecydujesz się podzielić dane w tabeli na 2 tabele, sala nie będzie mogła określić, jak dokonać takiego podziału. W takich przypadkach musisz ręcznie zdefiniować ścieżkę migracji, implementując klasę Migration.

Klasa Migration wyraźnie określa ścieżkę migracji między startVersion a endVersion przez zastąpienie metody Migration.migrate(). Dodaj klasy Migration do kreatora baz 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 określania ścieżek migracji możesz używać migracji automatycznych w przypadku niektórych wersji, a w przypadku innych migracji ręcznych. Jeśli zdefiniujesz migrację automatyczną i ręczną dla tej samej wersji, usługa Room będzie używać migracji ręcznej.

Migracje testowe

Migracje są często złożone, a nieprawidłowo zdefiniowana migracja może spowodować awarię aplikacji. Aby zachować stabilność aplikacji, przetestuj migracje. W sali znajduje się artefakt Maven (room-testing), który ułatwia testowanie zarówno migracji automatycznych, jak i ręcznych. Aby ten artefakt działał, musisz najpierw wyeksportować schemat swojej bazy danych.

Eksportuj schematy

Usługa Room może wyeksportować informacje o schemacie bazy danych do pliku JSON podczas kompilacji. Wyeksportowane pliki JSON reprezentują historię schematu bazy danych. Przechowuj te pliki w systemie kontroli wersji, aby usługa Room mogła tworzyć niższe wersje bazy danych na potrzeby testów i umożliwić automatyczne generowanie 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ę Room Gradle i określić katalog schematu za pomocą rozszerzenia room.

Odlotowy

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 wariantu, rodzaju lub typu kompilacji, musisz wielokrotnie określić różne lokalizacje, korzystając z konfiguracji schemaDirectory() kilka razy, podając variantMatchName jako pierwszy argument. Każda konfiguracja może pasować do 1 lub kilku wariantów na podstawie prostego porównania z nazwą wariantu.

Sprawdź, czy są one wyczerpujące i obejmują wszystkie wersje. Możesz też uwzględnić właściwość schemaDirectory() bez variantMatchName, aby obsługiwać warianty, które nie pasują do żadnej innej konfiguracji. Na przykład w przypadku aplikacji o 2 rodzajach kompilacji demo i full oraz 2 typach kompilacji debug i release prawidłowe konfiguracje są następujące:

Odlotowy

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

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

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

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

Kotlin

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

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

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

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

Ustaw lokalizację schematu za pomocą opcji procesora adnotacji

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

Pliki w tym katalogu są używane jako dane wejściowe i wyjściowe w niektórych zadaniach Gradle. Aby uzyskać poprawność i wydajność kompilacji przyrostowych i zapisanych w pamięci podręcznej, musisz użyć metody CommandLineArgumentProvider Gradle, aby poinformować Gradle o tym katalogu.

Najpierw skopiuj pokazaną poniżej klasę RoomSchemaArgProvider do pliku kompilacji Gradle modułu. Metoda asArguments() w przykładowej klasie przekazuje room.schemaLocation=${schemaDir.path} do KSP. Jeśli używasz opcji KAPT i javac, zmień tę wartość na -Aroom.schemaLocation=${schemaDir.path}.

Odlotowy

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ć polecenia RoomSchemaArgProvider z określonym katalogiem schematu:

Odlotowy

// 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 usługi androidx.room:room-testing z usługi Room do zależności testów i dodaj lokalizację wyeksportowanego schematu jako folder zasobów:

Build.gradle

Odlotowy

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 testowy zawiera klasę MigrationTestHelper, która może odczytywać wyeksportowane pliki schematu. Pakiet zawiera również interfejs JUnit4 TestRule, który umożliwia zarządzanie utworzonymi bazami danych.

Poniższy przykład ilustruje 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.
    }
}

Przetestuj wszystkie migracje

Chociaż można przetestować pojedynczą migrację przyrostową, zalecamy dołączenie testu obejmującego wszystkie migracje zdefiniowane w bazie danych aplikacji. Dzięki temu nie będzie rozbieżności między niedawno utworzoną instancją bazy danych a starszą instancją, która podąża za zdefiniowanymi ścieżkami migracji.

W poniższym przykładzie pokazano test obejmujący wszystkie zdefiniowane migracje:

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

Bezproblemowa obsługa brakujących ścieżek migracji

Jeśli sala nie może znaleźć ścieżki migracji umożliwiającej uaktualnienie istniejącej bazy danych na urządzeniu do bieżącej wersji, pojawia się IllegalStateException. Jeśli utrata istniejących danych i brak ścieżki migracji jest akceptowalna, podczas tworzenia bazy danych wywołaj metodę kreatora fallbackToDestructiveMigration():

Kotlin

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

Java

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

Ta metoda umożliwia usłudze Room niszczycielskie odtworzenie tabel w bazie danych aplikacji, gdy konieczna jest przyrostowa migrację i nie ma zdefiniowanej ścieżki migracji.

Jeśli w określonych sytuacjach chcesz, aby pokoje były odtwarzane tylko w sposób niszczycielski, masz kilka możliwości niż fallbackToDestructiveMigration():

  • Jeśli określone wersje historii schematu powodują błędy, których nie można rozwiązać za pomocą ścieżek migracji, użyj metody fallbackToDestructiveMigrationFrom(). Ta metoda oznacza, że w przypadku migracji z określonych wersji chcesz, aby pokój służył do niszczycielskiej rekreacji.
  • Jeśli chcesz, aby usługa Pokój cofała się do niszczycielskiej wersji tylko podczas migracji z wyższej wersji bazy danych do niższej, użyj parametru fallbackToDestructiveMigrationOnDowngrade().

Obsługuj domyślne wartości kolumn podczas przechodzenia na pokój 2.2.0

W pokoju w wersji 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 wykonanej instrukcji SQL. Spowoduje to utworzenie wartości domyślnej, której nie zna sala. Oznacza to, że jeśli baza danych została pierwotnie utworzona przy użyciu wersji Room starszej niż 2.2.0, uaktualnienie aplikacji do wersji Room 2.2.0 może wymagać podania specjalnej ścieżki migracji istniejących wartości domyślnych zdefiniowanych przez Ciebie bez korzystania z interfejsów Room API.

Załóżmy np., że wersja 1 bazy danych definiuje encję 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 dodaje nową kolumnę NOT NULL i określa ścieżkę migracji z wersji 1 do 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 bazowej między aktualizacjami i nowymi instalacjami aplikacji. Wartość domyślna kolumny tag jest zadeklarowana tylko w ścieżce migracji z wersji 1 do 2, więc użytkownicy, którzy zainstalują aplikację od wersji 2, nie mają w schemacie bazy danych domyślnej wartości parametru tag.

W przypadku wersji Pokoju starszych niż 2.2.0 ta rozbieżność jest nieszkodliwa. Jeśli jednak później aplikacja przejdzie na korzystanie z pokoju w wersji 2.2.0 lub nowszej i zmieni klasę encji Song tak, aby zawierała wartość domyślną dla tag za pomocą adnotacji @ColumnInfo, w pokoju będzie widoczna rozbieżność. Powoduje to błędy weryfikacji schematu.

Aby schemat bazy danych był spójny u wszystkich użytkowników po zadeklarowaniu domyślnych wartości kolumn we wcześniejszych ścieżkach migracji, podczas pierwszego uaktualnienia aplikacji do korzystania z pokoju 2.2.0 lub nowszego wykonaj te czynności:

  1. Zadeklaruj domyślne wartości kolumn w odpowiednich klasach encji za pomocą adnotacji @ColumnInfo.
  2. Zwiększ numer wersji bazy danych o 1.
  3. Zdefiniuj ścieżkę migracji do nowej wersji, która implementuje strategię upuszczania i odtwarzania treści, aby dodać niezbędne wartości domyślne do istniejących kolumn.

Poniższy przykład ilustruje ten proces:

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