Saltar al contenido

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

Rust
// 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:

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