Dia 7 — Modelar o dado
Chave primária e identidade da linha
Chave primária e identidade da linha
Toda tabela relacional precisa de uma coluna que identifique a linha, e essa coluna é a chave primária. Em SQLite ela se escreve como INTEGER PRIMARY KEY, e o efeito do tipo combinado é o AUTOINCREMENT: o banco passa a escolher o próximo número sozinho.
CREATE TABLE tarefas ( id INTEGER PRIMARY KEY AUTOINCREMENT, titulo TEXT NOT NULL, prazo TEXT NOT NULL, feita INTEGER NOT NULL DEFAULT 0 );
Com AUTOINCREMENT, o INSERT não escreve a coluna id: ele devolve o número escolhido no insertId, e a linha existe. Sem AUTOINCREMENT — só INTEGER PRIMARY KEY — o SQLite ainda preenche o id com o próximo inteiro livre, e a diferença é que ele pode reaproveitar o número de uma linha apagada. Com AUTOINCREMENT o número nunca volta, o que garante que o id da tarefa 7 nunca vá parar em outra tarefa.
O que a chave primária resolve é o identificador: a capacidade de dizer "esta linha" e nada mais. Um identificador que aceita repetição não é identificador — é um campo. A distinção importa em tudo que vem depois, porque UPDATE e DELETE são construídos sobre ela.
O exemplo desta página monta o CREATE TABLE a partir de uma descrição de colunas e mostra o insertId que o banco devolve a cada gravação: 1, 2, 3. Depois usa o id para localizar uma linha específica e compara com o resultado de filtrar por título, que pega duas.
Por que o id e necessário
O identificador é o que permite UPDATE, DELETE e WHERE apontarem para uma linha exata. Sem ele, a única forma de localizar uma tarefa é por conteúdo — e conteúdo muda. O aplicativo que filtra por título cria duas tarefas com o mesmo título, e passa a alterar as duas; o que apaga por título apaga tarefas que o usuário não escolheu.
É o por que id em uma frase: sem ele, o filtro do UPDATE e do DELETE deixa de ser exato, e a única alternativa é reescrever o comando errado para todo caso. A linha única que o id garante é o que permite que o filtro seja id = ? e que ele receba o valor da linha que o usuário tocou.
Uma tabela sem chave tem um comportamento que o SQL não proíbe e que o aplicativo sempre sofre: duas linhas com o mesmo conteúdo são indistinguíveis, e o UPDATE que altera "a linha do título X" altera as duas. O rowsAffected denuncia — ele volta 2 onde o aplicativo esperava 1, e esse 2 é o sinal de que o filtro está atrapalhando.
| Identificador | Comportamento |
|---|---|
INTEGER PRIMARY KEY AUTOINCREMENT | o banco gera, nunca repete, cresce |
INTEGER PRIMARY KEY | o banco gera, pode reaproveitar número apagado |
TEXT PRIMARY KEY com uuid | o aplicativo gera, formato é texto |
| sem chave | linhas iguais não se distinguem |
O uuid é a alternativa quando o identificador precisa existir antes de gravar — fila de sincronização, dado que vem de outro aparelho, importação que combina dois bancos. Em SQLite ele é gerado em JavaScript, porque o banco não tem gerador de uuid embutido.
A escolha entre o inteiro e o uuid é uma escolha de quando o identificador nasce. Com AUTOINCREMENT, ele nasce no momento da gravação, e o aplicativo só precisa do insertId. Com uuid, ele nasce antes — no botão, na fila, na tela de importação — e a gravação vira uma das muitas coisas que podem referenciar a linha.
O exemplo mostra o lado prático dessa escolha: a fila de sincronização do aplicativo gera os identificadores em JavaScript, e o console confirma que nenhum deles é número. É esse o teste prático — se a fila precisa existir antes de gravar, é uuid; se só precisa apontar para o que já está gravado, é inteiro.
Exemplo
// `INTEGER PRIMARY KEY AUTOINCREMENT`: o banco escolhe o proximo `id` e // nunca reaproveita o numero de uma linha apagada. O exemplo mostra a // diferenca entre os tres jeitos de identificar uma linha. function criarTabela(colunas) { const corpo = colunas .map((c) => c.nome + ' ' + c.tipo + (c.regra ? ' ' + c.regra : '')) .join(', '); return 'CREATE TABLE tarefas (' + corpo + ');'; } console.log('com AUTOINCREMENT:'); console.log(criarTabela([ { nome: 'id', tipo: 'INTEGER', regra: 'PRIMARY KEY AUTOINCREMENT' }, { nome: 'titulo', tipo: 'TEXT', regra: 'NOT NULL' }, ])); // o `insertId` e o numero que o banco escolheu let contador = 0; function inserir(banco, titulo) { contador += 1; banco.tarefas.push({ id: contador, titulo: titulo }); return { insertId: contador, rowsAffected: 1 }; } const banco = { tarefas: [] }; console.log('insertId da 1a gravacao:', inserir(banco, 'Revisar o WHERE').insertId); console.log('insertId da 2a gravacao:', inserir(banco, 'Ler o capitulo 4').insertId); console.log('insertId da 3a gravacao:', inserir(banco, 'Enviar o relatorio').insertId); // o `id` localiza a linha exata: duas tarefas podem ter o mesmo titulo banco.tarefas.push({ id: contador + 1, titulo: 'Revisar o WHERE' }); const alvo = banco.tarefas.filter((linha) => linha.titulo === 'Revisar o WHERE'); console.log('filtrar por titulo pega', alvo.length, 'linhas | por id pega 1'); // com AUTOINCREMENT o numero apagado nao volta const ultimoId = banco.tarefas[banco.tarefas.length - 1].id; const restantes = banco.tarefas.filter((linha) => linha.id !== ultimoId); console.log('apagou o id', ultimoId, '| o proximo insertId sera', ultimoId + 1); console.log('linhas que sobraram:', restantes.length, '| sem o id', ultimoId); // a alternativa de texto: o aplicativo gera o identificador function uuidSimples() { return 'tarefa-' + Math.random().toString(16).slice(2, 10); } const filaDeSincronizacao = [ { id: uuidSimples(), titulo: 'Sincronizar do servidor', pendente: 1 }, { id: uuidSimples(), titulo: 'Enviar pendencia', pendente: 1 }, ]; console.log('fila com id gerado pelo app:', filaDeSincronizacao.map((linha) => linha.id)); console.log('nenhum deles e numero:', filaDeSincronizacao.every((linha) => !/^\d+$/.test(linha.id)));
Saída real
com AUTOINCREMENT: CREATE TABLE tarefas (id INTEGER PRIMARY KEY AUTOINCREMENT, titulo TEXT NOT NULL); insertId da 1a gravacao: 1 insertId da 2a gravacao: 2 insertId da 3a gravacao: 3 filtrar por titulo pega 2 linhas | por id pega 1 apagou o id 4 | o proximo insertId sera 5 linhas que sobraram: 3 | sem o id 4 fila com id gerado pelo app: [ 'tarefa-bc8e4906', 'tarefa-b380193b' ] nenhum deles e numero: true
Tipos, tamanho e o que a coluna aceita
Tipos, tamanho e o que a coluna aceita
O SQLite é dinamicamente tipado: o tipo na definição da coluna é uma recomendação, e o valor que entra é gravado do jeito que vier. titulo TEXT aceita o número 123 e grava o inteiro. Nenhum erro aparece, a consulta volta a linha, e o aplicativo quebra mais adiante em titulo.toUpperCase().
O que essa falha tem de instrução é a ordem em que ela aparece. O INSERT deu certo, o SELECT deu certo, e o erro só aparece onde o aplicativo usa o valor como texto. Por isso a defesa não está na consulta: está no tipo de coluna que o aplicativo escolhe e no que ele grava.
Duas regras resolvem: NOT NULL e o tipo certo na escrita. NOT NULL proíbe a coluna vazia, e CHECK aceita a regra que for escrita:
CREATE TABLE tarefas ( id INTEGER PRIMARY KEY AUTOINCREMENT, titulo TEXT NOT NULL, prazo TEXT NOT NULL, feita INTEGER NOT NULL DEFAULT 0 CHECK (feita IN (0, 1)) );
| Tipo | Aceita | Exemplo |
|---|---|---|
INTEGER | inteiro de 64 bits | id, contagem, 0 ou 1 para booleano |
REAL | número com parte fracionária | nota, preço, percentual |
TEXT | qualquer sequência de caracteres | título, data em AAAA-MM-DD |
BLOB | sequência de bytes | imagem, arquivo |
Os quatro tipos são o vocabulário inteiro de tipo do SQLite, e não há tipo de data: data é TEXT com formato AAAA-MM-DD, e a ordenação funciona porque o formato ordena junto. BLOB é o tipo para o que não é texto, e o que o celular realmente grava nele é arquivo e imagem.
Valor padrão e coluna obrigatória
DEFAULT entra em ação quando o INSERT não escreve a coluna. INSERT INTO tarefas (titulo, prazo) VALUES (?, ?) numa tabela com feita INTEGER NOT NULL DEFAULT 0 grava feita igual a 0. Sem o DEFAULT, o mesmo INSERT falha, porque NOT NULL sem valor não tem o que gravar — e a falha aparece no catch, longe do INSERT que a causou.
A coluna obrigatória e o valor padrão são o par que resolve a mesma dificuldade por lados opostos. O NOT NULL diz que a coluna sempre vai ter valor; o DEFAULT diz qual valor entra quando ninguém escreveu. Uma coluna obrigatória sem padrão é uma coluna que obriga o aplicativo a escrever algo sempre, e é aí que aparece o DEFAULT 0 da coluna feita.
O exemplo desta página mostra os dois caminhos: o INSERT sem a coluna obrigatória é recusado com a mensagem de NOT NULL, e o INSERT com as duas colunas devolve uma linha gravada. O DEFAULT então aparece sozinho na leitura — a coluna que o INSERT não escreveu entra com 0.
O tamanho máximo não existe em SQLite: TEXT não tem limite declarado. O limite real é o espaço em disco e o tempo de leitura, e quem controla isso é o aplicativo, com validação antes de gravar.
O CHECK fecha o conjunto de regras que o banco aplica no momento da escrita, e ele é o que transforma "a coluna deveria ser 0 ou 1" em uma garantia. Sem ele, feita aceita 7, e o === 1 do mapeamento do dia 5 trata 7 como pendente sem reclamar. Com ele, o valor errado é recusado na hora da gravação, e o erro de tipo no banco aparece com o nome da coluna e o da regra.
typeof diz o que foi gravado
A função typeof(coluna) devolve 'integer', 'real', 'text', 'blob' ou 'null'. É a forma mais direta de descobrir que uma coluna guardou o tipo errado, e ela funciona sobre dados que já estão gravados:
SELECT id, titulo, typeof(titulo) FROM tarefas;
A função é a resposta para o defeito do começo da aula: se a coluna TEXT guardou um inteiro, typeof mostra 'integer', e a consulta funciona sobre a tabela inteira em vez de linha por linha. O exemplo desta página percorre as linhas e imprime o tipo de cada título, e termina gravando um 123 em titulo TEXT — o typeof devolve integer, e o toUpperCase quebra com TypeError. É o defeito completo, das duas pontas: o banco aceitou e o aplicativo caiu.
O nome id é uma convenção do SQLite: as colunas rowid, oid e _rowid_ existem em toda tabela sem WITHOUT ROWID. Quando INTEGER PRIMARY KEY está declarado, id é a própria chave primária; sem essa declaração, id é uma coluna comum e o aluno perde a geração automática sem perceber.
Exemplo
// O SQLite e dinamicamente tipado: o tipo da coluna e recomendacao, e o // valor entra como vier. `NOT NULL`, `DEFAULT` e `CHECK` sao as regras que // impedem o dado errado de chegar a tela. const banco = { tarefas: [ { 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 }, { id: 3, titulo: 'Comprar cafe', prazo: null, feita: 0 }, ], }; // `typeof`: o que foi de fato gravado na coluna function tipoDo(valor) { if (valor === null || valor === undefined) return 'null'; if (Number.isInteger(valor)) return 'integer'; if (typeof valor === 'number') return 'real'; if (typeof valor === 'string') return 'text'; return 'blob'; } console.log('id, titulo e o tipo REALMENTE gravado:'); for (const linha of banco.tarefas) { console.log(' ', linha.id, '|', linha.titulo, '-> typeof =', tipoDo(linha.titulo)); } // `TEXT NOT NULL`: gravar sem a coluna obrigatoria falha, e o `catch` // do aplicativo e quem ve a falha function inserir(colunas, valores) { const obrigatorias = { titulo: true, prazo: true }; for (const coluna of Object.keys(obrigatorias)) { if (!colunas.includes(coluna) && obrigatorias[coluna]) { throw new Error('NOT NULL constraint failed: ' + coluna); } } return { rowsAffected: 1, insertId: banco.tarefas.length + 1 }; } try { inserir(['titulo'], ['Sem prazo']); console.log('gravou (nao deveria ter gravado)'); } catch (erro) { console.log('INSERT sem a coluna obrigatoria:', erro.message); } const comPrazo = inserir(['titulo', 'prazo'], ['Com prazo', '2026-09-15']); console.log('INSERT com as duas:', comPrazo.rowsAffected, 'linha'); // `DEFAULT`: a coluna que o `INSERT` nao escreve entra com o valor padrao function valorDaColuna(linha, coluna) { if (coluna in linha) return linha[coluna]; return coluna === 'feita' ? 0 : null; } console.log('feita de quem o INSERT nao escreveu:', valorDaColuna({}, 'feita')); // `CHECK`: so os valores permitidos entram function confereCheck(valor) { if (valor !== 0 && valor !== 1) { throw new Error('CHECK constraint failed: feita'); } return valor; } console.log('CHECK aceita 0 e 1:', confereCheck(0), confereCheck(1)); try { confereCheck(7); } catch (erro) { console.log('CHECK recusa:', erro.message); } // e o tipo errado que passa sem erro: `TEXT` gravando um numero const linhaEstranha = { id: 4, titulo: 123, prazo: '2026-09-20', feita: 0 }; console.log('titulo gravado como numero:', linhaEstranha.titulo, '-> typeof =', tipoDo(linhaEstranha.titulo)); try { console.log(linhaEstranha.titulo.toUpperCase()); } catch (erro) { console.log('o aplicativo quebra tarde:', erro.constructor.name + ':', erro.message); }
Saída real
id, titulo e o tipo REALMENTE gravado: 1 | Revisar o WHERE -> typeof = text 2 | Ler o capitulo 4 -> typeof = text 3 | Comprar cafe -> typeof = text INSERT sem a coluna obrigatoria: NOT NULL constraint failed: prazo INSERT com as duas: 1 linha feita de quem o INSERT nao escreveu: 0 CHECK aceita 0 e 1: 0 1 CHECK recusa: CHECK constraint failed: feita titulo gravado como numero: 123 -> typeof = integer o aplicativo quebra tarde: TypeError: linhaEstranha.titulo.toUpperCase is not a function