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 anotación @AutoMigration a la propiedad autoMigrations en @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 {
  ...
}

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 te solicitará que implementes un AutoMigrationSpec. 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 estática que implemente AutoMigrationSpec en tu clase RoomDatabase y agrega una o más de las siguientes opciones de anotación:

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

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 la app necesita realizar más trabajo una vez que se completa la migración automática, puedes implementar onPostMigrate(). Si implementas este método en tu AutoMigrationSpec, Room lo 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 sabrá cómo realizar esta división. En casos como estos, debes definir manualmente una ruta de migración mediante la implementación de una clase Migration.

Cada clase Migration define de forma explícita una ruta de migración entre una startVersion y una endVersion anulando el método Migration.migrate(). Agrega las clases Migration definidas al compilador de bases de datos mediante el método 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();

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 room-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 puede exportar 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. Almacena estos archivos en tu sistema de control de versión para que Room pueda crear versiones anteriores de la base de datos con fines de prueba y habilitar la generación de migraciones automáticas.

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

Si usas Room 2.6.0 o una versión posterior, puedes aplicar el complemento de Room para Gradle y usar la extensión room para especificar el directorio del esquema.

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

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

Si el esquema de tu base de datos difiere en función de la variante, la variante o el tipo de compilación, debes especificar distintas ubicaciones mediante la configuración de schemaDirectory() varias veces, cada una con un variantMatchName como primer argumento. Cada configuración puede coincidir con una o más variantes según una 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 variantes de compilación demo y full, y dos tipos de compilación debug y release, las siguientes son configuraciones válidas:

Groovy

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

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

Si usas la versión 2.5.2 o una anterior de Room, o 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.

Los archivos de este directorio se usan como entradas y salidas para algunas tareas de Gradle. Para garantizar la precisión y el rendimiento de las compilaciones incrementales y almacenadas en caché, debes usar CommandLineArgumentProvider de Gradle a fin de informar a Gradle sobre este directorio.

Primero, copia la clase RoomSchemaArgProvider que se muestra a continuación en el archivo de compilación de Gradle de tu módulo. El método 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() {
    // 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}")
  }
}

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

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

Cómo probar una sola migración

Para poder probar las migraciones, debes agregar el artefacto Maven androidx.room:room-testing de Room a tus dependencias de prueba y la ubicación del esquema exportado como una carpeta de elementos:

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

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

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

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

Cómo probar todas las migraciones

Aunque es posible probar una sola migración incremental, 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:

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

Cómo resolver de manera óptima la falta de rutas de migración

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 una IllegalStateException. Si no es un problema perder datos existentes cuando falta una ruta de migración, llama al método de compilador fallbackToDestructiveMigration() cuando crees la base de datos:

Kotlin

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

Java

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

Este método le indica a Room 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.

Si solo quieres que Room recurra a la recreación destructiva en determinadas situaciones, existen algunas 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.

Cómo controlar el valor predeterminado de la columna al actualizar a Room 2.2.0

En Room 2.2.0 y versiones posteriores, puedes definir un valor predeterminado para una columna con la anotación @ColumnInfo(defaultValue = "..."). En las versiones anteriores a la 2.2.0, la única manera de definir un valor predeterminado para una columna es definirlo directamente en una instrucción de SQL ejecutada, lo cual crea un valor predeterminado que Room no conoce. Esto significa que, si una versión de Room anterior a la 2.2.0 crea una base de datos, cuando actualices la app a la versión 2.2.0, es posible que debas proporcionar una ruta de migración especial para los valores predeterminados existentes que definiste sin usar las APIs de Room.

Por ejemplo, supongamos que la versión 1 de una base de datos define una entidad 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;
}

Supongamos también que la versión 2 de la misma base de datos agrega una columna NOT NULL nueva y define una ruta de migración de la versión 1 a la versión 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 ''");
    }
};

Esto genera una discrepancia en la tabla subyacente entre las actualizaciones y las instalaciones nuevas de la app. Como el valor predeterminado de la columna tag solo se declara en la ruta de migración de la versión 1 a la 2, los usuarios que instalaron la app a partir de la versión 2 no tienen el valor predeterminado de tag en el esquema de la base de datos.

En las versiones de Room anteriores a la 2.2.0, esta discrepancia no genera problemas. Sin embargo, si luego se actualiza la app a Room 2.2.0 o una versión posterior, y cambia la clase de entidad Song para incluir un valor predeterminado para tag con la anotación @ColumnInfo, entonces, Room podrá ver esa discrepancia, lo que generará validaciones de esquema fallidas.

Para asegurarte de que no haya discrepancias en el esquema de la base para todos los usuarios cuando se declaren los valores predeterminados de la columna en tus rutas de migración anteriores, haz lo siguiente la primera vez que actualices tu app para usar Room 2.2.0 o una versión posterior:

  1. Declara valores predeterminados de columna en sus respectivas clases de entidad mediante la anotación @ColumnInfo.
  2. Aumenta el número de versión de la base de datos en uno.
  3. Define una ruta de migración a la nueva versión, que implemente la estrategia para soltar y recrear, para agregar los valores predeterminados necesarios a las columnas existentes.

En el siguiente ejemplo, se muestra este proceso:

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