Migrer votre base de données Room

Lorsque vous ajoutez ou modifiez des fonctionnalités dans votre application, vous devez modifier vos classes d'entités Room et les tables de base de données sous-jacentes afin de refléter ces modifications. Il est important de conserver les données utilisateur déjà présentes dans la base de données sur l'appareil lorsqu'une mise à jour d'application modifie le schéma de la base de données.

Room prend en charge les options automatisées et manuelles pour une migration incrémentielle. Les migrations automatiques fonctionnent pour la plupart des modifications de schéma de base, mais vous devrez peut-être définir manuellement les chemins de migration pour des modifications plus complexes.

Migrations automatisées

Pour déclarer une migration automatisée entre deux versions de base de données, ajoutez une annotation @AutoMigration à la propriété autoMigrations dans @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 {
  ...
}

Spécifications de la migration automatique

Si Room détecte des modifications de schéma ambiguës et qu'il ne parvient pas à générer de plan de migration sans informations supplémentaires, il génère une erreur au moment de la compilation et vous demande d'implémenter une AutoMigrationSpec. Cela se produit généralement lorsqu'une migration implique l'une des opérations suivantes :

  • Supprimer ou renommer une table
  • Supprimer ou renommer une colonne

Vous pouvez utiliser AutoMigrationSpec pour fournir les informations supplémentaires dont Room a besoin pour générer correctement les chemins de migration. Définissez une classe statique qui implémente AutoMigrationSpec dans votre classe RoomDatabase, puis annotez-la avec une ou plusieurs des annotations suivantes :

Si vous souhaitez utiliser l'implémentation de AutoMigrationSpec pour une migration automatisée, définissez la propriété spec dans l'annotation @AutoMigration correspondante :

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

Si votre application doit effectuer plus de travail une fois la migration automatisée terminée, vous pouvez implémenter onPostMigrate(). Si vous implémentez cette méthode dans votre AutoMigrationSpec, Room l'appelle une fois la migration automatisée terminée.

Migrations manuelles

Si une migration implique des modifications de schéma complexes, il se peut que Room ne soit pas en mesure de générer automatiquement un chemin de migration approprié. Par exemple, si vous décidez de diviser les données d'une table en deux, Room ne peut pas déterminer comment effectuer cette division. Dans de tels cas, vous devez définir manuellement un chemin de migration en implémentant une classe Migration.

Une classe Migration définit explicitement un chemin de migration entre une startVersion et une endVersion en remplaçant la méthode Migration.migrate(). Ajoutez vos classes Migration à votre compilateur de base de données à l'aide de la méthode 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();

Lorsque vous définissez des chemins de migration, vous pouvez utiliser des migrations automatisées pour certaines versions et des migrations manuelles pour d'autres. Si vous définissez à la fois une migration automatisée et une migration manuelle pour la même version, Room utilise la migration manuelle.

Tester les migrations

Les migrations sont souvent complexes. Une migration définie de manière incorrecte peut entraîner le plantage de votre application. Pour préserver la stabilité de votre application, testez vos migrations. Room fournit un artefact Maven room-testing pour faciliter le processus de test des migrations automatisées et manuelles. Pour que cet artefact fonctionne, vous devez d'abord exporter le schéma de votre base de données.

Exporter des schémas

Room peut exporter les informations de schéma de votre base de données dans un fichier JSON au moment de la compilation. Pour exporter le schéma, définissez l'argument du processeur d'annotations room.schemaLocation à l'aide d'un CommandLineArgumentProvider dans le fichier app/build.gradle.

Commencez par définir le CommandLineArgumentProvider qui fournit le room.schemaLocation au processeur, comme illustré dans l'exemple suivant. Notez que la méthode asArguments() de l'exemple de classe transmet -Aroom.schemaLocation=${schemaDir.path} à javac ou KAPT. Si vous utilisez KSP, vous devez remplacer cet élément par room.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 KSP, change the line below to return
    // ["room.schemaLocation=${schemaDir.path}".toString()].
    return ["-Aroom.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 KSP, change the line below to return
    // listOf("room.schemaLocation=${schemaDir.path}").
    return listOf("-Aroom.schemaLocation=${schemaDir.path}")
  }
}

Configurez ensuite les options de compilation pour utiliser RoomSchemaArgProvider avec le répertoire de schéma spécifié, en vous assurant d'abord qu'il existe :

Groovy

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

Les fichiers JSON exportés représentent l'historique des schémas de votre base de données. Stockez ces fichiers dans votre système de contrôle des versions afin que Room puisse créer d'anciennes versions de la base de données à des fins de test et pour permettre la génération des migrations automatiques.

Tester une seule migration

Avant de pouvoir tester vos migrations, ajoutez l'artefact Maven androidx.room:room-testing de Room à vos dépendances de test, puis ajoutez l'emplacement du schéma exporté en tant que dossier d'éléments :

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

Le package de test fournit une classe MigrationTestHelper, qui peut lire les fichiers de schéma exportés. Le package implémente également l'interface TestRule de JUnit4 pour lui permettre de gérer les bases de données créées.

L'exemple suivant présente un test pour une seule migration :

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

Tester toutes les migrations

Bien qu'il soit possible de tester une seule migration incrémentielle, nous vous recommandons d'inclure un test qui couvre toutes les migrations définies pour la base de données de votre application. Cela garantit qu'il n'y a pas de différence entre une instance de base de données récemment créée et une instance plus ancienne qui a suivi les chemins de migration définis.

L'exemple suivant présente un test pour toutes les migrations définies :

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

Gérer correctement les chemins de migration manquants

Si Room ne parvient pas à trouver un chemin de migration pour mettre à niveau une base de données existante sur un appareil, une IllegalStateException se produit. S'il est acceptable de perdre des données existantes lorsqu'un chemin de migration est manquant, appelez la méthode de compilateur fallbackToDestructiveMigration() lorsque vous créez la base de données :

Kotlin

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

Java

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

Cette méthode indique à Room de recréer de manière destructive les tables de la base de données de votre application lorsqu'une migration incrémentielle est nécessaire en l'absence de chemin de migration défini.

Si vous souhaitez que Room ait uniquement recours à la recréation destructive dans certaines situations, il existe quelques alternatives à fallbackToDestructiveMigration() :

  • Si des versions spécifiques de votre historique des schémas génèrent des erreurs que vous ne pouvez pas résoudre avec les chemins de migration, utilisez plutôt fallbackToDestructiveMigrationFrom(). Cette méthode indique que vous souhaitez que Room ait uniquement recours à la recréation destructive lors de la migration à partir de versions spécifiques.
  • Si vous souhaitez que Room ait uniquement recours à la recréation destructive lors de la migration d'une version de base de données ultérieure vers une version antérieure, utilisez plutôt fallbackToDestructiveMigrationOnDowngrade().

Gérer les valeurs par défaut des colonnes lors de la mise à niveau vers Room 2.2.0

À partir de la version 2.2.0, vous pouvez définir une valeur par défaut pour une colonne à l'aide de l'annotation @ColumnInfo(defaultValue = "..."). Dans les versions antérieures à la version 2.2.0, le seul moyen de définir une valeur par défaut pour une colonne consiste à la définir directement dans une instruction SQL exécutée, qui crée une valeur par défaut dont Room n'a pas connaissance. Cela signifie que si une base de données a initialement été créée par une version antérieure à Room 2.2.0, la mise à niveau de votre application pour utiliser Room 2.2.0 peut nécessiter un chemin de migration spécial pour les valeurs par défaut existantes que vous avez définies sans utiliser les API Room.

Par exemple, supposons que la version 1 d'une base de données définisse une entité 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;
}

Supposons également que la version 2 de la même base de données ajoute une nouvelle colonne NOT NULL et définit un chemin de migration de la version 1 vers la version 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 ''");
    }
};

Cela entraîne une différence dans la table sous-jacente entre les mises à jour et les nouvelles installations de l'appli. La valeur par défaut de la colonne tag étant uniquement déclarée dans le chemin de migration de la version 1 à la version 2, les utilisateurs qui installent l'appli à partir de la version 2 ne disposent pas de la valeur par défaut pour tag dans leur schéma de base de données.

Dans les versions antérieures à Room 2.2.0, cette différence est sans conséquence. Toutefois, si l'application est mise à niveau ultérieurement pour utiliser Room 2.2.0 ou une version ultérieure et modifie la classe d'entité Song pour inclure une valeur par défaut pour tag à l'aide de l'annotation @ColumnInfo, Room peut voir cette différence. Cela entraîne l'échec des validations de schéma.

Pour garantir la cohérence du schéma de base de données pour tous les utilisateurs lorsque des valeurs par défaut des colonnes sont déclarées dans vos chemins de migration précédents, procédez comme suit lors de la première mise à niveau de votre application pour qu'elle utilise Room 2.2.0 ou une version ultérieure :

  1. Déclarez les valeurs par défaut des colonnes dans leurs classes d'entités respectives à l'aide de l'annotation @ColumnInfo.
  2. Augmentez le numéro de version de la base de données d'une unité.
  3. Définissez un chemin de migration vers la nouvelle version qui implémente la stratégie de suppression et de recréation pour ajouter les valeurs par défaut nécessaires aux colonnes existantes.

L'exemple suivant illustre ce processus :

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