Cómo migrar tu base de datos de Room

A medida que agregas y cambias funciones de tu app, debes modificar las clases de entidad de Room y las tablas de base de datos subyacentes para reflejar esos cambios. Es importante conservar los datos del usuario que ya están en la base de datos del dispositivo cuando una actualización de la app cambia el esquema de la base de datos.

Room admite opciones manuales y automáticas para la migración incremental. Las migraciones automáticas funcionan para la mayoría de los cambios de esquema básicos, pero es posible que debas definir rutas de migración de forma manual si se requieren cambios más complejos.

Migraciones automáticas

Para declarar una migración automática entre dos versiones de base de datos, agrega una @AutoMigration anotación a la autoMigrations propiedad en @Database:

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabaseV1 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration(from = 1, to = 2)
  ]
)
abstract class AppDatabaseV2 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

Especificaciones de la migración automática

Si Room detecta cambios de esquema ambiguos y no puede generar un plan de migración sin más entradas, arrojará un error de tiempo de compilación y deberás proporcionar una AutoMigrationSpec implementación. Por lo general, esto ocurre cuando una migración involucra una de las siguientes opciones:

  • Borrar una tabla o cambiarle el nombre
  • Borrar una columna o cambiarle el nombre

Puedes usar AutoMigrationSpec para darle a Room la información adicional que necesita para generar correctamente rutas de migración. Define una clase que implemente AutoMigrationSpec en tu clase RoomDatabase y anótala con una o más de las siguientes opciones:

Si deseas usar la implementación de AutoMigrationSpec para una migración automatizada, configura la propiedad spec en la anotación @AutoMigration correspondiente:

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = MigrationSpec1To2::class
    )
  ]
)
abstract class AppDatabaseWithSpec : RoomDatabase() {
  abstract fun userDao(): UserDao
}

@RenameTable(fromTableName = "User", toTableName = "AppUser")
internal class MigrationSpec1To2 : AutoMigrationSpec

Si tu app necesita hacer más trabajo después de que se complete la migración automática, puedes implementar onPostMigrate. Si implementas esta función en tu AutoMigrationSpec, Room la llamará después de que se complete la migración automática.

Migraciones manuales

En los casos en los que una migración implica cambios de esquema complejos, es posible que Room no pueda generar automáticamente una ruta de migración adecuada. Por ejemplo, si decides dividir los datos de una tabla en dos, Room no podrá determinar el modo en que se debe realizar esta división. En casos como estos, debes definir manualmente una ruta de migración mediante la implementación de una Migration clase.

Una clase Migration define de forma explícita una ruta de migración entre una startVersion y una endVersion anulando la función migrate. Agrega tus Migration clases al compilador de bases de datos mediante la addMigrations función:

val MIGRATION_1_2 = object : Migration(1, 2) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder<ManualMigrationDatabase>(applicationContext, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
  .build()

Cuando defines las rutas de migración, puedes usar las migraciones automáticas para algunas versiones y las manuales en otras. Si defines una migración automática y una manual para la misma versión, Room usará la manual.

Cómo probar migraciones

Las migraciones suelen ser complejas, y una migración definida de forma incorrecta puede provocar que falle tu app. Para preservar la estabilidad de tu app, debes probar las migraciones. Room proporciona un artefacto Maven room3-testing para ayudar en el proceso de prueba de las migraciones automáticas y manuales. Para que este artefacto funcione, primero debes exportar el esquema de la base de datos.

Cómo exportar esquemas

Room exporta la información del esquema de la base de datos a un archivo JSON durante el tiempo de compilación. Los archivos JSON exportados representan el historial de esquemas de la base de datos. Debes almacenar estos archivos en tu sistema de control de versión para poder volver a crear versiones anteriores de la base de datos para realizar pruebas y admitir la generación de migraciones automáticas.

Cómo establecer la ubicación del esquema con el complemento de Gradle de Room

Para especificar el directorio del esquema, aplica el complemento de Gradle de Room y usa la room3 extensión.

Groovy

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

Si el esquema de tu base de datos difiere según la variante, el tipo de producto o el tipo de compilación, debes especificar diferentes ubicaciones usando la configuración schemaDirectory varias veces, cada una con un variantMatchName como primer argumento. Cada configuración puede coincidir con una o más variantes según la comparación simple con el nombre de la variante.

Asegúrate de que sean exhaustivas y abarquen todas las variantes. También puedes incluir un schemaDirectory() sin un variantMatchName para controlar las variantes que no coinciden con ninguna de las otras configuraciones. Por ejemplo, en una app con dos tipos de compilación demo y full, y dos tipos de compilación debug y release, las siguientes son configuraciones válidas:

Groovy

room3 {
  // 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

room3 {
  // 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")
}

Cómo establecer la ubicación del esquema con la opción del procesador de anotaciones

Si no usas el complemento de Gradle de Room, establece la ubicación del esquema con la opción del procesador de anotaciones room.schemaLocation.

Gradle usa archivos en este directorio como entradas y salidas para algunas tareas de Gradle. Para la corrección y el rendimiento de las compilaciones incrementales y almacenadas en caché, debes usar Gradle's CommandLineArgumentProvider para informar a Gradle sobre este directorio.

Primero, copia la siguiente clase RoomSchemaArgProvider en el archivo de compilación de Gradle de tu módulo. La función asArguments de la clase de muestra pasa room.schemaLocation=${schemaDir.path} a KSP. Si usas KAPT y javac, cambia este valor a -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() {
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

Luego, configura las opciones de compilación para usar RoomSchemaArgProvider con el directorio del esquema especificado:

Groovy

ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

Cómo probar una sola migración

Para poder probar las migraciones, debes agregar el artefacto androidx.room3:room3-testing a tus dependencias de prueba y la ubicación del esquema exportado como un directorio de elementos:

Groovy

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room3:room3-testing:3.0.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room3:room3-testing:3.0.1")
}

El paquete de prueba proporciona una clase MigrationTestHelper, que puede leer archivos de esquema exportados. El paquete también implementa la interfaz JUnit4 TestRule para administrar las bases de datos creadas.

En el siguiente ejemplo, se muestra una prueba para una sola migración:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    @get:Rule
    val helper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrate1To2() = runTest {
        val connection = helper.createDatabase(1)
        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        connection.execSQL("INSERT INTO User (id, name) VALUES (1, 'John Doe')")
        connection.close()

        // Re-open the database with version 2 and provide MIGRATION_1_2
        val migratedConnection = helper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2))

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
        val hasData = migratedConnection.prepare("SELECT COUNT(*) FROM User").use {
          it.step()
          it.getLong(0) > 0
        }
        assertTrue("Expected data was not migrated", hasData)
        migratedConnection.close()
    }
}

Cómo probar todas las migraciones

Aunque es posible probar una migración incremental única, se recomienda que incluyas una prueba que abarque todas las migraciones definidas para la base de datos de tu app. Esto garantiza que no haya discrepancias entre una instancia de base de datos creada recientemente y una instancia anterior que siguió las rutas de migración definidas.

En el siguiente ejemplo, se muestra una prueba para todas las migraciones definidas:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrateAll() = runTest {
        // Create earliest version of the database.
        val connection = helper.createDatabase(1)
        connection.close()

        // Create latest version of the database.
        val db = Room.databaseBuilder<AppDatabase>(instrumentation.targetContext, TEST_DB)
          .setDriver(AndroidSQLiteDriver())
          .addMigrations(*ALL_MIGRATIONS)
          .build()
        // Open the database, Room validates the schema once all migrations
        // execute.
        db.useReaderConnection { connection ->
          // Perform additional validation
        }

        db.close()
    }
}

Cómo resolver de manera óptima las rutas de migración que faltan

Si Room no puede encontrar una ruta de migración para actualizar una base de datos existente en un dispositivo a la versión actual, se produce un IllegalStateException. Si no es un problema perder datos existentes cuando falta una ruta de migración, llama a la función de compilador fallbackToDestructiveMigration cuando crees la base de datos:

Room.databaseBuilder<FallbackMigrationDatabase>(applicationContext, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Esta función configura Room para que vuelva a crear las tablas de la base de datos en tu app de manera destructiva cuando necesite realizar una migración incremental sin una ruta de migración definida.

Para recurrir a la recreación destructiva solo en ciertas situaciones, usa una de las siguientes alternativas para fallbackToDestructiveMigration:

  • Si las versiones específicas de tu historial de esquema causan errores que no puedes resolver con las rutas de migración, usa fallbackToDestructiveMigrationFrom en su lugar. Ese método indica que quieres recurrir a la recreación destructiva solo cuando migras desde versiones específicas.
  • Si deseas que Room recurra a la recreación destructiva solo cuando migres de una versión de base de datos posterior a una anterior, usa fallbackToDestructiveMigrationOnDowngrade en su lugar.