Migracja bazy danych sal

W miarę dodawania i zmieniania funkcji w aplikacji musisz modyfikować klasy encji Room i powiązane z nimi tabele bazy danych, aby odzwierciedlały te zmiany. Ważne jest zachowanie danych użytkownika, które są już w bazie danych na urządzeniu, gdy aktualizacja aplikacji zmienia schemat bazy danych.

Room obsługuje zarówno automatyczne, jak i ręczne opcje migracji przyrostowej. Automatyczna migracja sprawdza 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.

Automatyczne migracje

Aby zadeklarować automatyczną migrację między 2 wersjami bazy danych, dodaj adnotację @AutoMigration do właściwości autoMigrations@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 biblioteka Room wykryje niejednoznaczne zmiany schematu i nie może wygenerować planu migracji bez dodatkowych danych, zgłosi błąd kompilacji i poprosi o wdrożenie AutoMigrationSpec. Najczęściej dzieje się tak, gdy migracja obejmuje jedną z tych sytuacji:

  • usuwanie lub zmienianie nazwy tabeli;
  • usuwanie kolumny lub zmiana jej nazwy;

Możesz użyć AutoMigrationSpec, aby przekazać usłudze Room dodatkowe informacje potrzebne do prawidłowego wygenerowania ścieżek migracji. Zdefiniuj klasę statyczną, która implementuje AutoMigrationSpec w klasie RoomDatabase, i dodaj do niej co najmniej jedną z tych adnotacji:

Aby użyć implementacji AutoMigrationSpec w przypadku 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 aplikacja musi wykonać więcej działań, możesz zaimplementować onPostMigrate(). Jeśli zaimplementujesz tę metodę w AutoMigrationSpec, Room wywoła ją po zakończeniu automatycznej migracji.

Migracje ręczne

W przypadku migracji obejmujących złożone zmiany schematu Room może nie być w stanie automatycznie wygenerować odpowiedniej ścieżki migracji. Jeśli na przykład zdecydujesz się podzielić dane w tabeli na 2 tabele, Room nie będzie wiedzieć, jak to zrobić. W takich przypadkach musisz ręcznie zdefiniować ścieżkę migracji, implementując klasę Migration.

Klasa Migration jawnie definiuje ścieżkę migracji między startVersionendVersion przez zastąpienie metody Migration.migrate(). Dodaj klasy Migration do narzędzia do tworzenia 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żywać automatycznej migracji w przypadku niektórych wersji, a ręcznej w przypadku innych. Jeśli dla tej samej wersji zdefiniujesz zarówno automatyczną, jak i ręczną migrację, Room użyje migracji ręcznej.

Testowanie migracji

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

Schematy eksportu

Room może wyeksportować informacje o schemacie bazy danych do pliku JSON w czasie kompilacji. 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 umożliwiać 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 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, smaku lub typu kompilacji, musisz określić różne lokalizacje, używając konfiguracji schemaDirectory() kilka razy, a jako pierwszy argument podając variantMatchName. Każda konfiguracja może pasować do co najmniej 1 wersji na podstawie prostego porównania z nazwą wersji.

Upewnij się, że są one wyczerpujące i obejmują wszystkie warianty. Możesz też dodać schemaDirectory() bez variantMatchName, aby obsługiwać wersje, które nie pasują do żadnej z pozostałych konfiguracji. Na przykład w aplikacji z 2 wersjami demofull oraz 2 typami kompilacji 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 2.5.2 lub starszej biblioteki Room albo nie korzystasz z 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ść przyrostowych i buforowanych kompilacji, musisz użyć CommandLineArgumentProvider, aby poinformować Gradle o tym katalogu.

Najpierw skopiuj klasę RoomSchemaArgProvider podaną poniżej do pliku build.gradle modułu. Metoda asArguments() w przykładowej klasie przekazuje wartość room.schemaLocation=${schemaDir.path} do metody KSP. Jeśli używasz 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 schematów:

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

Kotlin

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

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

Pakiet testowy zawiera klasę MigrationTestHelper, która może odczytywać wyeksportowane pliki schematu. Pakiet implementuje też interfejs JUnit4TestRule, dzięki czemu może zarządzać utworzonymi bazami danych.

Poniższy 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

Możesz przetestować pojedynczą migrację przyrostową, ale zalecamy przeprowadzenie testu obejmującego wszystkie migracje zdefiniowane dla bazy danych aplikacji. Dzięki temu nie będzie rozbieżności między nowo utworzoną instancją bazy danych a starszą instancją, która przeszła zdefiniowane ścieżki migracji.

Poniższy 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};
}

Eleganckie obsługiwanie brakujących ścieżek migracji

Jeśli biblioteka Room nie może znaleźć ścieżki migracji, aby uaktualnić istniejącą bazę danych na urządzeniu do bieżącej wersji, wystąpi IllegalStateException. Jeśli w przypadku braku ścieżki migracji dopuszczalna jest utrata dotychczasowych danych, podczas tworzenia bazy danych wywołaj metodę buildera 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 bibliotekę Room, że ma destrukcyjnie odtworzyć tabele w bazie danych aplikacji, gdy musi przeprowadzić migrację przyrostową, a nie ma zdefiniowanej ścieżki migracji.

Jeśli chcesz, aby funkcja Room wracała do destrukcyjnego odtwarzania tylko w określonych sytuacjach, masz kilka alternatyw dla fallbackToDestructiveMigration():

  • Jeśli określone wersje historii schematu powodują błędy, których nie możesz rozwiązać za pomocą ścieżek migracji, użyj fallbackToDestructiveMigrationFrom(). Ta metoda wskazuje, że chcesz, aby Room przywracał bazę danych tylko w przypadku migracji z określonych wersji.
  • Jeśli chcesz, aby Room wracał do destrukcyjnego odtwarzania tylko podczas migracji z nowszej wersji bazy danych do starszej, użyj zamiast tego fallbackToDestructiveMigrationOnDowngrade().

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

W przypadku biblioteki Room w wersji 2.2.0 i nowszych możesz zdefiniować wartość domyślną 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 w wersji Rooma starszej niż 2.2.0, uaktualnienie aplikacji do wersji 2.2.0 może wymagać podania specjalnej ścieżki migracji dla istniejących wartości domyślnych zdefiniowanych bez użycia interfejsów API Rooma.

Załóżmy na przykład, że wersja 1 bazy danych definiuje 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 w wersji 2 tej samej bazy danych dodano nową kolumnę NOT NULL i zdefiniowano ś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 bazowej między aktualizacjami a nowymi instalacjami aplikacji. Dzieje się tak, ponieważ wartość domyślna kolumny tag jest deklarowana tylko na ścieżce migracji z wersji 1 do wersji 2. Wszyscy użytkownicy, którzy zainstalują aplikację począwszy od wersji 2, nie będą mieć w schemacie bazy danych wartości domyślnej kolumny tag.

W wersjach biblioteki Room starszych niż 2.2.0 ta rozbieżność nie ma znaczenia. Jeśli jednak aplikacja zostanie później uaktualniona do wersji Room 2.2.0 lub nowszej, a klasa Song zostanie zmieniona tak, aby zawierała wartość domyślną dla tag przy użyciu adnotacji @ColumnInfo, Room będzie mógł wykryć tę rozbieżność. Powoduje to niepowodzenie weryfikacji schematu.

Aby mieć pewność, że schemat bazy danych jest spójny u wszystkich użytkowników, gdy w starszych ścieżkach migracji zadeklarowane są wartości domyślne kolumn, wykonaj te czynności przy pierwszej aktualizacji aplikacji do wersji Room 2.2.0 lub nowszej:

  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. Określ ścieżkę migracji do nowej wersji, która implementuje strategię usuwania i ponownego tworzenia, aby dodać niezbędne wartości domyślne do istniejących kolumn.

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