Pular para o conteúdo

Migrações

Mudar o schema de um banco que tem dado dentro — com ida, volta e histórico.

Num sistema em produção o banco tem dado dentro. Trocar o create_table no código não muda a tabela que já existe, e apagar e recriar perde tudo.

A lista, com ida e volta#

dataforge
steady MIGRACOES := [
    {
        "version": 1,
        "description": "clientes",
        "up": """
            CREATE TABLE clientes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nome TEXT NOT NULL
            );
        """,
        "down": "DROP TABLE clientes;"
    },
    {
        "version": 2,
        "description": "e-mail do cliente",
        "up":   "ALTER TABLE clientes ADD COLUMN email TEXT;",
        "down": "ALTER TABLE clientes DROP COLUMN email;"
    }
]

Banco.migrate(db, MIGRACOES)      // aplica o que falta; devolve quantas
ChamadaFaz
Banco.migrate(db, lista)aplica as que faltam, em ordem de versão
Banco.migrations_applied(db)versão, descrição e quando
Banco.rollback_migration(db, lista)desfaz uma
Banco.rollback_migration(db, lista, ate := 3)desfaz até a 3, que fica
Banco.schema_sql(db)o schema como o SQLite o guarda

É idempotente, e isso é o ponto#

migrate grava numa tabela _migrations o que já aplicou, e pula essas. Rodar de novo devolve 0. É o que permite chamá-lo no começo de todo processo:

dataforge
db := Banco.connect("dados.db")
Banco.migrate(db, MIGRACOES)
V.subir(porta := 8501)

Sem isso, subir o servidor duas vezes quebraria na segunda — e alguém acabaria escrevendo um script de migração que se roda à mão, que é o mesmo problema com mais passos.

Toda migração deveria ter `down`#

Desfazer acontece no meio de um incidente — que é quando ninguém tem paciência para editar o banco à mão. Duas escolhas deliberadas:

O dado sobrevive#

dataforge
Banco.insert(db, "clientes", {"nome": "Ana", "email": "ana@exemplo.br"})

steady MAIS := [...MIGRACOES, {
    "version": 3, "description": "telefone",
    "up":   "ALTER TABLE clientes ADD COLUMN telefone TEXT DEFAULT '';",
    "down": "ALTER TABLE clientes DROP COLUMN telefone;"
}]

Banco.migrate(db, MAIS)                  // uma aplicada
Banco.count(db, "clientes")              // o dado continua lá

As migrações que exigem cuidado#

MudançaComo fazer
acrescentar colunaALTER TABLE … ADD COLUMN — barato, e com DEFAULT não trava
renomear colunaALTER TABLE … RENAME COLUMN (SQLite 3.25+)
mudar tipo de colunatabela nova, INSERT … SELECT, DROP, RENAME — o SQLite não altera tipo
acrescentar NOT NULL sem DEFAULTimpossível com dado existente: preencha antes, em duas migrações
acrescentar índice numa tabela grandetrava a escrita enquanto constrói; faça na janela de manutenção
apagar colunaDROP COLUMN (3.35+), e é irreversível — o down não recupera o dado

Antes de migrar em produção#

dataforge
Banco.backup(db, $"antes-da-v{proxima}.db")
Banco.migrate(db, MIGRACOES)
Banco.integrity(db)

backup faz uma cópia consistente com o banco em uso — é a API de backup do SQLite, não um cp. Um cp de um banco sendo escrito copia um arquivo pela metade.

E o SQLite não desfaz DDL dentro de transação de forma confiável em todas as versões: o backup é a rede de segurança real, não o rollback.

Comparar dois ambientes#

dataforge
// em produção
IO.write("schema-prod.sql", Banco.schema_sql(prod))
// no seu
IO.write("schema-dev.sql", Banco.schema_sql(dev))

Um diff entre os dois mostra o que falta migrar. É o jeito mais direto de descobrir que alguém alterou o banco à mão.