Menu

Migrazioni in SQLite: versionare lo schema con user_version

Come far evolvere uno schema SQLite in sicurezza nel tempo con PRAGMA user_version, script di migrazione ordinati e transazioni per poter tornare indietro.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Gli schemi cambiano. Preparati.

La prima versione del tuo schema non è mai l'ultima. Si aggiungono colonne, si dividono tabelle, si ripensano gli indici. La domanda non è se il tuo schema cambierà, ma se la modifica arriverà pulita su ogni portatile, server e dispositivo che ha già una copia più vecchia del database.

A questo servono le migrazioni: una sequenza di piccoli script ordinati che portano un database dalla versione N alla N+1. Eseguili in ordine e qualsiasi database si mette in pari. Salta la disciplina e ti ritrovi con bug del tipo "sul mio computer funziona" che richiedono un intero pomeriggio per essere scovati.

SQLite ti dà esattamente uno strumento integrato per questo: PRAGMA user_version. È un intero a 32 bit che il database conserva per te e che SQLite stesso non tocca mai. Il significato lo decidi tu.

Un database nuovo parte da 0. Impostalo al numero della migrazione che hai appena applicato. Leggilo all'avvio per sapere a che punto sei.

Un ciclo di migrazione minimo

Il modello mentale: ogni migrazione è uno script SQL numerato. La tua app legge il user_version corrente, esegue in ordine ogni script con un numero più alto e aggiorna user_version dopo ciascuno.

Ecco la migrazione 1, che crea lo schema iniziale:

Due cose da notare. Tutto è racchiuso in BEGIN; ... COMMIT;, quindi è atomico: se CREATE TABLE fallisce, user_version non viene incrementato e puoi correggere e riprovare. E PRAGMA user_version = 1 è l'ultima istruzione prima del commit, quindi la versione cambia solo se tutto il resto è andato a buon fine.

Ora supponiamo che tu debba aggiungere una colonna created_at. È la migrazione 2:

Un database alla versione 0 le esegue entrambe. Uno alla versione 1 esegue solo la seconda. Uno alla versione 2 non esegue nulla. L'ordine è il contratto.

Cosa può e non può fare ALTER TABLE

ALTER TABLE in SQLite è volutamente limitato. Supporta:

  • ADD COLUMN: aggiunge in fondo una nuova colonna con un valore predefinito facoltativo.
  • DROP COLUMN: rimuove una colonna (dalla 3.35).
  • RENAME COLUMN: rinomina una colonna (dalla 3.25).
  • RENAME TO: rinomina la tabella stessa.

Nient'altro. Non puoi cambiare il tipo di una colonna, cambiare NOT NULL, modificare un vincolo CHECK o aggiungere una FOREIGN KEY a una colonna esistente.

-- Non supportato:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
ALTER TABLE users ADD CONSTRAINT users_email_check CHECK (email LIKE '%@%');

Quando ti serve una modifica che SQLite non sa fare direttamente, la ricetta ufficiale è "ricostruire la tabella". È più prolissa ma del tutto affidabile.

Ricostruire una tabella per le modifiche più grandi

Lo schema è: crea una nuova tabella con la forma che vuoi, copia i dati, elimina la vecchia, rinomina la nuova al suo posto. Tutto dentro una transazione.

La documentazione completa di SQLite la chiama ricetta in 12 passi e aggiunge qualche cautela in più per trigger, viste e riferimenti di chiave esterna: vale la pena leggerla una volta prima di farlo su uno schema di produzione. Nella maggior parte dei casi basta la versione in quattro passi vista sopra.

Un avvertimento: se hai chiavi esterne che puntano alla tabella che stai ricostruendo, esegui PRAGMA foreign_keys = OFF prima della migrazione e PRAGMA foreign_keys = ON dopo. Altrimenti DROP TABLE può rompere l'integrità referenziale a metà strada.

Guidare le migrazioni dalla tua applicazione

La contabilità è abbastanza semplice da poterla scrivere da te. In Python con la libreria standard:

Gli invarianti chiave:

  • Le migrazioni sono numerate in modo consecutivo a partire da 1. Nessun buco, nessun riordino.
  • Ogni migrazione è racchiusa in una transazione insieme all'incremento PRAGMA user_version = N.
  • Una volta che una migrazione è confermata e rilasciata, non la modifichi mai. Le nuove modifiche vanno in una nuova migrazione.

L'ultima regola è quella che i team infrangono più spesso. Se modifichi la migrazione 3 dopo che il database di un collega l'ha già applicata, il suo database resterà per sempre, e in silenzio, disallineato dal tuo.

Tenere un registro storico

user_version ti dice dove si trova un database. Non ti dice quando è stato eseguito ogni passo né cosa ha fatto. Una piccola tabella di servizio risolve il problema:

Ora hai una riga per migrazione con nome e timestamp: comodo quando devi capire "perché questo database ha una colonna che il codice non si aspetta?"

PRAGMA user_version resta la fonte di verità per il ciclo; la tabella è per le persone.

Rollback: cosa ti danno le transazioni e cosa no

Il DDL di SQLite è transazionale. Se la migrazione 5 inizia a creare una tabella, copiare dati e incrementare user_version, e la copia fallisce a metà, ROLLBACK annulla tutto, compreso il CREATE TABLE. Il database torna esattamente com'era prima di BEGIN.

Questo copre le migrazioni fallite. Non copre le migrazioni confermate con successo di cui ora ti penti. Per quelle scrivi una migrazione di ritorno separata (down-migration): uno script che annulla la modifica. SQLite non ha un'inversione automatica. Se la migrazione 7 ha aggiunto una colonna, la versione di ritorno la elimina. Se la migrazione 7 ha eliminato una colonna, la versione di ritorno non può recuperare i dati; il massimo che può fare è ricreare la colonna vuota.

In pratica, molti piccoli progetti saltano del tutto le migrazioni di ritorno e si affidano ai backup per "annullare". È una scelta valida, a patto di fare davvero i backup.

Qualche abitudine che ti evita guai

  • Una migrazione per ogni modifica logica. Una migrazione che aggiunge tre colonne non correlate è più difficile da rivedere e da annullare di tre migrazioni separate.
  • Prova le migrazioni su una copia della produzione. Le modifiche allo schema possono essere lente sulle tabelle grandi; scoprirlo in produzione non è divertente.
  • Non modificare mai una migrazione già rilasciata. Aggiungine una nuova.
  • Prima fai un backup. Un rapido .backup nella CLI, o una copia del file quando il database è chiuso, è un'assicurazione economica prima di qualsiasi migrazione non banale.
  • Attenzione a PRAGMA foreign_keys. Disattivalo durante la ricostruzione delle tabelle, riattivalo dopo.

Per i progetti più grandi, usa uno strumento dedicato: Alembic con SQLAlchemy, golang-migrate, Knex, Flyway. Gestiscono l'ordinamento, le esecuzioni concorrenti e le convenzioni di squadra che altrimenti dovresti reinventare. I principi sono gli stessi del ciclo visto sopra; lo strumento elimina solo il codice ripetitivo.

Prossimo passo: modalità WAL e concorrenza

Di solito le migrazioni vengono eseguite mentre l'applicazione è offline o tiene un lock esclusivo. Il resto del tempo, il tuo database serve letture e scritture da più connessioni contemporaneamente, e la modalità di journal predefinita di SQLite non è sempre la più adatta. La prossima pagina tratta la modalità WAL, cosa cambia e quando passarci.

Domande frequenti

Come si versiona uno schema SQLite?

SQLite ha in ogni database uno spazio integrato per un intero a 32 bit chiamato user_version, accessibile con PRAGMA user_version. Leggilo all'avvio, confrontalo con il numero dell'ultima migrazione che il tuo codice conosce ed esegui in ordine le migrazioni mancanti. Non serve una tabella aggiuntiva, anche se molte app ne aggiungono una come registro storico.

Posso annullare una migrazione SQLite?

Racchiudi ogni migrazione in BEGIN; ... COMMIT;. Se qualcosa all'interno fallisce, ROLLBACK annulla l'intero passaggio, sia le modifiche allo schema sia quelle ai dati, perché il DDL di SQLite è transazionale. Per annullare una migrazione già confermata ti serve uno script di ritorno separato scritto da te: SQLite non lo genera al posto tuo.

Perché ALTER TABLE è limitato in SQLite?

SQLite supporta ALTER TABLE ADD COLUMN, RENAME TABLE, RENAME COLUMN e DROP COLUMN, ma non modifiche arbitrarie come cambiare il tipo o i vincoli di una colonna. La soluzione è la ricetta in 12 passi: crea una nuova tabella con la forma desiderata, esegui INSERT INTO new_table SELECT ... FROM old_table, elimina la vecchia e rinomina la nuova.

Meglio usare uno strumento di migrazione o scriverne uno mio?

Per le app piccole, un ciclo fatto a mano su file .sql numerati e guidato da PRAGMA user_version richiede forse 30 righe di codice e funziona benissimo. Per i progetti più grandi, strumenti come Alembic (Python), golang-migrate (Go) o Knex (Node) gestiscono ordinamento, lock e flussi di lavoro di squadra che altrimenti dovresti reinventare.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA