Room: migrar la base de datos
Al terminar: Cambiar el esquema de una base de datos que ya tiene datos sin perderlos, con una migración automática.
Al terminar esta lección, la base de Marcador tiene una columna nueva, aMano, en la tabla gol, y el móvil que ya tenía la app con datos guardados no pierde ni uno: instalas la versión nueva encima de la anterior, la abres y el gol que registraste antes sigue ahí. Esa operación se llama migración.
Importa porque una app de verdad se actualiza con los datos de los usuarios dentro. Cambiar una tabla sin migración obliga a borrar la base, y borrar la base es perder lo que la gente guardó. Es también una de las preguntas más repetidas en las entrevistas de Android.
Temas: versión del esquema, migración automática y valor por defecto de una columna nueva. No se instala nada: KSP y Room ya vienen de Room: entidades y DAO. Primero lo practicas con la biblioteca de esa lección y después lo aplicas a Marcador.
Qué es la versión del esquema
El esquema es la forma de la base: qué tablas hay y qué columnas tiene cada una. Room guarda en el fichero de la base, en el móvil, el número de versión con el que se creó, y en el código de la app lleva version = … dentro de @Database. Al abrir la base compara los dos números:
| Versión en el móvil | Versión en el código | Qué hace Room |
|---|---|---|
| 1 | 1 | Abre la base tal cual |
| 1 | 2 | Necesita una migración de la 1 a la 2: la receta para transformar la base sin borrar filas. Sin receta no puede abrirla |
| (no hay base) | 2 | Crea la base directamente en la versión 2 |
Migración automática: 1.json, 2.json y AutoMigration
Room calcula la migración comparando dos ficheros que exporta al compilar (exportSchema = true), uno por versión, en app/schemas:
app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase/1.json <- la versión 1 (ya lo tienes)
app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase/2.json <- la versión 2 (lo generará tu compilación)Son rutas de disco: la vista Android no muestra esa carpeta, así que se llega con la Terminal.
Dentro de los JSON está la sentencia que crea la tabla gol. TEXT e INTEGER son los tipos de columna de SQLite (texto y número entero), y FOREIGN KEY es la clave foránea que une cada gol con su partido, la que declaraste con @ForeignKey. La diferencia entre versiones es una sola columna:
1.json: … `equipo` TEXT NOT NULL, FOREIGN KEY(`partidoId`) …
2.json: … `equipo` TEXT NOT NULL, `aMano` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`partidoId`) …AutoMigration(from = 1, to = 2) le pide a Room que genere la receta a partir de esa diferencia: una clase MarcadorDatabase_AutoMigration_1_2_Impl que ejecuta esta sentencia SQL sobre la base del móvil:
ALTER TABLE `gol` ADD COLUMN `aMano` INTEGER NOT NULL DEFAULT 0aMano dirá si un gol se registró con los botones de la app (true) o venía de los partidos de ejemplo con los que se llena la base la primera vez (false). Como la columna no admite nulos (NOT NULL), las filas que ya existen necesitan algún valor: por eso DEFAULT 0, y de ahí sale el @ColumnInfo(defaultValue = "0") de la entidad.
Dos detalles que despistan:
Booleanse guarda comoINTEGER. SQLite no tiene un tipotrue/false: guarda los booleanos como el número entero0(falso) o1(verdadero). Room hace la conversión en las dos direcciones, así que en Kotlin sigues escribiendotrueyfalse. Por eso en el SQL apareceINTEGER … DEFAULT 0: «falso» para todos los goles que ya existían.defaultValue = "0"es un texto. Room pega ese texto tal cual en la sentencia SQL, después deDEFAULT. Es un valor del esquema de la base, escrito en SQL (0, nofalse), y no tiene nada que ver con el= falsede Kotlin, que solo se aplica cuando tu código construye unGolEntitysin daraMano.
Una columna nueva con migración automática
Piezas nuevas:
@ColumnInfo(defaultValue = "0"): el valor que el esquema de la base da a una columna. No es lo mismo que el= falsede Kotlin, que es del constructor de la clase.AutoMigration(from = 1, to = 2), que se lista en@Database(autoMigrations = […]): la migración que Room genera solo.version = 2en@Database: sube la versión del esquema.
La misma forma en el recetario inventado de Room: entidades y DAO: los ingredientes ganan una columna opcional. No es la columna de la biblioteca ni la de Marcador:
data class IngredienteEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val recetaId: Long,
val nombre: String,
@ColumnInfo(defaultValue = "0") // valor del ESQUEMA para las filas que ya existían (texto SQL)
val opcional: Boolean = false, // valor de KOTLIN al construir sin darlo; se guarda como INTEGER
)
@Database(
entities = [RecetaEntity::class, IngredienteEntity::class],
version = 2, // sube la versión: el esquema cambió
exportSchema = true, // escribe 2.json junto a 1.json
autoMigrations = [AutoMigration(from = 1, to = 2)], // Room compara 1.json y 2.json y genera el ALTER TABLE
)
abstract class RecetarioDatabase : RoomDatabase() {
abstract fun recetaDao(): RecetaDao
}Los dos errores que vas a encontrar
1. Falta el valor por defecto. Falla al procesar las anotaciones (sale con e: [ksp]; el número de línea y el nombre de la base dependen de tu fichero):
e: [ksp] …/BibliotecaDatabase.kt:39: New NOT NULL column'prestado' added with no default value specified.Please specify the default value using @ColumnInfo.Sin valor por defecto, Room no sabe qué poner en las filas que ya existen y no puede generar la migración. El = false de Kotlin no sirve: es del constructor de la clase, no del esquema de la base. Por eso la solución es @ColumnInfo(defaultValue = "0").
2. Falta 1.json. Room necesita los dos JSON para comparar, y forman parte de tu proyecto: no se borran. Si alguien, al limpiar ficheros que parecen generados, borra el 1.json de una base y compila, falla también al procesar las anotaciones:
e: [ksp] Schema '1.json' required for migration was not found at the schema out folder: …/app/schemas/…. Cannot generate auto migrations.Si pierdes 1.json sin copia, se regenera en tres pasos: 1) quita de la entidad la columna nueva; 2) pon version = 1 y borra la lista autoMigrations de @Database; 3) compila, y Room escribe de nuevo el 1.json (luego deshaces los pasos 1 y 2). Más adelante, en Migraciones de base de datos, verás este error con más calma.
Ejercicio: la biblioteca pasa a la versión 2
Una migración solo se ve sobre una base que ya tiene datos, y esa base ya la tienes: la biblioteca que creaste en Room: entidades y DAO, en la misma subcarpeta ejercicios/biblioteca. No crees una nueva.
Reto · Ejercicio: la biblioteca pasa a la versión 2
Antes de tocar nada, ejecuta otra vez la vista previa de la biblioteca en el emulador y comprueba que tiene libros (si no, añade tres).
Después, en BibliotecaDatabase.kt: el libro gana una columna prestado, que dice si está prestado, vale falso si no se da y tiene 0 como valor por defecto en el esquema; la base sube a la versión 2 con una migración automática de la 1 a la 2. En Biblioteca.kt: «Añadir libro» guarda el libro nuevo como prestado, y cada línea termina en « · prestado» o en « · en casa».
Compila con ./gradlew :app:assembleGratisDebug y ejecuta la vista previa en el emulador sin desinstalar. Lo que debes ver: 1. Libro 1 · en casa, 2. Libro 2 · en casa y 3. Libro 3 · en casa: los libros de antes siguen ahí y la base les dio el valor por defecto. Pulsa «Añadir libro»: 4. Libro 4 · prestado. Y ls app/schemas/com.sebastiannarvaez.marcador.ejercicios.biblioteca.BibliotecaDatabase muestra 1.json y 2.json. Al terminar, elige otra vez app en el selector de arriba.
Pistas, solo con lo visto: el ejemplo de opcional de arriba tiene las tres piezas, y los dos errores de arriba son los que te saldrán si te dejas el valor por defecto o el 1.json.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.biblioteca
import android.content.Context
import androidx.room3.AutoMigration
import androidx.room3.ColumnInfo
import androidx.room3.Dao
import androidx.room3.Database
import androidx.room3.Entity
import androidx.room3.Insert
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.AndroidSQLiteDriver
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "libro")
data class LibroEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val titulo: String,
@ColumnInfo(defaultValue = "0")
val prestado: Boolean = false,
)
@Dao
interface LibroDao {
@Query("SELECT * FROM libro ORDER BY id")
fun observarLibros(): Flow<List<LibroEntity>>
@Insert
suspend fun insertar(libro: LibroEntity)
}
@Database(
entities = [LibroEntity::class],
version = 2,
exportSchema = true,
autoMigrations = [AutoMigration(from = 1, to = 2)],
)
abstract class BibliotecaDatabase : RoomDatabase() {
abstract fun libroDao(): LibroDao
}
fun crearBiblioteca(contexto: Context): BibliotecaDatabase =
Room.databaseBuilder<BibliotecaDatabase>(
context = contexto.applicationContext,
name = contexto.getDatabasePath("biblioteca.db").absolutePath,
)
.setDriver(AndroidSQLiteDriver())
.build()package com.sebastiannarvaez.marcador.ejercicios.biblioteca
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
@Composable
fun Biblioteca(modifier: Modifier = Modifier) {
val contexto = LocalContext.current
val dao = remember { crearBiblioteca(contexto).libroDao() }
val flujo = remember { dao.observarLibros() }
val libros by flujo.collectAsStateWithLifecycle(initialValue = emptyList())
val alcance = rememberCoroutineScope()
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Libros guardados: ${libros.size}")
Button(onClick = { alcance.launch { dao.insertar(LibroEntity(titulo = "Libro ${libros.size + 1}", prestado = true)) } }) {
Text("Añadir libro")
}
libros.forEach { Text("${it.id}. ${it.titulo} · ${if (it.prestado) "prestado" else "en casa"}") }
}
}
@Preview(showBackground = true)
@Composable
private fun BibliotecaPreview() {
MaterialTheme { Biblioteca() }
}La base del móvil estaba en la versión 1; al abrirla, Room ejecuta el ALTER TABLE de la migración y los libros antiguos se quedan con prestado = 0. Mira también la carpeta de esquemas: ahora tiene 1.json y 2.json.
Paso · la columna aMano y la versión 2
Objetivo. GolEntity gana aMano, la base sube a la versión 2 con una migración automática, y los goles que se registran con los botones se guardan con aMano = true. Debes ver que la base del móvil conserva el gol de antes.
Antes de tocar nada: deja datos en la versión 1
La migración solo se ve con una base que ya existe. Comprueba que tu móvil (o emulador) tiene la app de la lección anterior con un gol guardado: ejecuta la app con la variante gratisDebug y abre la pestaña Partidos sin pulsar nada: si seguiste el punto de control de la lección anterior, el partido 1 dice 4-2 (de 3-2 a 4-2 fue tu gol de «Gol Rayo FC», y se conserva al cerrar la app). Si dice 3-2, aún no registraste ese gol: ve a la pestaña Marcador, pulsa «Gol Rayo FC» una vez y vuelve a Partidos, que ahora dirá 4-2. Las cifras de esta lección parten de ese 4-2: si tu partido 1 marca otra cosa, suma lo que corresponda al resultado de cada comprobación. No desinstales la app durante esta lección: desinstalar borra la base y la prueba no serviría.
Intenta escribirlo tú
Las piezas son las del ejemplo de opcional, aplicadas a GolEntity. Hazlo en este orden, y compila desde la Terminal de Android Studio (View → Tool Windows → Terminal) con ./gradlew :app:assembleGratisDebug entre el paso 1 y el 2:
- En
GolEntity(Entidades.kt), al final, una propiedad nueva,aMano, que dice si el gol se marcó a mano y vale falso si no se da; de momento, sin decirle nada al esquema. EnMarcadorDatabase.kt, sube la versión de la base a 2 y declara la migración automática de la 1 a la 2. Compila: falla, y el mensaje es el primer error de arriba. - Corrige la entidad: dale a esa columna un valor por defecto en el esquema, 0, y compila otra vez. Termina bien y aparece
2.jsonjunto a1.json. - En
PartidosRepositoryRoom.kt,registrarGolguarda el gol marcado como hecho a mano.
Los package y los import ya los tienes en cada fichero; el único import nuevo es el de la anotación o la clase que se estrena en cada bloque (Option+Enter sobre el nombre en rojo lo propone, y recuerda que los de Room son androidx.room3.…). El título de los bloques (app/src/main/java/…) es la ruta del fichero en tu proyecto; en la vista Android está en app → kotlin+java → com.sebastiannarvaez.marcador, dentro de data y de room.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import androidx.room3.ColumnInfo
@ColumnInfo(defaultValue = "0")
val aMano: Boolean = false,El import va con los demás. La anotación y la propiedad van al final de los parámetros de GolEntity, tras val equipo: String,.
import androidx.room3.AutoMigration
version = 2,
autoMigrations = [AutoMigration(from = 1, to = 2)],El import va con los demás. version = 2, sustituye a version = 1,, y autoMigrations va después de exportSchema = true, dentro de @Database( … ).
dao.insertarGol(GolEntity(partidoId = partidoId, minuto = gol.minuto, jugador = gol.jugador.nombre, equipo = gol.equipo.nombre, aMano = true))Sustituye la línea dao.insertarGol(…) de registrarGol: la única diferencia es aMano = true.
Comprobar que la migración conserva el gol
Instala la versión 2 sobre la 1, sin desinstalar: pulsa Run con la variante gratisDebug (o escribe ./gradlew :app:installGratisDebug en la Terminal) y abre la app. Android actualiza la instalación y conserva los datos de la app. En la pestaña Partidos, la fila del partido 1 sigue en 4-2 (el resultado con el que empezaste esta lección): la base del móvil estaba en la versión 1, Room ejecutó ALTER TABLE al abrirla y el gol se conservó con aMano = 0.
Después pulsa «Gol Rayo FC» otra vez (ahora se guarda con aMano = true), cierra la app del todo (deslizándola hacia arriba en recientes, o con adb shell am force-stop com.sebastiannarvaez.marcador.debug en la Terminal; ese es el id de la variante gratisDebug) y ábrela: el partido 1 dice 5-2.
Cuando Room no puede deducir el cambio
La migración automática solo sirve cuando Room puede deducir el cambio comparando los dos JSON, como al añadir una columna con valor por defecto. Con otros cambios, como renombrar una columna, el JSON nuevo solo dice «ya no está a y hay una b»: Room no sabe si es la misma columna con otro nombre o dos distintas. Entonces escribes la migración a mano: una clase que describe la versión de origen y la de destino y ejecuta tú mismo las sentencias SQL que transforman la base (por ejemplo, ALTER TABLE … RENAME COLUMN …), y se la das a Room al construir la base. Marcador no necesita ninguna, así que no la escribirás aquí; lo que debes saber es cuándo hace falta.
// Una migración escrita a mano: de qué versión a cuál, y el SQL que la hace.
val MIGRACION_2_3 = object : Migration(2, 3) { // origen 2, destino 3
override suspend fun migrate(connection: SQLiteConnection) { // Room la llama al abrir una base en la versión 2
connection.execSQL("ALTER TABLE gol RENAME COLUMN jugador TO goleador") // el cambio que Room no sabe deducir
}
}
// Se le da a Room al construir la base:
// Room.databaseBuilder<MarcadorDatabase>(…).setDriver(…).addMigrations(MIGRACION_2_3).build()Es una ilustración (una migración 2→3 imaginaria, que Marcador no tiene): Marcador no la necesita y no la escribes. object : Migration(2, 3) { … } es un objeto anónimo (Herencia, interfaces y composición); los import son androidx.room3.migration.Migration, androidx.sqlite.SQLiteConnection y androidx.sqlite.execSQL.
Punto de control
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL, yapp/schemas/…/MarcadorDatabase/2.jsonaparece junto al1.json(ls app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase).- Con la versión 1 instalada antes,
installGratisDebugla actualiza sin desinstalar y, en Partidos, la fila del partido 1 sigue en 4-2. - Tras otro «Gol Rayo FC», cerrar la app y abrirla, el partido 1 dice 5-2.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.La base de un móvil está en la versión 1 del esquema y la app nueva declara
version = 2. ¿Qué necesita Room para abrirla sin perder datos?2.Añades a una entidad una columna
aMano: Boolean = falseque no admite nulos, sin@ColumnInfo, y declarasAutoMigration(from = 1, to = 2). ¿Qué ocurre al compilar?3.¿Para qué sirve el fichero
1.jsondeapp/schemas?4.Has probado la versión 1 con un gol guardado y quieres comprobar que la migración lo conserva. ¿Qué haces?
5.Una migración automática no puede deducir el cambio (por ejemplo, renombrar una columna). ¿Qué haces?