Escribir una migración
El mecanismo completo: la lista, la versión, la transacción y la copia. Con el código que se puede copiar y los tres cambios de esquema que necesitan cuidado.
El motor
// la lista: ordenada, inmutable, y cada una lleva de la versión N-1 a la Nconst MIGRACIONES: &[&str] = &[ // 1 "CREATE TABLE proyecto (id INTEGER PRIMARY KEY, nombre TEXT NOT NULL) STRICT;", // 2 "ALTER TABLE proyecto ADD COLUMN creado_en INTEGER NOT NULL DEFAULT 0;", // 3 "CREATE INDEX idx_proyecto_creado ON proyecto(creado_en);",];pub fn migrar(conn: &mut Connection, ruta: &Path) -> Result<(), Error> { let actual: u32 = conn.query_row("PRAGMA user_version", [], |f| f.get(0))?; let objetivo = MIGRACIONES.len() as u32; if actual > objetivo { return Err(Error::BaseDeVersionPosterior); // nivel 43.1 } if actual == objetivo { return Ok(()); } // 1 · copia antes de tocar nada let copia = copia_de_seguridad(conn, ruta)?; // 2 · todas las pendientes, en una sola transacción let resultado = (|| -> Result<(), Error> { let tx = conn.transaction()?; for (i, sql) in MIGRACIONES.iter().enumerate().skip(actual as usize) { tx.execute_batch(sql)?; tx.pragma_update(None, "user_version", (i + 1) as u32)?; } tx.commit()?; Ok(()) })(); // 3 · si algo falló, la base quedó como estaba; la copia se conserva match resultado { Ok(()) => { let _ = std::fs::remove_file(&copia); Ok(()) } Err(e) => Err(Error::MigracionFallida { causa: Box::new(e), copia }), }}Cuatro detalles que son la lección:
- La versión se actualiza dentro de la transacción, después de cada migración. Si se hiciera al final, un fallo dejaría todas aplicadas y la versión sin subir.
- Todas las pendientes van en una sola transacción. O se aplican las veinte que faltan, o ninguna.
- La copia solo se borra si todo fue bien, y si falla, su ruta viaja en el error para poder decírsela al usuario.
- Y las claves foráneas merecen un cuidado: durante ciertos cambios de esquema hay que desactivarlas temporalmente, y eso no se puede hacer dentro de una transacción. Ver abajo.
Los tres cambios que no son directos
SQLite soporta añadir columnas y poco más. Estos tres necesitan el mismo rodeo:
El rodeo es siempre el mismo y merece aprenderlo una vez:
-- 1 · crear la tabla nueva con la forma que quieresCREATE TABLE proyecto_nueva ( id INTEGER PRIMARY KEY, nombre TEXT NOT NULL CHECK (length(nombre) <= 200), creado_en INTEGER NOT NULL) STRICT;-- 2 · copiar los datos, transformando lo que haga faltaINSERT INTO proyecto_nueva (id, nombre, creado_en) SELECT id, substr(nombre, 1, 200), COALESCE(creado_en, 0) FROM proyecto;-- 3 · sustituirDROP TABLE proyecto;ALTER TABLE proyecto_nueva RENAME TO proyecto;-- 4 · y volver a crear los índices y disparadores, que se fueron con la tablaCREATE INDEX idx_proyecto_creado ON proyecto(creado_en);El paso 4 se olvida constantemente, y el síntoma es una aplicación que va bien y de pronto va lenta tras una actualización: los índices desaparecieron con la tabla vieja.
Y una advertencia del orden: si hay claves foráneas apuntando a esa tabla, la secuencia correcta pasa por desactivarlas antes de abrir la transacción y comprobar la integridad referencial al terminar. La comprobación al final no es opcional: es lo que detecta que el rodeo dejó referencias huérfanas.
Verificar antes de publicar
Tres pruebas, y son automatizables con lo del nivel 42.4:
La prueba 2 es la que justifica guardar una base de ejemplo por versión en el repositorio. Ocupan poco y son la única forma de verificar el salto de la versión 3 a la 27.