Dia 9 — Alterar a estrutura depois de criada
ALTER TABLE e a coluna nova
ALTER TABLE e a coluna nova
A tabela que o aplicativo cria na versão 1 do código vai precisar de uma coluna que ninguém previu. ALTER TABLE é o comando que muda a estrutura depois que a tabela existe — e ele tem um limite importante: no SQLite, uma operação por comando.
ALTER TABLE tarefas ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 0;
O ADD COLUMN acrescenta a coluna ao fim da tabela e preenche as linhas existentes com o DEFAULT. Sem DEFAULT e sem NOT NULL, a coluna nova entra preenchida com null.
É o ALTER TABLE que faz a alterar estrutura que o dia 9 chama de migração de banco: o código mudou, e o arquivo gravado no aparelho não mudou junto. Sem o comando que leva um ao outro, o aplicativo de hoje abre um arquivo de ontem e falha na consulta.
O DEFAULT não é detalhe de estilo aqui, e sim a diferença entre a coluna nova utilizável e a coluna nova que quebra a consulta. Uma coluna INTEGER NOT NULL sem DEFAULT, acrescentada em uma tabela com mil linhas, entra preenchida com null em todas elas, e qualquer WHERE prioridade = 0 ignora as mil. Com DEFAULT 0, a linha antiga entra no mesmo estado da linha nova, e o aplicativo não precisa distinguir as duas.
O limite de uma operação por comando aparece em código como este, que tentaria fazer duas coisas de uma vez e não é aceito:
ALTER TABLE tarefas DROP COLUMN prazo, ADD COLUMN prioridade INTEGER;
Os outros dois comandos:
ALTER TABLE tarefas RENAME TO lista; -- muda o nome da tabela ALTER TABLE tarefas DROP COLUMN prazo; -- remove a coluna
O RENAME TO é seguro quando a tabela tem chave estrangeira apontando para ela, porque o SQLite reescreve a referência junto. O DROP COLUMN é o perigoso: o dado some do arquivo sem backup, e se a tabela for a mesma que a consulta do aplicativo espera, o erro aparece na próxima leitura, com mensagem de coluna inexistente.
A coluna antiga é o assunto real do DROP COLUMN: ela some do arquivo, e o código que ainda a lê quebra na consulta. O sintoma é o mesmo da coluna escrita errada no UPDATE do dia 6, visto do outro lado — lá o dado foi para uma coluna que ninguém lê, aqui a coluna some e o código continua lendo.
PRAGMA table_info confere a estrutura real
O ALTER TABLE que roda no aparelho é o que vale; a definição no código é o que o aplicativo acha que existe. A diferença entre as duas é o schema gravado, e é a coluna antiga que existe em um e não no outro.
PRAGMA table_info(tarefas);
Cada linha devolvida traz cid, name, type, notnull, dflt_value e pk. Comparar essa lista com a lista que o código espera é o teste que diz, em tempo de execução, se o arquivo em disco é a versão que o aplicativo sabe ler.
| Pergunta | Comando |
|---|---|
| A coluna nova já existe? | PRAGMA table_info |
| O que tem gravado na tabela? | SELECT * FROM tarefas LIMIT 1 |
| Quantas linhas tem? | SELECT COUNT(*) FROM tarefas |
O versionamento do schema é o que impede essa divergência de virar erro em campo: o código declara a estrutura que espera, e o PRAGMA diz qual estrutura está no arquivo. A aula seguinte transforma essa comparação em procedimento — comparar, decidir o que falta, e gravar que foi resolvido.
O exemplo desta página faz a sequência inteira e imprime cada etapa: o CREATE TABLE inicial, o ALTER TABLE com o ADD COLUMN, a lista de colunas do arquivo depois da alteração com a nova no fim, e o valor que as linhas antigas ganharam. Fecha com o DROP COLUMN, que reduz a lista de colunas e deixa a linha sem o campo — o caminho de volta, que é irreversível.
Exemplo
// `ALTER TABLE` muda a estrutura depois que a tabela existe. No SQLite e // uma operacao por comando, e coluna nova entra sempre com `DEFAULT`. const estrutura = [ { nome: 'id', tipo: 'INTEGER', obrigatoria: true, padrao: null, chave: true }, { nome: 'titulo', tipo: 'TEXT', obrigatoria: true, padrao: null, chave: false }, { nome: 'prazo', tipo: 'TEXT', obrigatoria: true, padrao: null, chave: false }, { nome: 'feita', tipo: 'INTEGER', obrigatoria: false, padrao: 0, chave: false }, ]; function emSql(colunas) { return colunas .map((c) => c.nome + ' ' + c.tipo + (c.chave ? ' PRIMARY KEY AUTOINCREMENT' : '') + (c.obrigatoria ? ' NOT NULL' : '') + (c.padrao !== null ? ' DEFAULT ' + c.padrao : '')) .join(', '); } console.log('CREATE TABLE tarefas (' + emSql(estrutura) + ');'); // o arquivo do aparelho tem a versao 1 do schema const arquivo = { colunas: estrutura.map((c) => ({ ...c })), linhas: [ { id: 1, titulo: 'Revisar o WHERE', prazo: '2026-09-10', feita: 0 }, { id: 2, titulo: 'Ler o capitulo 4', prazo: '2026-09-12', feita: 1 }, ] }; function tableInfo(colunas) { return colunas.map((coluna, posicao) => ({ cid: posicao, name: coluna.nome, type: coluna.tipo, notnull: coluna.obrigatoria ? 1 : 0, dflt_value: coluna.padrao, pk: coluna.chave ? 1 : 0, })); } // 1. a coluna nova: entra ao fim e preenche com o DEFAULT const nova = { nome: 'prioridade', tipo: 'INTEGER', obrigatoria: true, padrao: 0, chave: false }; console.log('ALTER TABLE tarefas ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 0;'); arquivo.colunas.push(nova); arquivo.linhas = arquivo.linhas.map((linha) => ({ ...linha, prioridade: nova.padrao })); console.log('colunas do arquivo depois do ADD COLUMN:', tableInfo(arquivo.colunas).map((c) => c.name).join(', ')); console.log('as linhas antigas ganharam prioridade =', arquivo.linhas.map((l) => l.prioridade)); // 2. sem DEFAULT e sem NOT NULL, a coluna entra com null const semPadrao = { nome: 'anotacao', tipo: 'TEXT', obrigatoria: false, padrao: null, chave: false }; const comNull = arquivo.linhas.map((linha) => ({ ...linha, anotacao: null })); console.log('coluna nova sem DEFAULT ->', comNull[0].anotacao); // 3. `PRAGMA table_info` diz o que o arquivo tem de verdade console.log('PRAGMA table_info(tarefas):'); for (const coluna of tableInfo(arquivo.colunas)) { console.log(' cid', coluna.cid, '|', coluna.name, '|', coluna.type, '| notnull', coluna.notnull, '| dflt', coluna.dflt_value, '| pk', coluna.pk); } // 4. comparar o arquivo com o que o codigo espera const esperadas = ['id', 'titulo', 'prazo', 'feita', 'prioridade']; const existentes = tableInfo(arquivo.colunas).map((c) => c.name); const faltando = esperadas.filter((nome) => !existentes.includes(nome)); console.log('colunas que o codigo espera:', esperadas.length); console.log('faltando no arquivo:', faltando.length === 0 ? 'nenhuma' : faltando.join(', ')); // 5. `DROP COLUMN`: o dado some do arquivo, sem backup const antesDoDrop = arquivo.colunas.length; arquivo.colunas = arquivo.colunas.filter((c) => c.nome !== 'prioridade'); arquivo.linhas = arquivo.linhas.map(({ prioridade, ...resto }) => resto); console.log('DROP COLUMN prioridade: colunas', antesDoDrop, '->', arquivo.colunas.length); console.log('a linha ficou sem a coluna:', Object.keys(arquivo.linhas[0]).join(', '));
Saída real
CREATE TABLE tarefas (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, titulo TEXT NOT NULL, prazo TEXT NOT NULL, feita INTEGER DEFAULT 0); ALTER TABLE tarefas ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 0; colunas do arquivo depois do ADD COLUMN: id, titulo, prazo, feita, prioridade as linhas antigas ganharam prioridade = [ 0, 0 ] coluna nova sem DEFAULT -> null PRAGMA table_info(tarefas): cid 0 | id | INTEGER | notnull 1 | dflt null | pk 1 cid 1 | titulo | TEXT | notnull 1 | dflt null | pk 0 cid 2 | prazo | TEXT | notnull 1 | dflt null | pk 0 cid 3 | feita | INTEGER | notnull 0 | dflt 0 | pk 0 cid 4 | prioridade | INTEGER | notnull 1 | dflt 0 | pk 0 colunas que o codigo espera: 5 faltando no arquivo: nenhuma DROP COLUMN prioridade: colunas 5 -> 4 a linha ficou sem a coluna: id, titulo, prazo, feita
A migração que roda ao abrir o banco
A migração que roda ao abrir o banco
O banco que está no aparelho foi criado por uma versão anterior do aplicativo. A migração é o caminho que leva esse arquivo até a estrutura que o código de hoje espera, sem perder o dado que o usuário gravou.
O SQLite guarda um número inteiro dentro do próprio arquivo para dizer qual versão do schema está gravada: PRAGMA user_version. Ele começa em 0 num arquivo novo, e cada migração concluída grava o próximo número.
PRAGMA user_version; -- le a versao gravada PRAGMA user_version = 2; -- grava a versao
O número fica dentro do arquivo, e não no código nem em um arquivo do lado — essa é a propriedade que faz o mecanismo funcionar. O aplicativo não precisa adivinhar a versão: ele lê, e o arquivo responde.
O algoritmo completo cabe em dez linhas: ler a versão, rodar as migrações que faltam, gravar a nova versão.
async function migrar(banco) { const [versao] = await banco.executeSql('PRAGMA user_version'); const atual = versao.rows[0].user_version; if (atual < 1) { await banco.executeSql('CREATE TABLE IF NOT EXISTS tarefas (...)'); await banco.executeSql('PRAGMA user_version = 1'); } if (atual < 2) { await banco.executeSql('ALTER TABLE tarefas ADD COLUMN prioridade INTEGER DEFAULT 0'); await banco.executeSql('PRAGMA user_version = 2'); } return atual; }
O desenho é uma lista de migrações numeradas, e cada uma roda quando o arquivo está abaixo da versão que ela aplica. É assim que se executar migração: a migrar na abertura acontece uma vez, logo depois do openDatabase, antes de qualquer tela consultar o banco. Com o número gravado no arquivo, a primeira execução e a décima separem pelo mesmo código.
O detalhe da ordem é o que impede a migração de correr duas vezes: o if compara a versão do arquivo com a versão da migração, e a gravação da versão é a confirmação de que ela rodou. Como o número só anda para a frente, a única situação em que a migração 2 roda de novo é se ela não conseguiu gravar a versão 2.
Por que a versão se grava no fim
A ordem importa. Se o PRAGMA user_version = 2 for gravado antes do ALTER TABLE e o aparelho morrer no meio, o arquivo fica marcado como versão 2 sem ter a coluna — e a próxima abertura pula a migração, e o aplicativo quebra com "no such column" para sempre. Gravando a versão por último, a falha deixa o arquivo marcado como versão 1, e a próxima abertura tenta de novo.
É o que transforma a falha em migração pendente em vez de dano permanente. O aparelho morrer no meio de uma migração é raro, e o custo de proteger esse caso é uma linha na ordem certa.
A mesma razão exige que a migração inteira rode dentro de uma transação: ou o ALTER TABLE e a gravação da versão acontecem, ou nenhum dos dois. Com a transação, o arquivo não fica no estado intermediário em que a coluna não existe mas a versão já diz que existe.
O exemplo desta página mostra os quatro estados do arquivo. Na primeira execução, o arquivo nasce na versão 0 e as três migrações rodam em ordem, terminando na versão 3. Com o aplicativo reaberto já na versão 3, nada pendente roda e a tarefa do usuário continua como estava. Com o arquivo parado na versão 1, a segunda e a terceira migração rodam — que é o abrir e migrar depois de uma falha.
A alternativa: tabela de migrações
PRAGMA user_version guarda um número só. Quando a migração precisa de nome — "adicionei a coluna prioridade", "criei o índice por prazo" — a forma comum é uma tabela própria:
CREATE TABLE IF NOT EXISTS migracoes ( id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT NOT NULL UNIQUE, aplicada_em TEXT NOT NULL );
A consulta que decide o que falta é um SELECT nome FROM migracoes, e o que falta é tudo que está no código e não está nessa lista. Custa uma tabela a mais e dá nome, histórico e data — o que só importa depois da terceira migração.
O schema_version é o nome que a tabela costuma receber quando ela existe no lugar do PRAGMA, e a escolha entre os dois é de granularidade: o user_version sabe "a versão 3 está aplicada", e não sabe o que a versão 3 fez. Quando a pergunta passa a ser "por que meu aparelho está com a coluna e o do colega não", a tabela de migrações é a que responde — e o INSERT com nome e aplicada_em é o registro que responde.
O exemplo fecha mostrando esse registro: o nome de cada migração e a data em que foi aplicada, e o INSERT que grava uma delas.
Exemplo
// A migracao roda ao abrir o banco. `PRAGMA user_version` guarda, dentro // do proprio arquivo, qual versao do schema esta gravada. const BANCO_NOVO = 0; function abrirBanco(nomeArquivo) { // um "arquivo": o schema gravado e as linhas return { arquivo: nomeArquivo, userVersion: 0, colunas: [], linhas: [], registro: [], }; } // as migracoes do codigo, em ordem. Cada uma so roda se a versao do // arquivo for menor que a versao que ela aplica. const migracoes = [ { paraVersao: 1, nome: 'criar tabela tarefas', aplicar(banco) { banco.colunas = ['id', 'titulo', 'prazo', 'feita']; banco.linhas.push({ id: 1, titulo: 'Revisar o WHERE', prazo: '2026-09-10', feita: 0 }); banco.linhas.push({ id: 2, titulo: 'Ler o capitulo 4', prazo: '2026-09-12', feita: 1 }); }, }, { paraVersao: 2, nome: 'add column prioridade', aplicar(banco) { banco.colunas.push('prioridade'); banco.linhas = banco.linhas.map((linha) => ({ ...linha, prioridade: 0 })); }, }, { paraVersao: 3, nome: 'criar indice por prazo', aplicar(banco) { banco.registro.push('CREATE INDEX idx_tarefas_prazo ON tarefas (prazo);'); }, }, ]; function migrar(banco) { const rodada = []; for (const migracao of migracoes) { if (banco.userVersion >= migracao.paraVersao) { rodada.push(migracao.paraVersao + ': ja estava'); continue; } // o passo real e a gravacao da versao; se o aparelho morrer entre os // dois, a proxima abertura tenta de novo migracao.aplicar(banco); banco.userVersion = migracao.paraVersao; rodada.push(migracao.paraVersao + ': ' + migracao.nome); } return rodada; } // 1. primeira execucao: o arquivo nasce na versao 0 e roda tudo const novo = abrirBanco('app.db'); console.log('arquivo recem-criado, PRAGMA user_version =', novo.userVersion); console.log('rodou:', migrar(novo).join(' | ')); console.log('user_version depois:', novo.userVersion); console.log('colunas no arquivo:', novo.colunas.join(', ')); console.log('linhas preservadas:', novo.linhas.length, '| prioridade de cada uma:', novo.linhas.map((l) => l.prioridade)); // 2. segunda execucao: nada pendente, e o dado do usuario continua la const usuario = abrirBanco('app.db'); usuario.userVersion = 3; usuario.colunas = ['id', 'titulo', 'prazo', 'feita', 'prioridade']; usuario.linhas = [{ id: 7, titulo: 'Tarefa do usuario', prazo: '2026-10-01', feita: 0, prioridade: 2 }]; console.log('\naplicativo reaberto na versao 3:'); console.log('rodou:', migrar(usuario).join(' | ')); console.log('a tarefa do usuario continua:', usuario.linhas[0].titulo, '| prioridade', usuario.linhas[0].prioridade); // 3. a falha no meio: a versao antiga fica, e a proxima abertura tenta const pelaMetade = abrirBanco('app.db'); pelaMetade.userVersion = 1; pelaMetade.colunas = ['id', 'titulo', 'prazo', 'feita']; console.log('\narquivo parado na versao', pelaMetade.userVersion); console.log('rodou:', migrar(pelaMetade).join(' | ')); console.log('terminou na versao', pelaMetade.userVersion); // 4. a alternativa: tabela de migracoes, com nome e data const historico = []; for (const migracao of migracoes) { historico.push({ nome: migracao.nome, aplicada_em: '2026-09-05' }); } console.log('\ntabela de migracoes:'); for (const linha of historico) console.log(' ', linha.nome, '|', linha.aplicada_em); console.log('INSERT INTO migracoes (nome, aplicada_em) VALUES (?, ?);', JSON.stringify(historico[0].nome), "'2026-09-05'");
Saída real
arquivo recem-criado, PRAGMA user_version = 0 rodou: 1: criar tabela tarefas | 2: add column prioridade | 3: criar indice por prazo user_version depois: 3 colunas no arquivo: id, titulo, prazo, feita, prioridade linhas preservadas: 2 | prioridade de cada uma: [ 0, 0 ] aplicativo reaberto na versao 3: rodou: 1: ja estava | 2: ja estava | 3: ja estava a tarefa do usuario continua: Tarefa do usuario | prioridade 2 arquivo parado na versao 1 rodou: 1: ja estava | 2: add column prioridade | 3: criar indice por prazo terminou na versao 3 tabela de migracoes: criar tabela tarefas | 2026-09-05 add column prioridade | 2026-09-05 criar indice por prazo | 2026-09-05 INSERT INTO migracoes (nome, aplicada_em) VALUES (?, ?); "criar tabela tarefas" '2026-09-05'