Migraciones de base de datos
Al terminar: Cambiar el esquema sin borrar los datos del usuario: la columna nueva, la versión y el dato que atraviesa el núcleo.
Ahora queremos pintar el color del tipo en la lista de favoritos, y para eso hay que saber si el Pokémon es de fuego o de agua: un dato que la base todavía no guarda. Al terminar esta lección la tabla tendrá una columna más, el dato nuevo viajará desde la base hasta las dos pantallas, y la app compilará en Android y en iOS. En la lección siguiente, Probar la migración, un test demostrará que los favoritos de quien ya tenía la app siguen ahí.
Este es el momento incómodo de cualquier app que guarda datos: cambiar la forma de una tabla que ya tiene filas dentro, sin perderlas. En tu emulador todo funciona si desinstalas, porque la base se crea desde cero; quien lleva tres meses con la app tiene un fichero con el esquema viejo.
Qué le hace una migración a una tabla con filas
Una migración es la receta que convierte la base vieja en la nueva, fila a fila, sin tirar nada. Por dentro, Room la escribe en SQL, el lenguaje de las bases de datos: aquí solo hace falta reconocer tres frases. INSERT INTO guarda una fila, ALTER TABLE … ADD COLUMN añade una columna a una tabla que ya existe y SELECT lee filas.
El ejemplo usa una biblioteca, con una tabla de libros y dos ya guardados. Es un ejemplo solo para leer: no tienes que ejecutarlo ni instalar nada. Para leerlo, tres palabras: CREATE TABLE crea una tabla con sus columnas; INTEGER y TEXT son los tipos de columna (un número entero y un texto); PRIMARY KEY marca la columna que identifica cada fila, que no se puede repetir. Y dos más, las de la migración: NOT NULL significa «esta casilla no puede quedar vacía»; DEFAULT '' es la decisión de qué poner cuando nadie la rellenó.
-- La tabla de la versión 1, con dos libros ya guardados.
CREATE TABLE libro (id INTEGER PRIMARY KEY, titulo TEXT NOT NULL);
INSERT INTO libro VALUES (1, 'Dune');
INSERT INTO libro VALUES (2, 'Emma');
-- Intento 1: una columna obligatoria (NOT NULL) y sin valor por defecto.
-- Los dos libros que ya existen no tendrían nada que poner en ella.
ALTER TABLE libro ADD COLUMN genero TEXT NOT NULL;
-- Intento 2: la misma columna, diciendo qué poner en las filas que ya existen.
ALTER TABLE libro ADD COLUMN genero TEXT NOT NULL DEFAULT '';
SELECT id, titulo, genero FROM libro;Esto es lo que escribe sqlite3 (el motor que usan Room y Android) si guardas el ejemplo en un fichero y lo ejecutas con sqlite3 -header -column :memory: < libros.sql (esas dos opciones piden los nombres de columna y la salida alineada). El número de la primera línea es el de la línea del fichero donde falla, contando los comentarios:
Runtime error near line 8: Cannot add a NOT NULL column with default value NULL
id titulo genero
-- ------ ------
1 Dune
2 Emma El primer intento se rechaza y el segundo funciona: los dos libros siguen ahí, con el género vacío. Esa es la regla de las migraciones: rellenan con lo que tú declaraste, no inventan el dato que no existía. Room te lo pide al compilar, como verás en el paso 3.
Paso 1 · Deja datos en la versión 1
La migración solo se ve con una base que ya existe. Antes de tocar nada, ejecuta la app de Android (configuración androidApp, como en Ejecutar en Android), marca tres Pokémon con el corazón y comprueba que salen en la pestaña Favoritos. No desinstales la app hasta terminar la lección siguiente: desinstalar borra la base y la prueba no serviría.
Paso 2 · La columna, y la palabra que no es opcional
El archivo. FavoriteEntity.kt es la fila de la tabla, de Persistencia con Room 3. Ábrelo con Navigate → File… (Cmd+Shift+O) y escribe FavoriteEntity. Se modifica: añade una propiedad al final de los parámetros de la clase (y el import androidx.room3.ColumnInfo, que propone Option+Enter):
/**
* El tipo principal, guardado con el favorito.
*
* Llego en la version 2 del esquema. Sin el, la lista de favoritos no
* podia pintar el color sin pedir el detalle de cada uno, y eso convertia
* una pantalla que funcionaba sin red en una que no.
*
* `defaultValue` no es opcional: sin el, Room no sabe que poner en las
* filas que ya existen y la migracion automatica **no se puede generar**.
*/
@ColumnInfo(defaultValue = "")
val primaryType: String = "",Hay dos valores por defecto ahí y hacen cosas distintas, igual que en el aMano de Marcador (Room: migrar la base de datos): el = "" de Kotlin es para el código (construir un FavoriteEntity sin dar el tipo) y el defaultValue = "" de @ColumnInfo es para SQLite: acaba escrito en el CREATE TABLE como DEFAULT '' y es lo que Room necesita para las filas que ya existen, el mismo que en el ejemplo de los libros.
Paso 3 · Subir la versión y declarar la migración
El archivo. PokedexDatabase.kt, en el mismo paquete (Navigate → File…). Se modifica: sustituye la línea @Database(entities = [FavoriteEntity::class], version = 1, exportSchema = true) por este bloque, y añade import androidx.room3.AutoMigration con los demás:
@Database(
entities = [FavoriteEntity::class],
version = 2,
exportSchema = true,
// La migracion automatica: Room compara los dos esquemas exportados y
// escribe el ALTER TABLE. Solo puede hacerlo porque `schemas/1.json` esta
// guardado en la carpeta `schemas` del proyecto; si se hubiera borrado,
// habria que escribirla a mano.
autoMigrations = [AutoMigration(from = 1, to = 2)],
)Compílalo en la Terminal de Android Studio (View → Tool Windows → Terminal), que ya se abre en la carpeta de tu proyecto:
./gradlew :shared:kspKotlinJvmDebe terminar en BUILD SUCCESSFUL, y en la salida de la Terminal verás, entre otras, las líneas > Task :shared:kspKotlinJvm y > Task :shared:copyRoomSchemas. Mira el resultado en el panel (vista Project): abre shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase, donde ya están 1.json y 2.json.
Los esquemas, por destino
kspKotlinJvm es la pasada de Room de un destino. Como diste el procesador de Room a cada destino en Persistencia con Room 3, cada uno hace la suya, y la migración se valida en todos. La orden de arriba hace solo la pasada de la JVM; cuando compilas la app de Android o la de iOS, cada destino hace la suya, y como los esquemas salen idénticos queda un solo 2.json en shared/schemas. Si un destino falla, la tarea que falla lo dice en su nombre (kspKotlinIosArm64 FAILED).
Mira el JSON, y provoca los dos errores
El 2.json nuevo tiene el DEFAULT '' que sale del @ColumnInfo (ábrelo con Navigate → File…, escribe 2.json; es una línea muy larga, busca createSql). El ${TABLE_NAME} que verás dentro es un hueco que Room rellena con el nombre de la tabla cuando crea la base; no lo toques:
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pokemonId` INTEGER NOT NULL, `name` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `primaryType` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`pokemonId`))",Para reconocerlos después, provoca los dos fallos de esta migración (y deshazlos):
1. Falta defaultValue. Quita @ColumnInfo(defaultValue = "") de la entidad y repite ./gradlew :shared:kspKotlinJvm:
e: [ksp] …/data/local/PokedexDatabase.kt:20: New NOT NULL column'primaryType' added with no default value specified.Please specify the default value using @ColumnInfo.Es el mismo rechazo del ejemplo de los libros, ahora con las palabras de Room. Falla en PokedexDatabase.kt (la línea 20, la de abstract class PokedexDatabase; si tu fichero tiene alguna línea de más o de menos, el número cambia: lo importante es el mensaje), no en la entidad: es la migración la que no se puede generar. El = "" de Kotlin no sirve, porque es del constructor y no del esquema.
Deshazlo así: vuelve a poner la anotación y repite la orden una vez: debe terminar en BUILD SUCCESSFUL. El intento fallido deja un 2.json sin el DEFAULT, y la compilación buena lo reescribe.
2. Falta 1.json. Renómbralo a 1.json.bak desde la Terminal, en la carpeta de tu proyecto (mv mueve o renombra un fichero; aquí lo renombra):
mv shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase/1.json shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase/1.json.baky compila otra vez con la misma orden:
e: [ksp] …/data/local/PokedexDatabase.kt:20: Schema '1.json' required for migration was not found at the schema out folder: …/shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase. Cannot generate auto migrations.Room necesita los dos JSON para comparar: forman parte de tu proyecto y no se borran. Devuélvelo antes de seguir:
mv shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase/1.json.bak shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase/1.jsonPaso 4 · El dato atraviesa shared
El tipo se guarda cuando alguien marca el favorito, y se lee cuando se pinta la fila. En shared hay cuatro cambios cortos, abriendo cada fichero con Navigate → File….
FavoritePokemon.kt (dominio). El dominio acepta que el dato puede faltar. Sustituye la línea data class FavoritePokemon(val id: Int, val name: String) { por:
data class FavoritePokemon(
val id: Int,
val name: String,
/** Puede faltar en los favoritos guardados antes de la version 2. */
val primaryType: PokemonType? = null,
) {Este nulo es el punto de la lección. La migración no inventa datos que no existían: a las filas antiguas les pone la cadena vacía, y una cadena vacía no es ningún tipo de Pokémon. Inventar uno por defecto («normal», pongamos) habría enseñado un dato falso a quien guardó ese favorito hace meses.
FavoritesRepository.kt (leer). Sustituye observeFavorites (y añade import com.sebastiannarvaez.pokedex.domain.PokemonType):
fun observeFavorites(): Flow<List<FavoritePokemon>> =
dao.observeAll().map { filas ->
filas.map { FavoritePokemon(it.pokemonId, it.name, PokemonType.deApi(it.primaryType)) }
}PokemonType.deApi("") devuelve nulo, de ahí el PokemonType? del dominio. En la base el tipo se guarda como texto, el nombre del enum (FIRE, WATER…), y deApi lo convierte de vuelta buscando ese nombre sin distinguir mayúsculas; una cadena vacía o desconocida da nulo en vez de romper.
FavoritesRepository.kt (guardar). Y toggle, que ahora recibe el tipo y lo escribe. Sustituye la función entera:
suspend fun toggle(id: Int, name: String, primaryType: PokemonType? = null): Boolean = withContext(dispatchers.io) {
if (dao.isFavorite(id)) {
dao.remove(id)
false
} else {
dao.add(
FavoriteEntity(
pokemonId = id,
name = name,
addedAt = ahora(),
primaryType = primaryType?.name.orEmpty(), // `.name` es el nombre del valor del enum (FIRE); `orEmpty()` cambia el nulo por ""
),
)
true
}
}FavoritesViewModel.kt. Y el gesto que llaman las pantallas pasa el tipo hacia el repositorio. Sustituye toggle (y añade import com.sebastiannarvaez.pokedex.domain.PokemonType):
fun toggle(id: Int, name: String, primaryType: PokemonType? = null) {
viewModelScope.launch { repository.toggle(id, name, primaryType) }
}El valor por defecto = null evita que se rompa el código de Kotlin que ya llama a toggle(id, name). En iOS no.
Paso 5 · Las dos interfaces pintan el tipo si está
Android. El archivo es PokemonListScreen.kt, en la carpeta list (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list). Se modifica una línea: la lambda alMarcar manda también el tipo principal del Pokémon. Sustituye alMarcar = { p -> favoritos.toggle(p.id, p.name) }, por:
alMarcar = { p -> favoritos.toggle(p.id, p.name, p.types.firstOrNull()) },p.types.firstOrNull() es el primer tipo de la lista o nulo si no hay ninguno, justo lo que pide el parámetro PokemonType?.
El corazón de la tarjeta es el único sitio donde se marca un favorito (la ficha no tiene corazón), así que esta es la única llamada de Android que hay que cambiar. Quitar uno desde la pestaña Favoritos tampoco cambia: ese gesto llama a quitar, que lee de la base la fila entera, tipo incluido, y la guarda para devolverla con «Deshacer» (lo hiciste en Quitar con deshacer), así que no pierde nada.
Ahora, en FavoritesScreen.kt (carpeta favorites), en la función Fila, el Text del número ("N.º …", con sus tres líneas de estilo y color) pasa a ir dentro de un Row junto al tipo, que se pinta solo si existe. tipo.color y tipo.etiqueta son las extensiones de androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt, que ya usa la tarjeta de La lista en Android; CircleShape es la forma redonda de Compose (androidx.compose.foundation.shape). Los import los propone Android Studio:
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"N.º ${favorito.id.toString().padStart(4, '0')}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// El tipo se guardo con el favorito, asi que esta fila se pinta
// entera sin red. Los guardados antes de la version 2 no lo
// tienen, y por eso puede ser nulo.
favorito.primaryType?.let { tipo ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tipo.color))
Text(
tipo.etiqueta,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}?.let ejecuta el bloque solo si hay valor, como viste en Nulabilidad.
iOS. En Xcode, abre iosApp/iosApp/List/PokemonListView.swift y sustituye la llamada a alMarcar, que ahora pasa el tipo (en Swift, .first de un array es opcional):
alMarcar: { favoritos?.toggle(id: pokemon.id, name: pokemon.name, primaryType: pokemon.types.first) }En iosApp/iosApp/Favorites/FavoritesView.swift hay dos cambios. El primero, la llamada de quitar dentro de .swipeActions. Quitar no necesita el tipo: se pasa solo porque Swift exige los tres parámetros (favorito.primaryType es el que ya trae la fila).
viewModel?.toggle(id: favorito.id, name: favorito.name, primaryType: favorito.primaryType)El segundo, la fila con el tipo si existe. if let entra solo si el opcional trae algo, como viste en Opcionales, y tipo.tinte y tipo.etiqueta son las extensiones de iosApp/iosApp/List/TypeStyle.swift, que ya usa la tarjeta de La lista en iOS. Este HStack sustituye al Text(String(format: "N.º %04d", favorito.id)) con sus dos modificadores; el resto de la fila no cambia:
HStack(spacing: 8) {
Text(String(format: "N.º %04d", favorito.id))
.font(.caption2)
.foregroundStyle(.secondary)
// El tipo viaja con el favorito: la fila se
// pinta entera sin red.
if let tipo = favorito.primaryType {
HStack(spacing: 5) {
Circle().fill(tipo.tinte).frame(width: 7, height: 7)
Text(tipo.etiqueta)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}Ninguna de las dos pantallas tiene una rama de «tipo desconocido»: si no está, no se pinta y la fila queda con su número y su nombre.
Compila los dos sistemas. En la Terminal, desde la carpeta de tu proyecto, ./gradlew :androidApp:assembleDebug; en Xcode, Cmd+B. Los dos terminan sin errores. No ejecutes todavía la app de Android: la migración se aplica al abrir la base, y eso lo comprobarás en la lección siguiente, con un test y con tus tres favoritos.
Punto de control
./gradlew :shared:kspKotlinJvmtermina enBUILD SUCCESSFULyshared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabasetiene1.jsony2.json.- El
createSqlde2.jsontermina la columna nueva conDEFAULT ''. FavoritePokemon.primaryTypees nulable y las dos interfaces lo tratan como tal.- La app de Android compila (
assembleDebug) y la de iOS compila en Xcode (Cmd+B). ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testsigue en 51 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpetaejercicios: esta lección no añade ninguno.
La migración está declarada, pero nadie ha comprobado que funcione, y esa es la parte peligrosa: compila, los demás tests pasan, y el fallo solo aparece en el teléfono de quien ya tenía la app. En Probar la migración escribes el test que se comporta como esa persona.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En
@ColumnInfo(defaultValue = "") val primaryType: String = ""hay dos valores por defecto. ¿Qué hace cada uno?2.¿Qué hace falta para que
AutoMigration(from = 1, to = 2)se pueda generar?3.¿Por qué
FavoritePokemon.primaryTypees nulable?4.En SQL,
ALTER TABLE libro ADD COLUMN genero TEXT NOT NULLfalla si la tabla ya tiene filas. ¿Por qué?5.Quitaste
@ColumnInfo(defaultValue = "")para provocar el error y lo volviste a poner. ¿Qué haces para que2.jsonquede bien ahora mismo?