Google se compromete a impulsar la igualdad racial para las comunidades afrodescendientes. Obtén información al respecto.

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 para reflejar esos cambios. Sin embargo, 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.

La biblioteca de persistencia de Room admite migraciones incrementales con las clases Migration para satisfacer esa necesidad. Cada subclase de Migration define una ruta de migración entre una startVersion y una endVersion anulando el método Migration.migrate(). Cuando una actualización de la app requiere una actualización de la versión de la base de datos, Room ejecuta el método migrate() desde una o más subclases de Migration para migrar la base de datos a la versión más reciente durante el tiempo de ejecución:

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();

Precaución: Con el objetivo de mantener la lógica de migración funcionando como se espera, usa consultas completas, en lugar de hacer referencia a constantes que las representen.

Una vez finalizado el proceso de migración, Room valida el esquema para garantizar que se realice correctamente la migración. Si encuentra un problema, genera una excepción que contiene la información que no coincide.

Para obtener más información, consulta la Muestra de migración de Room en GitHub.

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 con el proceso de prueba. Sin embargo, 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

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

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

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

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"

    @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)

    @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().getTargetContext(),
                AppDatabase.class,
                TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            getOpenHelper().getWritableDatabase()
            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");
    }
};