Cómo migrar bases 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 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 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.

Prueba las 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. Para exportar el esquema, establece la propiedad room.schemaLocation del procesador de anotaciones en el archivo app/build.gradle:

build.gradle

Groovy

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

Kotlin

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
            }
        }
    }
}

Los archivos JSON exportados representan el historial de esquemas de la base de datos. Debes almacenar los archivos en tu sistema de control de versión, ya que eso permite que Room cree versiones anteriores de la base de datos con fines de prueba.

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

Kotlin

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

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

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 {
            // db has schema version 1. insert some data using SQL queries.
            // You cannot 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);

        // db has schema version 1. insert some data using SQL queries.
        // You cannot 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 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:

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 will validate 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 will validate 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 las rutas de migración que faltan

Si Room no encuentra 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 mediante 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 definirla 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 API de Room.

Por ejemplo, supongamos que la versión 1 de una base de datos define una entidad Song:

Kotlin

// Song Entity, DB Version 1, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song Entity, DB 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, DB 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, DB 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 a fin de incluir un valor predeterminado para tag con la anotación @ColumnInfo, entonces, Room podrá ver esa discrepancia, lo que generará validaciones de esquema fallidas.

A fin de asegurarte de que el esquema de la base de datos sea coherente 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, a fin de 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");
    }
};