Migracja bazy danych sal

Aby uwzględnić te zmiany, podczas dodawania i modyfikowania funkcji w aplikacji musisz modyfikować klasy encji i bieżące tabele bazy danych. Gdy aktualizacja aplikacji zmienia schemat bazy danych, ważne jest zachowanie danych użytkownika, które są już w bazie danych na urządzeniu.

W przypadku pokoi obsługiwane są zarówno automatyczne, jak i ręczne opcje migracji przyrostowej. 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.

Migracje automatyczne

Aby zadeklarować automatyczną migrację między 2 wersjami bazy danych, dodaj do właściwości autoMigrations w @Database adnotację @AutoMigration:

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 usługa Room wykryje niejednoznaczne zmiany schematu i nie może wygenerować planu migracji bez dodatkowych danych wejściowych, wystąpi błąd podczas kompilacji i prosi o wdrożenie AutoMigrationSpec. Dzieje się tak najczęściej wtedy, gdy migracja obejmuje jedną z tych sytuacji:

  • usunięcie tabeli lub zmianę jej nazwy,
  • usunięcie kolumny lub zmianę jej nazwy,

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

Aby użyć implementacji 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ć jeszcze więcej pracy, możesz zaimplementować onPostMigrate(). Jeśli wdrożysz tę metodę w swoim obiekcie AutoMigrationSpec, sala wywoła ją po zakończeniu automatycznej migracji.

Migracje ręczne

W przypadku, gdy migracja obejmuje złożone zmiany schematu, automatyczne wygenerowanie odpowiedniej ścieżki migracji przez pokój może nie być możliwe. Jeśli np. zdecydujesz się podzielić dane w tabeli na 2 tabele, pokój nie będzie wiedzieć, jak przeprowadzić ten podział. 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, zastępując metodę 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 definiowania ścieżek migracji możesz używać migracji automatycznych w przypadku niektórych wersji i migracji ręcznych w przypadku innych. Jeśli dla tej samej wersji zdefiniujesz zarówno migrację automatyczną, jak i ręczną, usługa w pokoju przeprowadzi migrację ręczną.

Migracje testowe

Migracje są często złożone i nieprawidłowo zdefiniowana migracja może spowodować awarię aplikacji. Przetestuj migrację, aby zachować stabilność aplikacji. W pokoju dostępny jest artefakt Maven room-testing, który ułatwia testowanie migracji zarówno automatycznych, jak i ręcznych. Aby ten artefakt działał, musisz najpierw wyeksportować schemat bazy danych.

Eksportuj schematy

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

Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle

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

Odlotowe

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 określić różne lokalizacje, korzystając wielokrotnie z konfiguracji schemaDirectory(), przy czym w każdej z nich pierwszym argumentem jest 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 wersje. Możesz też dodać parametr schemaDirectory() bez parametru variantMatchName, aby obsługiwać warianty, których nie pasują do żadnej z pozostałych konfiguracji. Na przykład w aplikacji z 2 rodzajami kompilacji demo i full oraz 2 typami kompilacji debug i release prawidłowe konfiguracje:

Odlotowe

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 korzystasz z wersji 2.5.2 lub starszej pokoju oraz jeśli 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 niektórych zadaniach Gradle. Aby zapewnić poprawność i wydajność kompilacji przyrostowych i zapisanych w pamięci podręcznej, musisz użyć CommandLineArgumentProvider Gradle do poinformowania Gradle o tym katalogu.

Najpierw skopiuj podaną 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 KAPT i javac, zmień tę wartość na -Aroom.schemaLocation=${schemaDir.path}.

Odlotowe

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:

Odlotowe

// 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 do zależności testowych artefakt Maven androidx.room:room-testing z pokoju i dodaj lokalizację wyeksportowanego schematu jako folder zasobów:

build.gradle

Odlotowe

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 implementuje również interfejs JUnit4 TestRule, dzięki czemu może zarządzać utworzonymi bazami danych.

Ten przykład przedstawia 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

Mimo że można przetestować pojedynczą migrację przyrostową, zalecamy wykonanie testu obejmującego wszystkie migracje zdefiniowane dla bazy danych aplikacji. Daje to pewność, że nie ma rozbieżności między niedawno utworzoną instancją bazy danych a starszą instancją, która korzysta ze zdefiniowanych ścieżek migracji.

Poniższy przykład przedstawia test dla 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};
}

Bezpłatna 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, wystąpi błąd IllegalStateException. Jeśli można zachować istniejące dane w przypadku braku ścieżki migracji, podczas tworzenia bazy danych wywołaj metodę kompilacji fallbackToDestructiveMigration():

Kotlin

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

Java

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

Ta metoda informuje pokój, że w momencie, gdy nie ma zdefiniowanej ścieżki migracji, konieczne jest niszczycielskie odtworzenie tabel w bazie danych aplikacji.

Jeśli chcesz, aby pokój powrócił w określonych sytuacjach do destrukcyjnej rozrywki, masz kilka innych rozwiązań 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 wskazuje, że pokoje mają wrócić do destrukcyjnego trybu przywracania tylko podczas migracji z określonych wersji.
  • Jeśli chcesz, aby pokój przełączał się na niszczycielską odtwarzanie tylko podczas migracji z wyższej wersji bazy danych do starszej, użyj metody fallbackToDestructiveMigrationOnDowngrade().

Przejście na pokój 2.2.0 obsługuje wartości domyślne kolumn

W pokoju 2.2.0 lub nowszym możesz zdefiniować wartość domyślną kolumny, korzystając z adnotacji @ColumnInfo(defaultValue = "..."). W wersjach starszych niż 2.2.0 jedynym sposobem zdefiniowania wartości domyślnej kolumny jest bezpośrednie zdefiniowanie jej bezpośrednio w wykonanej instrukcji SQL. Powoduje to utworzenie wartości domyślnej, której sala nie jest świadoma. Oznacza to, że jeśli baza danych została pierwotnie utworzona w Pomieszczeniu w wersji starszej niż 2.2.0, uaktualnienie aplikacji do wersji 2.2.0 może wymagać utworzenia specjalnej ścieżki migracji dla istniejących wartości domyślnych zdefiniowanych bez użycia interfejsów API pokoju.

Załóżmy na przykład, ż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 definiuje ś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 między aktualizacjami a nowymi instalacjami aplikacji. Wartość domyślna w kolumnie tag jest zadeklarowana tylko w ścieżce migracji z wersji 1 do wersji 2, więc użytkownicy, którzy instalują aplikację od wersji 2, nie mają domyślnej wartości tag w schemacie bazy danych.

W wersjach 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, by zawierała domyślną wartość dla tag przy użyciu adnotacji @ColumnInfo, sala może sprawdzić tę rozbieżność. Powoduje to niepowodzenie weryfikacji schematu.

Aby zapewnić spójność schematu bazy danych u wszystkich użytkowników przy zadeklarowaniu domyślnych wartości kolumn we wcześniejszych ścieżkach migracji, przy pierwszym uaktualnieniu 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ę usuwania i ponownego tworzenia, która pozwala dodać niezbędne wartości domyślne do istniejących kolumn.

Następujący 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");
    }
};