Cómo migrar bases de datos de Room

A medida que agregas y cambias funciones en tu app, debes modificar tus clases de entidades para reflejar estos cambios. Cuando un usuario actualiza a la versión más reciente de tu app, no quieres que pierda todos sus datos existentes, especialmente si no puedes recuperarlos desde un servidor remoto.

La biblioteca de persistencias Room te permite escribir clases Migration para conservar los datos del usuario. Cada clase Migration especifica startVersion y endVersion. En el tiempo de ejecución, Room ejecuta el método migrate() de cada clase Migration, utilizando el orden correcto para migrar la base de datos a una versión posterior.

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 tu lógica de migración funcionando como se espera, usa consultas completas en lugar de hacer referencia a constantes que las representen.

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

Prueba las migraciones

Las migraciones no son fáciles de escribir; si no lo haces correctamente, podría ocurrir un bloqueo en tu app. Para preservar la estabilidad de tu app, deberías probar tus migraciones de antemano. Room proporciona un artefacto Maven de prueba que ayuda con este proceso. Sin embargo, para que este artefacto funcione, debes exportar el esquema de tu base de datos.

Exporta los esquemas

Después de la compilación, Room exporta la información del esquema de tu base de datos en un archivo JSON. Para exportar el esquema, configura la propiedad del procesador de anotaciones room.schemaLocation en tu archivo build.gradle, como se muestra en el siguiente fragmento de código:

build.gradle

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

Deberías almacenar los archivos JSON exportados (que representan el historial de esquemas de tu base de datos) en tu sistema de control de versiones, ya que le permiten a Room crear versiones anteriores de tu base de datos con fines de prueba.

Para probar estas migraciones, agrega el artefacto Maven android.arch.persistence.room:testing de Room a tus dependencias de prueba y agrega la ubicación del esquema como carpeta de activos, como se muestra en el siguiente fragmento de código:

build.gradle

    android {
        ...
        sourceSets {
            androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
        }
    }
    

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

En el siguiente fragmento de código, aparece una prueba de migración de ejemplo:

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

Prueba todas las migraciones

En el ejemplo anterior, se mostraba cómo probar una migración incremental, de una versión a otra. Pero se recomienda realizar una prueba que procese todas las migraciones. Este tipo de prueba es útil para detectar cualquier discrepancia creada por una base de datos que pasó por la ruta de migración en comparación con una que se creó recientemente.

En el siguiente fragmento de código, se muestra un ejemplo de prueba de todas las migraciones:

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

Resuelve de manera óptima las rutas de migración que faltan

Después de actualizar los esquemas de tu base de datos, es posible que algunas bases de datos del dispositivo aún puedan usar una versión de esquema anterior. Si Room no puede encontrar una regla de migración para actualizar la base de datos de ese dispositivo de la versión anterior a la versión actual, se produce una excepción IllegalStateException.

Para evitar que la app falle ante esta situación, llama al método del generador 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();
    

Cuando incluyes esta cláusula en la lógica de compilación de la base de datos de tu app, le dices a Room que recree destructivamente las tablas de la base de datos de tu app en los casos en los que falta la ruta de migración entre las versiones del esquema.

La lógica de resguardo de recreación destructiva incluye las siguientes opciones adicionales:

  • Si se producen errores en versiones específicas de tu historial de esquemas que no puedes resolver con rutas de migración, usa fallbackToDestructiveMigrationFrom(). Este método indica que deseas que Room use la lógica de resguardo solo en los casos en que la base de datos intente hacer la migración desde una de esas versiones problemáticas.
  • Para realizar una recreación destructiva solo cuando se intente una degradación de esquema, usa fallbackToDestructiveMigrationOnDowngrade().

Controla el valor predeterminado de las columnas cuando actualices a Room 2.2.0

Room 2.2.0 agregó compatibilidad con la definición de un valor predeterminado de columna mediante @ColumnInfo(defaultValue = "..."). El valor predeterminado de una columna es una parte importante del esquema de la base de datos y tu entidad, y se valida mediante Room durante una migración. Si tu base de datos ya había sido creada por una versión de Room anterior a 2.2.0, es posible que tengas que proporcionar una migración para los valores predeterminados agregados previamente desconocidos para Room.

Por ejemplo, en una versión 1 de una base de datos, hay una entidad Song declarada como tal:

Entidad Song, versión 1 de la base de datos, Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
    }
    
Para una versión 2 de la misma base de datos, se agrega una nueva columna NOT NULL:

Entidad Song, versión 2 de la base de datos, Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String,
        val tag: String // added in version 2
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
        @NonNull
        final String tag; // added in version 2
    }
    
Junto con una migración de la versión 1 a la 2:

Migración de 1 a 2, Room 2.1.0

Kotlin

    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

    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 ''");
        }
    };
    
Este tipo de migración es inofensivo en las versiones de Room anteriores a 2.2.0, pero causará problemas una vez que Room se actualice y se agregue un valor predeterminado mediante @ColumnInfo a la misma columna. Con ALTER TABLE, la entidad Song cambia no solo para contener la nueva columna tag, sino también para contener un valor predeterminado. Sin embargo, las versiones de Room anteriores a 2.2.0 no identifican esos cambios, lo que resulta en una falta de coincidencia de esquema entre un usuario de la aplicación que la instaló recientemente frente a un usuario que migró de la versión 1 a la 2. Específicamente, una base de datos creada recientemente en la versión 2 no contendrá el valor predeterminado.

En esa situación, se debe proporcionar una migración para que el esquema de la base de datos sea coherente entre los usuarios de la aplicación, ya que Room 2.2.0 ahora validará los valores predeterminados una vez que estén definidos en la clase de la entidad. El tipo de migración requerida implica lo siguiente:

  • Declarar el valor predeterminado en la clase de la entidad mediante @ColumnInfo
  • Aumentar la versión de la base de datos de a una
  • Proporcionar una migración que implemente la estrategia de soltar y volver a crear (permite agregar un valor predeterminado a una columna ya creada)

En el siguiente fragmento de código, se muestra un ejemplo de implementación de migración que suelta y vuelve a crear la tabla Song:

Migración de 2 a 3, Room 2.2.0

Kotlin

    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

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