Projeto: da Planilha ao Banco
A Norte Suprimentos, fornecedora da Loja Aurora, mandou por email a planilha dos produtos que vai começar a vender: oito linhas, salvas como CSV. A Marta quer os produtos cadastrados no banco até o fim do dia. Digitar um por um? Nem pensar. Neste projeto a Ana leva a planilha do arquivo até a tabela produtos, no terminal do sqlite3: importa, descobre que veio tudo bagunçado, limpa com SQL, converte os tipos, insere e, no fim, exporta um CSV de volta para conferência. É o trabalho mais comum do mundo com bancos de dados, e quase nunca dá certo de primeira.
- O que é um arquivo CSV e como criá-lo e conferi-lo no terminal.
- A importar com
.import --csv, e por que tudo chega como texto. - A limpar os problemas de toda planilha: espaços sobrando, vírgula decimal, células vazias, linhas repetidas, nomes que não batem.
- A converter os tipos com
CAST,REPLACE,TRIMeNULLIF. - A levar os dados limpos para a tabela de verdade com
INSERT INTO … SELECT. - A exportar de volta para CSV com
.headers,.mode csve.once.
Este projeto acontece em dois lugares. A importação e a exportação são comandos de ponto do programa sqlite3 (aula Comandos de Ponto), que só existem no terminal: os quadros pretos mostram a sessão real da Ana, no computador dela. A limpeza é SQL puro, e essa você pratica no editor do guia, em cada versão. A aula Importar e Exportar CSV explica os comandos em detalhe; aqui eles trabalham juntos num caso real.
A ideia: CSV é uma planilha em texto puro
CSV quer dizer comma-separated values, "valores separados por vírgula". É uma planilha sem cores nem fórmulas: cada linha do arquivo é uma linha da tabela, e as colunas são separadas por vírgula. A primeira linha, quase sempre, traz os nomes das colunas. Quando um valor tem vírgula dentro (como "6,90" ou "Caderno Brochura, 48 folhas"), ele vem entre aspas duplas, para a vírgula de dentro não ser confundida com a que separa colunas.
Qualquer planilha (LibreOffice, Excel, Google Planilhas) salva e abre CSV. É por isso que ele é a "língua franca" entre planilhas e bancos de dados.
Versão 1: o arquivo
A Ana salvou o anexo do email. Para você poder repetir tudo no seu computador, aqui está o arquivo criado direto no terminal, com cat e um here-document (tudo entre << 'FIM' e a linha FIM vai para o arquivo):
$ cat > novos-produtos.csv << 'FIM' > nome,categoria,fornecedor,preco,estoque > Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200 > Borracha Branca ,Papelaria,Norte Suprimentos,"1,20",350 > "Caderno Brochura, 48 folhas",Papelaria,Norte Suprimentos,"6,90",150 > Bola de Futebol,Esporte e Lazer,Norte Suprimentos,"59,90", > Peteca,esporte e lazer,Norte Suprimentos,"12,50",40 > Pá de Jardinagem,Jardim,Norte Suprimentos,"24,00",25 > Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200 > Ioiô Colorido,Brinquedo,Norte Suprimentos,"8,75",60 > FIM $ wc -l novos-produtos.csv 9 novos-produtos.csv « 1 cabeçalho + 8 produtos $ head -3 novos-produtos.csv nome,categoria,fornecedor,preco,estoque Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200 Borracha Branca ,Papelaria,Norte Suprimentos,"1,20",350
Olhe o arquivo com olhos de detetive antes de importar. Uma planilha feita por gente traz problemas que o banco não vai perdoar:
| Linha | O problema | Por que atrapalha |
|---|---|---|
| todas | preço com vírgula decimal: "3,49" | Para o SQL, o separador decimal é o ponto. 3,49 não é um número. |
| Borracha | espaços antes e depois do nome | ' Borracha Branca ' é diferente de 'Borracha Branca' numa busca. |
| Caderno | vírgula dentro do nome, entre aspas | Só um leitor de CSV de verdade entende que é uma coluna só. |
| Bola | estoque vazio | Vazio não é zero, nem NULL: é um texto vazio. |
| Peteca | categoria em minúsculas | 'esporte e lazer' não é igual a 'Esporte e Lazer'. |
| Caneta (2ª vez) | linha repetida | Viraria dois produtos iguais. |
| Ioiô | categoria que não existe: "Brinquedo" | Na Aurora, a categoria é "Brinquedos", no plural. |
A estratégia profissional é sempre a mesma: importar primeiro para uma tabela de passagem (em inglês, staging), do jeito que vier; limpar e conferir lá, com SQL; e só então copiar para a tabela de verdade. A tabela produtos nunca vê a bagunça.
Versão 2: importar para a tabela de passagem
A Ana abre o banco da loja e importa:
$ sqlite3 loja.db SQLite version 3.50.4 2025-07-30 19:33:53 Enter ".help" for usage hints. sqlite> .import --csv novos-produtos.csv importados sqlite> .schema importados CREATE TABLE IF NOT EXISTS "importados"( "nome" TEXT, "categoria" TEXT, "fornecedor" TEXT, "preco" TEXT, "estoque" TEXT); sqlite> SELECT COUNT(*) FROM importados; 8
- .import
- Comando de ponto do
sqlite3: lê um arquivo e põe as linhas numa tabela. Em silêncio: se deu certo, não escreve nada. - --csv
- Lê o arquivo como CSV de verdade: vírgula separa colunas, aspas protegem vírgulas de dentro. Sem esta opção, o resultado é um desastre (veja Quando dá errado).
- novos-produtos.csv
- O arquivo, na pasta atual.
- importados
- A tabela de destino. Como ela não existia, o
sqlite3criou uma, usando a primeira linha do arquivo como nomes das colunas. Por isso vieram 8 linhas, não 9: o cabeçalho virou nome de coluna. - .schema importados
- Mostra o
CREATE TABLEque o.importescreveu. Repare: todas as colunas sãoTEXT. O.importnão adivinha tipos.
Agora, a tabela inteira. O modo box desenha uma moldura em volta de cada coluna, o que facilita ler tabelas no terminal:
sqlite> .mode box sqlite> SELECT * FROM importados; ┌─────────────────────────────┬─────────────────┬───────────────────┬───────┬─────────┐ │ nome │ categoria │ fornecedor │ preco │ estoque │ ├─────────────────────────────┼─────────────────┼───────────────────┼───────┼─────────┤ │ Caneta Gel Preta │ Papelaria │ Norte Suprimentos │ 3,49 │ 200 │ │ Borracha Branca │ Papelaria │ Norte Suprimentos │ 1,20 │ 350 │ │ Caderno Brochura, 48 folhas │ Papelaria │ Norte Suprimentos │ 6,90 │ 150 │ │ Bola de Futebol │ Esporte e Lazer │ Norte Suprimentos │ 59,90 │ │ │ Peteca │ esporte e lazer │ Norte Suprimentos │ 12,50 │ 40 │ │ Pá de Jardinagem │ Jardim │ Norte Suprimentos │ 24,00 │ 25 │ │ Caneta Gel Preta │ Papelaria │ Norte Suprimentos │ 3,49 │ 200 │ │ Ioiô Colorido │ Brinquedo │ Norte Suprimentos │ 8,75 │ 60 │ └─────────────────────────────┴─────────────────┴───────────────────┴───────┴─────────┘ sqlite> SELECT nome, preco, typeof(preco), estoque, typeof(estoque) FROM importados WHERE rowid <= 4; ┌─────────────────────────────┬───────┬───────────────┬─────────┬─────────────────┐ │ nome │ preco │ typeof(preco) │ estoque │ typeof(estoque) │ ├─────────────────────────────┼───────┼───────────────┼─────────┼─────────────────┤ │ Caneta Gel Preta │ 3,49 │ text │ 200 │ text │ │ Borracha Branca │ 1,20 │ text │ 350 │ text │ │ Caderno Brochura, 48 folhas │ 6,90 │ text │ 150 │ text │ │ Bola de Futebol │ 59,90 │ text │ │ text │ └─────────────────────────────┴───────┴───────────────┴─────────┴─────────────────┘ sqlite> SELECT preco * 2 FROM importados WHERE rowid = 1; ┌───────────┐ │ preco * 2 │ ├───────────┤ │ 6 │ └───────────┘
O --csv fez o trabalho dele: o Caderno, com a vírgula no nome, ficou numa coluna só, e as aspas sumiram. Mas o typeof confirma: o preço 3,49 é texto, e o estoque 200 também. Nas contas, o SQLite aproveita só o começo do texto que parece número: '3,49' * 2 deu 6, porque ele leu o 3 e parou na vírgula (aula Os Tipos no SQLite). Sem erro, sem aviso: é o tipo de coisa que só aparece no fechamento do caixa.
O editor do guia não tem comandos de ponto, então não roda .import. As caixas desta página criam a tabela importados exatamente como o .import a deixou: cinco colunas TEXT, com os textos do arquivo, espaços e vírgulas incluídos. O SQL de limpeza que você escrever ali funciona igual no terminal.
Missão: antes de limpar, a Ana quer saber se há produtos repetidos na planilha. Agrupe por nome e mostre só os que aparecem mais de uma vez, com o nome e quantas vezes (aulas GROUP BY e HAVING). Use TRIM(nome), para que espaços sobrando não escondam uma repetição.
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT nome, categoria, preco FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT TRIM(nome) AS nome, COUNT(*) AS vezes
FROM importados
GROUP BY TRIM(nome)
HAVING COUNT(*) > 1;
A Caneta Gel Preta veio duas vezes, com todos os campos iguais: alguém copiou a linha duas vezes na planilha. A versão 5 resolve com DISTINCT.
Versão 3: limpar e converter
Cada problema da planilha tem uma função de texto que resolve (aula Funções de Texto). A Ana testa uma de cada vez, no terminal, antes de mudar qualquer coisa. O .nullvalue NULL do começo faz o NULL aparecer escrito na tela, para não ser confundido com um texto vazio:
sqlite> .nullvalue NULL sqlite> SELECT '[' || nome || ']' AS nome, '[' || TRIM(nome) || ']' AS limpo FROM importados WHERE rowid = 2; ┌──────────────────────┬───────────────────┐ │ nome │ limpo │ ├──────────────────────┼───────────────────┤ │ [ Borracha Branca ] │ [Borracha Branca] │ └──────────────────────┴───────────────────┘ sqlite> SELECT preco, REPLACE(preco, ',', '.') AS com_ponto, CAST(REPLACE(preco, ',', '.') AS REAL) AS numero FROM importados WHERE rowid <= 3; ┌───────┬───────────┬────────┐ │ preco │ com_ponto │ numero │ ├───────┼───────────┼────────┤ │ 3,49 │ 3.49 │ 3.49 │ │ 1,20 │ 1.20 │ 1.2 │ │ 6,90 │ 6.90 │ 6.9 │ └───────┴───────────┴────────┘ sqlite> SELECT nome, estoque, typeof(estoque), NULLIF(estoque, '') AS sem_vazio FROM importados WHERE rowid = 4; ┌─────────────────┬─────────┬─────────────────┬───────────┐ │ nome │ estoque │ typeof(estoque) │ sem_vazio │ ├─────────────────┼─────────┼─────────────────┼───────────┤ │ Bola de Futebol │ │ text │ NULL │ └─────────────────┴─────────┴─────────────────┴───────────┘
- '[' || nome || ']'
- O
||junta textos. Os colchetes são um truque para ver espaços, que na tela são invisíveis:[ Borracha Branca ]mostra dois espaços antes e um depois. - TRIM(nome)
- Tira os espaços do começo e do fim (do meio, não).
- REPLACE(preco, ',', '.')
- Troca a vírgula pelo ponto. O resultado ainda é texto:
'1.20', com o zero. - CAST(… AS REAL)
- Converte o texto em número de verdade:
1.2. Agora as contas funcionam. - NULLIF(estoque, '')
- "Se for igual a
'', devolvaNULL." O texto vazio vira "sem valor", que é o que ele quer dizer (aula O Valor NULL).
Missão: escreva a consulta de limpeza completa: as 8 linhas com três colunas chamadas nome (sem os espaços), preco (número REAL, com ponto) e estoque (número INTEGER, ou NULL quando vier vazio). Junte as funções de dentro para fora: CAST(NULLIF(TRIM(estoque), '') AS INTEGER).
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT nome, preco, estoque
FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT TRIM(nome) AS nome,
CAST(REPLACE(preco, ',', '.') AS REAL) AS preco,
CAST(NULLIF(TRIM(estoque), '') AS INTEGER) AS estoque
FROM importados;
Por que não CAST(estoque AS INTEGER) direto? Porque CAST('' AS INTEGER) dá 0, e um estoque "não informado" viraria "zero em estoque" sem ninguém decidir isso. Com o NULLIF antes, o vazio vira NULL, e a decisão fica para você (na versão 5, a Ana decide que é 0, de propósito, com COALESCE).
Versão 4: as categorias batem?
A tabela produtos não guarda o nome da categoria, e sim o categoria_id. Então cada texto da planilha tem de encontrar a sua categoria na tabela categorias da Aurora. Antes de inserir, a pergunta certa é: alguma não vai encontrar? É o LEFT JOIN … IS NULL de sempre (aula LEFT JOIN):
sqlite> SELECT DISTINCT i.categoria FROM importados i LEFT JOIN categorias c ON c.nome = i.categoria WHERE c.id IS NULL; ┌─────────────────┐ │ categoria │ ├─────────────────┤ │ esporte e lazer │ │ Brinquedo │ └─────────────────┘
Duas categorias sem par. A "esporte e lazer" é só a caixa das letras: comparando com COLLATE NOCASE, o SQLite ignora maiúsculas e minúsculas, e ela encontra a "Esporte e Lazer". O "Brinquedo" é outro problema: a palavra é diferente, e só um UPDATE na tabela de passagem resolve.
sqlite> SELECT DISTINCT i.categoria FROM importados i LEFT JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE WHERE c.id IS NULL; ┌───────────┐ │ categoria │ ├───────────┤ │ Brinquedo │ └───────────┘ sqlite> UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo'; sqlite> SELECT changes(); ┌───────────┐ │ changes() │ ├───────────┤ │ 1 │ └───────────┘
O changes() conta as linhas que o último comando mudou: 1, o Ioiô. Corrigir na tabela de passagem é seguro: o arquivo original continua intacto, e a tabela produtos nem foi tocada.
Missão: no editor, a Aurora completa está carregada, com a tabela categorias de verdade. A consulta abaixo acusa duas categorias sem par. Faça a comparação ignorar maiúsculas e minúsculas, para sobrar só o problema de verdade.
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT DISTINCT i.categoria
FROM importados i
LEFT JOIN categorias c ON c.nome = i.categoria
WHERE c.id IS NULL;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT DISTINCT i.categoria
FROM importados i
LEFT JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE
WHERE c.id IS NULL;
Atenção: o NOCASE do SQLite só iguala as letras sem acento. Uma planilha com "ELETRÔNICOS" não encontraria "Eletrônicos" por causa do "Ô". Para esses casos, a saída é o UPDATE, como no Brinquedo.
Versão 5: para a tabela de verdade
Tudo conferido. Agora um único comando copia da tabela de passagem para produtos, limpando e convertendo no caminho: o INSERT INTO … SELECT (aula INSERT INTO SELECT). A Ana abre o banco de novo e, antes de tudo, liga as chaves estrangeiras, que no terminal vêm desligadas em cada conexão nova (aula PRAGMAs Úteis):
$ sqlite3 loja.db SQLite version 3.50.4 2025-07-30 19:33:53 Enter ".help" for usage hints. sqlite> PRAGMA foreign_keys = ON; sqlite> INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque) ...> SELECT DISTINCT TRIM(i.nome), c.id, f.id, CAST(REPLACE(i.preco, ',', '.') AS REAL), COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0) ...> FROM importados i ...> JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE ...> JOIN fornecedores f ON f.nome = i.fornecedor; sqlite> SELECT changes(); 7 sqlite> .mode box sqlite> SELECT p.id, p.nome, c.nome AS categoria, p.preco, typeof(p.preco) AS tipo, p.estoque FROM produtos p JOIN categorias c ON c.id = p.categoria_id WHERE p.id > 45; ┌────┬─────────────────────────────┬─────────────────┬───────┬──────┬─────────┐ │ id │ nome │ categoria │ preco │ tipo │ estoque │ ├────┼─────────────────────────────┼─────────────────┼───────┼──────┼─────────┤ │ 46 │ Caneta Gel Preta │ Papelaria │ 3.49 │ real │ 200 │ │ 47 │ Borracha Branca │ Papelaria │ 1.2 │ real │ 350 │ │ 48 │ Caderno Brochura, 48 folhas │ Papelaria │ 6.9 │ real │ 150 │ │ 49 │ Bola de Futebol │ Esporte e Lazer │ 59.9 │ real │ 0 │ │ 50 │ Peteca │ Esporte e Lazer │ 12.5 │ real │ 40 │ │ 51 │ Pá de Jardinagem │ Jardim │ 24.0 │ real │ 25 │ │ 52 │ Ioiô Colorido │ Brinquedos │ 8.75 │ real │ 60 │ └────┴─────────────────────────────┴─────────────────┴───────┴──────┴─────────┘ sqlite> SELECT COUNT(*) FROM produtos; ┌──────────┐ │ COUNT(*) │ ├──────────┤ │ 52 │ └──────────┘ sqlite> DROP TABLE importados;
- INSERT INTO produtos (…) SELECT …
- Em vez de
VALUES, as linhas vêm de uma consulta. As colunas doSELECTentram, na ordem, nas colunas listadas. - SELECT DISTINCT
- As duas Canetas Gel são idênticas depois da limpeza, então entram uma vez só. 8 linhas na planilha, 7 produtos novos.
- JOIN categorias c ON … COLLATE NOCASE
- Troca o nome da categoria pelo
iddela. - JOIN fornecedores f ON f.nome = i.fornecedor
- O mesmo para o fornecedor: Norte Suprimentos é o id 6.
- COALESCE(…, 0)
- A decisão sobre o estoque vazio: a Bola de Futebol entra com 0, porque o fornecedor ainda não mandou nenhuma (aula IFNULL e COALESCE). A coluna
estoqueda Aurora éNOT NULL; sem oCOALESCE, oNULLseria recusado. - o id
- Não foi informado: o SQLite continuou a numeração, do 46 ao 52.
- DROP TABLE importados
- A tabela de passagem cumpriu o seu papel e vai embora.
Repare na coluna tipo: agora o preço é real de verdade. A Aurora passou de 45 para 52 produtos.
Missão: faça a importação inteira no editor. A caixa já tem a tabela de passagem e, no fim, a consulta de conferência. Falta o meio: corrija o "Brinquedo" com um UPDATE e escreva o INSERT INTO produtos … SELECT com a limpeza, o DISTINCT e os dois JOIN. Se a Aurora terminar com 52 produtos e o Ioiô estiver em Brinquedos, deu certo.
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- 1. corrigir a categoria que não existe
-- 2. copiar para produtos, limpando no caminho
-- conferência
SELECT p.nome, c.nome AS categoria, p.preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- 1. corrigir a categoria que não existe
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
-- 2. copiar para produtos, limpando no caminho
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome),
c.id,
f.id,
CAST(REPLACE(i.preco, ',', '.') AS REAL),
COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;
-- conferência
SELECT p.nome, c.nome AS categoria, p.preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;
Esqueça o UPDATE e rode: o Ioiô simplesmente não entra, sem erro, porque o JOIN com categorias não acha par para "Brinquedo" e descarta a linha. Entram 6 produtos, e a Aurora fica com 51. Por isso a conferência da versão 4 vem antes: um JOIN que descarta linhas em silêncio é o jeito mais fácil de perder dados numa importação.
Versão 6: exportar de volta para CSV
A Marta quer conferir na planilha o que entrou. O caminho de volta usa três comandos de ponto antes do SELECT:
sqlite> .headers on
sqlite> .mode csv
sqlite> .once produtos-novos.csv
sqlite> SELECT p.id, p.nome, c.nome AS categoria, printf('%.2f', p.preco) AS preco, p.estoque FROM produtos p JOIN categorias c ON c.id = p.categoria_id WHERE p.id > 45;
sqlite> .quit
$ cat produtos-novos.csv
id,nome,categoria,preco,estoque
46,"Caneta Gel Preta",Papelaria,3.49,200
47,"Borracha Branca",Papelaria,1.20,350
48,"Caderno Brochura, 48 folhas",Papelaria,6.90,150
49,"Bola de Futebol","Esporte e Lazer",59.90,0
50,Peteca,"Esporte e Lazer",12.50,40
51,"Pá de Jardinagem",Jardim,24.00,25
52,"Ioiô Colorido",Brinquedos,8.75,60
- .headers on
- A primeira linha do arquivo sai com os nomes das colunas.
- .mode csv
- A saída passa a ser CSV: vírgulas entre as colunas e aspas onde precisa. O
sqlite3põe aspas em todo texto que tem espaço ou vírgula; "Peteca" e "Papelaria" saíram sem. - .once produtos-novos.csv
- A saída do próximo comando vai para o arquivo, e não para a tela. Depois dele, tudo volta para a tela. (Para mandar vários comandos para um arquivo, existe o
.output arquivo, que vale até um.outputsem nome.) - printf('%.2f', p.preco)
- Preço com 2 casas:
1.20, e não1.2. Numa planilha de preços, fica mais legível.
Repare que a tela não mostrou nada depois do SELECT: a saída foi toda para o arquivo, e o cat confirma. Abra produtos-novos.csv numa planilha e as colunas se separam sozinhas.
O LibreOffice e o Excel configurados para o Brasil às vezes esperam ponto e vírgula como separador, porque a vírgula é o separador decimal. O LibreOffice pergunta o separador ao abrir o arquivo. Se precisar, troque o separador depois do .mode csv:
sqlite> .mode csv
sqlite> .separator ";"
sqlite> .headers on
sqlite> SELECT id, nome, printf('%.2f', preco) AS preco FROM produtos WHERE id IN (48, 52);
id;nome;preco
48;"Caderno Brochura, 48 folhas";6.90
52;"Ioiô Colorido";8.75
Missão: o editor não grava arquivos, mas a consulta da exportação é SQL e pode ser preparada aqui. Depois da importação (já feita na caixa), escreva o SELECT da planilha de conferência: os 7 produtos novos com as colunas exatamente id, nome, categoria (o nome), preco (com 2 casas, via printf) e estoque.
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome), c.id, f.id,
CAST(REPLACE(i.preco, ',', '.') AS REAL),
COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;
-- a consulta da exportação
SELECT * FROM produtos WHERE id > 45;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome), c.id, f.id,
CAST(REPLACE(i.preco, ',', '.') AS REAL),
COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;
-- a consulta da exportação
SELECT p.id, p.nome, c.nome AS categoria,
printf('%.2f', p.preco) AS preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;
Formas diferentes de importar
A Ana importou para uma tabela nova, toda TEXT. Outra ideia comum é criar antes uma tabela com tipos e importar direto nela. Veja o que acontece:
sqlite> CREATE TABLE tipada (nome TEXT, categoria TEXT, fornecedor TEXT, preco REAL, estoque INTEGER); sqlite> .import --csv --skip 1 novos-produtos.csv tipada sqlite> .mode box sqlite> .nullvalue NULL sqlite> SELECT nome, preco, typeof(preco), estoque, typeof(estoque) FROM tipada WHERE rowid IN (1, 4); ┌──────────────────┬───────┬───────────────┬─────────┬─────────────────┐ │ nome │ preco │ typeof(preco) │ estoque │ typeof(estoque) │ ├──────────────────┼───────┼───────────────┼─────────┼─────────────────┤ │ Caneta Gel Preta │ 3,49 │ text │ 200 │ integer │ │ Bola de Futebol │ 59,90 │ text │ │ text │ └──────────────────┴───────┴───────────────┴─────────┴─────────────────┘
O --skip 1 pula a linha do cabeçalho (a tabela já tem nomes de coluna). O estoque 200 virou integer: a coluna tipada converte o que parece número. Mas 3,49 não parece, e ficou texto numa coluna REAL; o estoque vazio ficou texto vazio numa coluna INTEGER. O SQLite guarda o que não consegue converter (aula Os Tipos no SQLite). Ou seja: tipos não limpam dados. A limpeza continua sendo com você.
| Forma | Comando | Quando usar |
|---|---|---|
| Tabela nova, tudo texto | .import --csv arquivo.csv importados | Arquivo de fora, que você não conhece. Importa sem perder nada e limpa depois com SQL. A mais segura. |
| Tabela já criada, com tipos | .import --csv --skip 1 arquivo.csv tabela | Arquivo que você mesmo exportou, já limpo (ponto decimal, sem vazios). |
| Direto na tabela final | .import --csv --skip 1 arquivo.csv produtos | Quase nunca: o CSV precisaria ter exatamente as colunas da tabela, na ordem, e nenhum erro. |
| Pela planilha | a planilha gera INSERTs com fórmulas | Pouca coisa, uma vez só. Frágil com aspas e vírgulas. |
Quando dá errado
--csv
sqlite> .import novos-produtos.csv teste novos-produtos.csv:4: unescaped " character novos-produtos.csv:4: unescaped " character novos-produtos.csv:4: unescaped " character novos-produtos.csv:5: unescaped " character novos-produtos.csv:5: unescaped " character novos-produtos.csv:6: unescaped " character novos-produtos.csv:6: unescaped " character novos-produtos.csv:7: unescaped " character novos-produtos.csv:7: unescaped " character novos-produtos.csv:8: unescaped " character novos-produtos.csv:8: unescaped " character novos-produtos.csv:9: unescaped " character novos-produtos.csv:9: unescaped " character novos-produtos.csv:4: unterminated "-quoted field sqlite> .schema teste CREATE TABLE IF NOT EXISTS "teste"( "nome,categoria,fornecedor,preco,estoque" TEXT); sqlite> SELECT COUNT(*) FROM teste; 3
O que aconteceu: sem --csv, o .import usa o modo atual do sqlite3, que por padrão separa colunas com |, e não com vírgula. Ele não achou nenhum |, então a linha inteira virou uma coluna só, com o nome esquisito "nome,categoria,fornecedor,preco,estoque". As aspas no meio das linhas geraram uma chuva de avisos (unescaped " character: "caractere de aspas sem escape"; unterminated "-quoted field: "campo entre aspas que não termina"), e só 3 linhas entraram, embaralhadas.
Como consertar: DROP TABLE teste; e importe de novo com --csv. (Outra forma: .mode csv antes do .import faz o mesmo efeito.)
sqlite> .import --csv novos_produtos.csv importados Error: cannot open "novos_produtos.csv"
O que aconteceu: "não consigo abrir": o arquivo se chama novos-produtos.csv, com hífen. O sqlite3 procura o arquivo na pasta onde você abriu o programa.
Como consertar: confira o nome com ls antes de abrir o sqlite3, ou use o caminho completo: .import --csv /home/ana/novos-produtos.csv importados.
sqlite> CREATE TABLE sem_skip (nome TEXT, categoria TEXT, fornecedor TEXT, preco REAL, estoque INTEGER); sqlite> .import --csv novos-produtos.csv sem_skip sqlite> SELECT nome, preco FROM sem_skip WHERE rowid = 1; ┌──────┬───────┐ │ nome │ preco │ ├──────┼───────┤ │ nome │ preco │ └──────┴───────┘
O que aconteceu: quando a tabela já existe, o .import trata a primeira linha do arquivo como dado. Agora existe um "produto" chamado nome, com preço preco.
Como consertar: importe para tabela existente sempre com --skip 1. Se já aconteceu: DELETE FROM sem_skip WHERE nome = 'nome';.
Já visto na versão 2, mas é o erro mais caro de todos: '3,49' * 2 dá 6. Se a Ana tivesse inserido os preços sem o REPLACE, a coluna preco teria textos, e cada conta usaria só a parte antes da vírgula. Teste no editor:
SELECT '3,49' * 2 AS sem_limpar,
CAST(REPLACE('3,49', ',', '.') AS REAL) * 2 AS limpo,
typeof('3,49') AS tipo;
Como consertar: REPLACE(preco, ',', '.') e CAST(… AS REAL), sempre, antes de levar para a tabela final. E confira o resultado com typeof.
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
SELECT COUNT(*) AS linhas_na_planilha FROM importados;
SELECT COUNT(*) AS linhas_que_entrariam
FROM (SELECT DISTINCT TRIM(i.nome)
FROM importados i
JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE);
O que aconteceu: 8 linhas na planilha, mas só 6 entrariam: uma é a Caneta repetida (essa é de propósito), e a outra é o Ioiô, cuja categoria "Brinquedo" não existe. O JOIN descarta, sem erro, a linha que não acha par.
Como consertar: antes de todo INSERT … SELECT com JOIN, conte: as linhas que vão entrar batem com as que você esperava? Se não, procure as órfãs com LEFT JOIN … IS NULL (versão 4).
O .import é do programa sqlite3; cada banco tem o seu jeito de ler CSV, e a estratégia da tabela de passagem vale para todos. Trechos ilustrativos, que não rodam no editor:
-- PostgreSQL (no psql, o arquivo é lido do seu computador) \copy importados FROM 'novos-produtos.csv' WITH (FORMAT csv, HEADER true) -- MySQL / MariaDB LOAD DATA LOCAL INFILE 'novos-produtos.csv' INTO TABLE importados FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES; -- SQL Server BULK INSERT importados FROM 'C:\dados\novos-produtos.csv' WITH (FORMAT = 'CSV', FIRSTROW = 2);
Nos três, a tabela de destino precisa existir antes. Mais em As Diferenças entre os Bancos.
Referência rápida
| Passo | Comando |
|---|---|
| Importar para tabela nova (cabeçalho vira nome de coluna) | .import --csv arquivo.csv importados |
| Importar para tabela existente | .import --csv --skip 1 arquivo.csv tabela |
Ver o que o .import criou | .schema importados |
| Ver o tipo guardado | SELECT typeof(coluna) … |
| Ver espaços escondidos | '[' || coluna || ']' |
| Tirar espaços | TRIM(coluna) |
| Vírgula decimal → número | CAST(REPLACE(preco, ',', '.') AS REAL) |
| Vazio → NULL | NULLIF(TRIM(coluna), '') |
| Achar repetidos | GROUP BY TRIM(nome) HAVING COUNT(*) > 1 |
| Achar nomes sem par | LEFT JOIN … ON … COLLATE NOCASE WHERE c.id IS NULL |
| Copiar limpando | INSERT INTO produtos (…) SELECT DISTINCT … |
| Exportar | .headers on + .mode csv + .once arquivo.csv + SELECT … |
| Separador ponto e vírgula | .separator ";" depois do .mode csv |
Exercícios
1. Importar de verdade
Complete para importar o arquivo como CSV:
2. Vazio não é zero
Complete para transformar o texto vazio em NULL:
3. Depois de .import --csv novos-produtos.csv importados (tabela nova), qual é o tipo das colunas?
4. Quanto dá SELECT '3,49' * 2; no SQLite?
5. A planilha tinha 8 linhas, mas o INSERT … SELECT com JOIN categorias inseriu 6. O que é mais provável?
6. Para que serve o .once relatorio.csv?
Desafio: o relatório de preços da planilha
Missão: antes de importar, a Marta quer saber quantos produtos diferentes a planilha traz e quanto custaria comprar uma unidade de cada. Direto na tabela de passagem, mostre uma linha com produtos (a contagem dos nomes distintos, já sem espaços) e total (a soma dos preços convertidos, de cada produto uma vez, arredondada em 2 casas).
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
SELECT COUNT(*) AS produtos, SUM(preco) AS total
FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
(' Borracha Branca ', 'Papelaria', 'Norte Suprimentos', '1,20', '350'),
('Caderno Brochura, 48 folhas', 'Papelaria', 'Norte Suprimentos', '6,90', '150'),
('Bola de Futebol', 'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca', 'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem', 'Jardim', 'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta', 'Papelaria', 'Norte Suprimentos', '3,49', '200'),
('Ioiô Colorido', 'Brinquedo', 'Norte Suprimentos', '8,75', '60');
-- a consulta
WITH limpos AS (
SELECT DISTINCT TRIM(nome) AS nome,
CAST(REPLACE(preco, ',', '.') AS REAL) AS preco
FROM importados
)
SELECT COUNT(*) AS produtos, ROUND(SUM(preco), 2) AS total
FROM limpos;
Rode o código inicial antes de resolver: ele diz 8 produtos e um total de 116.0, porque conta a Caneta duas vezes e soma só a parte inteira de cada preço (3 + 1 + 6 + 59…). São 7 produtos e 116.74. A diferença parece pequena aqui; numa planilha de mil linhas, não seria.
Resumo
- CSV é uma planilha em texto: vírgula separa colunas, aspas protegem as vírgulas de dentro, a primeira linha costuma ser o cabeçalho.
- Importe com
.import --csvpara uma tabela de passagem. Numa tabela nova, o cabeçalho vira nome de coluna e tudo viraTEXT; numa tabela existente, use--skip 1. - Limpe com SQL:
TRIMpara espaços,REPLACE(…, ',', '.')+CAST(… AS REAL)para a vírgula decimal,NULLIF(…, '')para células vazias,DISTINCTpara linhas repetidas. - Confira antes de inserir:
typeofpara os tipos,LEFT JOIN … IS NULLpara nomes sem par,COUNTpara as linhas que vão entrar. UmJOINdescarta órfãs em silêncio. - Leve para a tabela final com
INSERT INTO … SELECT, comPRAGMA foreign_keys = ONno terminal, e apague a tabela de passagem. - Exporte com
.headers on,.mode csve.once arquivo.csv; para planilhas em português,.separator ";".