wandres.dev
ROOM · persistencia relacional

Migraciones: cambiar el esquema sin perder datos

El hash de identidad, las migraciones automáticas y sus límites, la migración manual con SQL, las especificaciones para renombrar y borrar, y por qué probar una migración con datos reales es la única verificación que cuenta.

⏱ 20 min

Una migración es la única operación de tu app que se ejecuta sobre datos que no controlas, en un dispositivo que no ves, y que si falla no admite reintento: los datos ya no están. Todo lo demás en Room se puede corregir con una actualización; una migración destructiva mal desplegada es irreversible para el usuario que la sufrió. Por eso este es el nivel donde la disciplina importa más que la técnica, y donde el esquema exportado en JSON deja de ser un artefacto burocrático para convertirse en la única fuente de verdad de lo que hay realmente en el disco de un teléfono ajeno.

🎯 Al terminar esta lección sabrás
  • Entender el hash de identidad y por qué Room se niega a abrir una base inesperada.
  • Declarar migraciones automáticas y reconocer con precisión sus límites.
  • Escribir migraciones manuales, incluida la recreación de tabla de SQLite.
  • Probar migraciones con datos reales y decidir cuándo el fallback destructivo es legítimo.

El hash de identidad: por qué Room se niega a abrir

Cada vez que compilas, Room calcula un hash a partir de la descripción completa del esquema —tablas, columnas, tipos, restricciones, índices— y lo incrusta en el código generado. Al abrir la base compara ese hash con el que la propia base guarda en su tabla interna de metadatos, y si el número de versión coincide pero el hash no, lanza una excepción en lugar de operar: prefiere que la app falle al arrancar en tu emulador a ejecutar consultas contra un esquema que no es el que el código cree. La causa habitual es haber cambiado una entidad sin subir la versión.

@Database(
    entities = [Nota::class, Etiqueta::class],
    version = 3,
    exportSchema = true,
)
abstract class AppDatabase : RoomDatabase()

El fichero JSON que exportSchema deposita en el directorio de esquemas contiene ese hash y la descripción canónica de cada versión. Debe entrar en el control de versiones. Sin el JSON de la versión anterior no hay forma fiable de saber qué había en el disco de los usuarios, ni Room puede generar migraciones automáticas, ni el test de migración tiene contra qué comparar.

⚠️
El error más caro del nivel es no versionar los esquemas

Si el JSON de una versión publicada no está en el repositorio, esa versión se ha perdido: no sabes qué columnas tenía ni con qué hash. A partir de ahí, escribir una migración correcta pasa a ser arqueología sobre dispositivos reales. Añade el directorio de esquemas al repositorio antes de publicar la versión 1, no después.

Migraciones automáticas y dónde se detienen

Room puede generar la migración él mismo comparando dos JSON consecutivos, siempre que el cambio sea inequívoco.

@Database(
    entities = [Nota::class],
    version = 2,
    autoMigrations = [AutoMigration(from = 1, to = 2)],
    exportSchema = true,
)
abstract class AppDatabase : RoomDatabase()

Añadir una tabla o una columna con valor por defecto es inequívoco: solo hay una traducción posible a SQL. Renombrar, en cambio, es ambiguo: al comparar dos esquemas, una columna renombrada es indistinguible de una borrada más otra creada, y esas interpretaciones difieren en si los datos sobreviven. Ante esa ambigüedad, Room exige que la resuelvas tú con una especificación.

@RenameColumn(tableName = "notas", fromColumnName = "cuerpo", toColumnName = "contenido")
@DeleteColumn(tableName = "notas", columnName = "obsoleta")
class MigracionUnoADos : AutoMigrationSpec

// y en la anotación:
// autoMigrations = [AutoMigration(from = 1, to = 2, spec = MigracionUnoADos::class)]

Automática sin ayuda

Añadir tabla, añadir columna con valor por defecto, añadir índice. La traducción a SQL es única.

📝

Automática con especificación

Renombrar o borrar tablas y columnas. Necesita una clase AutoMigrationSpec que desambigüe la intención.

🛠️

Solo manual

Cambiar el tipo de una columna, dividir o fusionar tablas, transformar datos existentes o poblar una columna calculada.

💡
AutoMigrationSpec también permite ejecutar SQL después

Sobrescribiendo onPostMigrate en la especificación puedes ejecutar sentencias una vez terminada la migración automática: rellenar una columna nueva a partir de otras, normalizar valores o reconstruir un índice. Es la vía para combinar la comodidad de lo automático con un retoque puntual de datos.

Migración manual: el patrón de recreación de tabla

Cuando el cambio transforma datos, la migración se escribe a mano. Room te entrega la base abierta dentro de una transacción y tú ejecutas SQL.

val MIGRACION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE notas ADD COLUMN prioridad INTEGER NOT NULL DEFAULT 0")
        db.execSQL("UPDATE notas SET prioridad = 1 WHERE archivada = 0")
    }
}

El límite de SQLite es su soporte parcial de ALTER TABLE: puede añadir columnas y renombrar, pero no cambiar el tipo de una columna ni alterar restricciones. Para eso existe el patrón canónico de cuatro pasos, que hay que ejecutar en este orden exacto.

flowchart TD
A[Version antigua en el disco] --> B[Crear tabla nueva con el esquema destino]
B --> C[Copiar datos con INSERT SELECT y transformarlos]
C --> D[Borrar la tabla antigua]
D --> E[Renombrar la nueva al nombre original]
E --> F[Recrear indices y disparadores]
F --> G{Room valida el esquema resultante}
G -->|Coincide con el JSON| H[Apertura correcta]
G -->|No coincide| I[Excepcion de migracion invalida]
style H fill:#a6e3a1,color:#11111b
style I fill:#f38ba8,color:#11111b
val MIGRACION_3_4 = object : Migration(3, 4) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE notas_nueva (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                titulo TEXT NOT NULL, contenido TEXT NOT NULL DEFAULT '',
                creada_en INTEGER NOT NULL)
        """)
        db.execSQL("""
            INSERT INTO notas_nueva (id, titulo, contenido, creada_en)
            SELECT id, titulo, COALESCE(contenido, ''), creada_en FROM notas
        """)
        db.execSQL("DROP TABLE notas")
        db.execSQL("ALTER TABLE notas_nueva RENAME TO notas")
        db.execSQL("CREATE INDEX index_notas_titulo ON notas (titulo)")
    }
}

Un detalle crítico: el esquema que dejes debe coincidir exactamente con el que Room espera, índices incluidos y con sus nombres canónicos. Room valida la estructura tras migrar, y una diferencia mínima en un índice aborta la apertura. El JSON exportado de la versión destino es la referencia para copiar esos nombres.

Probarlas: la única verificación que cuenta

Una migración que compila no demuestra nada. La biblioteca de tests de migración permite crear una base con el esquema de la versión antigua, poblarla con datos representativos, ejecutar la migración y validar el resultado.

@get:Rule
val helper = MigrationTestHelper(getInstrumentation(), AppDatabase::class.java)

@Test
fun migra_3_a_4_conservando_contenido() {
    helper.createDatabase(TEST_DB, 3).apply {
        execSQL("INSERT INTO notas (id, titulo, cuerpo, creada_en) VALUES (1, 'a', 'texto', 0)")
        close()
    }
    val db = helper.runMigrationsAndValidate(TEST_DB, 4, true, MIGRACION_3_4)
    db.query("SELECT contenido FROM notas WHERE id = 1").use {
        it.moveToFirst()
        assertEquals("texto", it.getString(0))
    }
}

El parámetro de validación compara el esquema resultante con el JSON de la versión destino: sin el directorio de esquemas versionado, este test no puede existir. Hay que probar también los saltos, porque un usuario que no abre la app en un año migrará de la 1 a la 5 de golpe, encadenando todas las migraciones intermedias.

⚠️
fallbackToDestructiveMigration borra los datos

fallbackToDestructiveMigration hace que Room, ante una migración ausente, borre la base y la recree vacía. Es legítimo en desarrollo, y también en producción cuando la base es puro caché reconstruible desde la red. Aplicarlo sobre datos que el usuario ha creado es pérdida de información sin aviso ni recuperación posible.

El esquema publicado es una promesa, y las migraciones son su historia

Hay una diferencia de naturaleza entre el código y el esquema que este nivel obliga a interiorizar. El código de tu app es reemplazable: cada actualización lo sustituye por completo, y una versión mala se corrige con la siguiente. El esquema no. En el momento en que publicas una versión, la forma de esa base queda grabada en millones de dispositivos y deja de pertenecerte; solo puedes transformarla, nunca sustituirla, y cada transformación debe funcionar sobre datos que jamás verás, en dispositivos que pueden quedarse sin batería a mitad de la operación. Es la única parte de tu programa que acumula historia en lugar de reemplazarla, y por eso la disciplina que exige se parece más a la de un sistema distribuido que a la del desarrollo de una pantalla. De ahí que las decisiones que parecen burocráticas sean en realidad las importantes. Versionar el JSON del esquema no es papeleo: es conservar la evidencia de qué promesa hiciste en cada release, y sin ella no puedes escribir una transformación correcta porque no sabes de dónde partes. Ejecutar la migración dentro de una transacción no es una cortesía del framework: es lo que garantiza que un apagón a mitad deje la base en la versión antigua íntegra en lugar de en un limbo con media tabla copiada. Y validar el esquema resultante contra el JSON no es paranoia: es cerrar el círculo entre lo que el código cree y lo que el disco contiene, que es exactamente la fractura que Room existe para impedir. La conclusión práctica cabe en una frase, y es la más valiosa de todo el nivel: diseña el esquema pensando en su quinta versión, no en la primera, porque el coste de cada decisión no se paga cuando la tomas, sino cada vez que tienes que migrar alrededor de ella.

⚔️ Evolucionar un esquema sin perder una fila
  1. Versiona el directorio de esquemas, publica mentalmente la versión 1 y comprueba que el JSON contiene el hash de identidad.
  2. Añade una columna con valor por defecto usando una migración automática y verifica que no hiciste falta escribir SQL.
  3. Renombra una columna con AutoMigrationSpec y confirma con un test que los datos de la columna antigua siguen ahí.
  4. Cambia el tipo de una columna aplicando el patrón de recreación de tabla, sin olvidar recrear el índice con su nombre canónico.
  5. Escribe un test que cree la base en la versión 1, encadene todas las migraciones hasta la última y valide el esquema final; después provoca a propósito un índice con nombre distinto y lee el fallo.