Normalização é o processo de organizar um schema relacional para eliminar redundância e proteger a integridade dos dados. Uma tabela não normalizada guarda o mesmo facto em múltiplos sítios — quando esse facto muda, é necessário actualizar várias linhas; se alguma ficar para trás, o schema fica inconsistente. As formas normais (1NF, 2NF, 3NF, BCNF) são regras progressivas que eliminam sistematicamente esses problemas. O objectivo não é chegar à forma mais alta possível, mas ao equilíbrio certo entre integridade e performance.
-- Tabela não normalizada — todos os dados numa só tabela "plana":
--
-- ENCOMENDA_FLAT
-- ┌──────┬────────────┬───────────────────┬───────────────┬──────────┬───────────────┬──────────┐
-- │ enc_ │ cliente_id │ cliente_email │ cliente_pais │ prod_id │ prod_nome │ qtd │
-- │ id │ │ │ │ │ │ │
-- ├──────┼────────────┼───────────────────┼───────────────┼──────────┼───────────────┼──────────┤
-- │ 1 │ 42 │ joao@example.com │ PT │ 10 │ Teclado Mec. │ 1 │
-- │ 1 │ 42 │ joao@example.com │ PT │ 11 │ Rato Wireless │ 2 │
-- │ 2 │ 42 │ joao@example.com │ PT │ 10 │ Teclado Mec. │ 1 │
-- │ 3 │ 99 │ maria@example.com │ ES │ 12 │ Monitor 4K │ 1 │
-- └──────┴────────────┴───────────────────┴───────────────┴──────────┴───────────────┴──────────┘
-- Problemas desta estrutura:
-- Anomalia de ACTUALIZAÇÃO:
-- O João muda o email → precisamos actualizar TODAS as linhas com cliente_id = 42
-- Se uma linha ficar com o email antigo → inconsistência silenciosa
-- Anomalia de INSERÇÃO:
-- Não podemos inserir um novo produto sem associá-lo a uma encomenda
-- O produto só "existe" quando aparece numa encomenda
-- Anomalia de ELIMINAÇÃO:
-- Se eliminarmos a encomenda 3, perdemos toda a informação do cliente 99 (Maria)
-- A eliminação de um facto destrói outro facto independente
Uma tabela está em 1NF quando cada coluna contém valores atómicos (indivisíveis), cada linha é única (existe uma chave primária), e não existem grupos repetidos de colunas. A violação mais comum é armazenar múltiplos valores numa única célula.
-- Violação de 1NF — valores não atómicos:
--
-- PRODUTO
-- ┌────┬──────────────┬─────────────────────────────┐
-- │ id │ nome │ tags │
-- ├────┼──────────────┼─────────────────────────────┤
-- │ 1 │ Teclado Mec. │ "electrónica, periférico" │ ← múltiplos valores numa célula
-- │ 2 │ Monitor 4K │ "electrónica, monitor, 4K" │
-- └────┴──────────────┴─────────────────────────────┘
--
-- Problema: como fazer WHERE tags = 'electrónica' de forma eficiente?
-- Como contar produtos por tag? Impossível sem string parsing.
-- Solução 1 — tabela separada (correcta para relação M:N):
CREATE TABLE tag (
id SERIAL PRIMARY KEY,
nome VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE produto_tag (
produto_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (produto_id, tag_id),
FOREIGN KEY (produto_id) REFERENCES produto(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
);
-- Solução 2 — array nativo do PostgreSQL (aceitável se apenas precisas de filtragem simples):
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
tags TEXT[] -- array de texto, indexável com GIN
);
-- Pesquisar: WHERE tags @> ARRAY['electrónica']
-- Mas: não há integridade referencial nos valores do array — solução 1 é mais robusta
-- Outro exemplo de violação 1NF — grupos de colunas repetidos:
--
-- ENCOMENDA_MAL
-- ┌────┬──────┬──────────┬──────────┬──────────┬──────────┐
-- │ id │ cli │ prod_1 │ qtd_1 │ prod_2 │ qtd_2 │
-- ├────┼──────┼──────────┼──────────┼──────────┼──────────┤
-- │ 1 │ 42 │ 10 │ 1 │ 11 │ 2 │
-- └────┴──────┴──────────┴──────────┴──────────┴──────────┘
-- Problema: limite fixo de produtos por encomenda, queries impossíveis
-- Solução: tabela de linhas (já vista no artigo anterior)
CREATE TABLE linha_encomenda (
encomenda_id INT NOT NULL,
produto_id INT NOT NULL,
quantidade INT NOT NULL,
PRIMARY KEY (encomenda_id, produto_id)
);
Uma tabela está em 2NF quando está em 1NF e todos os atributos não-chave dependem da chave primária completa — não apenas de parte dela. Este problema só existe em tabelas com chave primária composta.
-- Violação de 2NF — dependência parcial:
--
-- LINHA_ENCOMENDA_MAL (chave primária composta: encomenda_id + produto_id)
-- ┌─────────────┬────────────┬─────┬──────────────┬───────────────┐
-- │ encomenda_id│ produto_id │ qtd │ produto_nome │ produto_preco │
-- ├─────────────┼────────────┼─────┼──────────────┼───────────────┤
-- │ 1 │ 10 │ 1 │ Teclado Mec. │ 89.99 │
-- │ 1 │ 11 │ 2 │ Rato Wireless │ 29.99 │
-- │ 2 │ 10 │ 1 │ Teclado Mec. │ 89.99 │ ← duplicado!
-- └─────────────┴────────────┴─────┴──────────────┴───────────────┘
--
-- produto_nome e produto_preco dependem APENAS de produto_id (parte da chave)
-- NÃO dependem de (encomenda_id + produto_id) em conjunto
-- Isto é uma dependência PARCIAL → violação de 2NF
--
-- Problema: se o preço do Teclado mudar, há que actualizar todas as linhas com produto_id=10
-- Solução — separar a informação do produto:
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
preco NUMERIC(10, 2) NOT NULL -- preço actual do produto
);
CREATE TABLE linha_encomenda (
encomenda_id INT NOT NULL,
produto_id INT NOT NULL,
quantidade INT NOT NULL DEFAULT 1,
preco_unit NUMERIC(10, 2) NOT NULL, -- preço NO MOMENTO da compra (snapshot)
-- preco_unit depende de (encomenda_id + produto_id) — chave completa ✓
-- representa o preço histórico, independente do preço actual do produto
PRIMARY KEY (encomenda_id, produto_id),
FOREIGN KEY (encomenda_id) REFERENCES encomenda(id) ON DELETE CASCADE,
FOREIGN KEY (produto_id) REFERENCES produto(id) ON DELETE RESTRICT
);
-- Agora: produto.preco = preço actual (pode mudar livremente)
-- linha_encomenda.preco_unit = preço histórico (imutável após a compra)
Uma tabela está em 3NF quando está em 2NF e não existem dependências transitivas — ou seja, nenhum atributo não-chave depende de outro atributo não-chave. Se A → B e B → C, então C depende transitivamente de A através de B, e B deve ser movido para a sua própria tabela.
-- Violação de 3NF — dependência transitiva:
--
-- ENCOMENDA_MAL
-- ┌────┬────────────┬────────────────────┬──────────┬──────────────────┐
-- │ id │ cliente_id │ cliente_email │ cliente_ │ cliente_pais_ │
-- │ │ │ │ pais_cod │ nome │
-- ├────┼────────────┼────────────────────┼──────────┼──────────────────┤
-- │ 1 │ 42 │ joao@example.com │ PT │ Portugal │
-- │ 2 │ 42 │ joao@example.com │ PT │ Portugal │
-- │ 3 │ 99 │ maria@example.com │ ES │ Espanha │
-- └────┴────────────┴────────────────────┴──────────┴──────────────────┘
--
-- Dependências:
-- id → cliente_id (directo — OK)
-- cliente_id → cliente_email (transitivo — violação 3NF)
-- cliente_id → cliente_pais_cod (transitivo — violação 3NF)
-- cliente_pais_cod → cliente_pais_nome (transitivo em cadeia!)
--
-- Problema: cliente_email e pais_nome repetem-se em cada encomenda
-- Se o João mudar de email → actualizar todas as encomendas dele
-- Solução — decomposição em tabelas independentes:
CREATE TABLE pais (
codigo CHAR(2) PRIMARY KEY, -- 'PT', 'ES', 'US'
nome VARCHAR(100) NOT NULL
);
CREATE TABLE cliente (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nome VARCHAR(100) NOT NULL,
pais_cod CHAR(2) NOT NULL,
FOREIGN KEY (pais_cod) REFERENCES pais(codigo)
);
CREATE TABLE encomenda (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL,
total NUMERIC(10, 2) NOT NULL,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (cliente_id) REFERENCES cliente(id) ON DELETE RESTRICT
);
-- Agora: encomenda → cliente_id → (email, nome, pais_cod) → pais_nome
-- Cada facto existe num único sítio — sem redundância
A BCNF é uma versão mais rigorosa da 3NF. Uma tabela está em BCNF quando, para cada dependência funcional X → Y, X é uma superchave. A maioria das tabelas em 3NF já satisfaz BCNF; os casos que não satisfazem envolvem chaves candidatas sobrepostas.
-- Caso clássico de violação BCNF com chaves candidatas sobrepostas:
--
-- PROFESSOR_DISCIPLINA_SALA
-- Um professor ensina sempre a mesma disciplina (numa turma específica)
-- Uma disciplina numa sala é sempre dada pelo mesmo professor
--
-- ┌───────────┬─────────────┬──────┐
-- │ professor │ disciplina │ sala │
-- ├───────────┼─────────────┼──────┤
-- │ Ana │ Bases Dados │ A1 │
-- │ Ana │ Algoritmos │ B2 │
-- │ Bruno │ Bases Dados │ C3 │
-- └───────────┴─────────────┴──────┘
--
-- Chaves candidatas:
-- (professor, disciplina) → sala ← determina a sala
-- (disciplina, sala) → professor ← determina o professor
--
-- Dependência: sala → professor (sala determina professor — mas sala não é superchave)
-- Isto viola BCNF
--
-- Problema: se a sala A1 mudar de professor, há que actualizar várias linhas
-- Solução BCNF — decompor:
CREATE TABLE professor_sala (
sala VARCHAR(10) PRIMARY KEY,
professor VARCHAR(100) NOT NULL
);
CREATE TABLE professor_disciplina (
professor VARCHAR(100) NOT NULL,
disciplina VARCHAR(100) NOT NULL,
PRIMARY KEY (professor, disciplina)
);
-- Agora cada dependência está numa tabela onde o determinante é chave primária
Normalização maximiza a integridade mas pode exigir JOINs custosos em queries de leitura frequente. Em sistemas com padrões de leitura muito intensos — relatórios, dashboards, data warehouses — a desnormalização controlada é uma optimização válida.
-- Cenário: relatório de vendas diárias — executado milhões de vezes por dia
-- Schema normalizado (3NF) requer 4 JOINs:
SELECT
p.nome AS produto,
c.nome AS categoria,
SUM(l.quantidade * l.preco_unit) AS total_vendas
FROM linha_encomenda l
JOIN encomenda e ON e.id = l.encomenda_id
JOIN produto p ON p.id = l.produto_id
JOIN categoria c ON c.id = p.categoria_id
WHERE e.criada_em >= NOW() - INTERVAL '1 day'
GROUP BY p.nome, c.nome;
-- Solução 1 — coluna desnormalizada (categoria_nome em produto):
ALTER TABLE produto ADD COLUMN categoria_nome VARCHAR(100);
-- Preencher e manter actualizado via trigger ou aplicação
-- Elimina o JOIN com categoria — mas há que sincronizar manualmente
-- Solução 2 — tabela de agregação (summary table):
CREATE TABLE vendas_diarias (
data DATE NOT NULL,
produto_id INT NOT NULL,
produto_nome VARCHAR(200) NOT NULL, -- desnormalizado — snapshot do nome
categoria_nome VARCHAR(100) NOT NULL, -- desnormalizado
total_vendas NUMERIC(12, 2) NOT NULL,
total_unidades INT NOT NULL,
PRIMARY KEY (data, produto_id)
);
-- Populada por um job nocturno (pg_cron, cron, scheduler)
-- Query de relatório: SELECT * FROM vendas_diarias WHERE data = CURRENT_DATE
-- Zero JOINs — leitura directa
-- Solução 3 — Materialized View (PostgreSQL):
CREATE MATERIALIZED VIEW mv_vendas_diarias AS
SELECT
DATE(e.criada_em) AS data,
p.id AS produto_id,
p.nome AS produto_nome,
c.nome AS categoria_nome,
SUM(l.quantidade * l.preco_unit) AS total_vendas,
SUM(l.quantidade) AS total_unidades
FROM linha_encomenda l
JOIN encomenda e ON e.id = l.encomenda_id
JOIN produto p ON p.id = l.produto_id
JOIN categoria c ON c.id = p.categoria_id
GROUP BY DATE(e.criada_em), p.id, p.nome, c.nome
WITH DATA;
CREATE UNIQUE INDEX ON mv_vendas_diarias (data, produto_id);
-- Actualizar (pode ser agendado):
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_vendas_diarias;
-- CONCURRENTLY: permite leituras durante o refresh (requer índice UNIQUE)
-- Regra de ouro:
-- Normalizar primeiro → 3NF como ponto de partida
-- Desnormalizar apenas quando um problema de performance real foi medido
-- Documentar cada desnormalização e o motivo — é dívida técnica intencional
-- Tabela inicial não normalizada: dados de uma folha de cálculo de vendas
--
-- VENDAS_RAW
-- enc_id | data | cliente_nome | cliente_email | produtos_comprados | total
-- 1 | 2024-01-15 | João Silva | joao@example.com | Teclado;Rato | 119.98
-- 2 | 2024-01-16 | Maria Sousa | maria@example.com | Monitor | 299.99
-- 3 | 2024-01-16 | João Silva | joao@example.com | Livro SQL | 34.90
-- PASSO 1 — Aplicar 1NF:
-- Problemas: produtos_comprados não é atómico, enc_id não é PK única sem produto
-- Decompor em linhas atómicas:
-- enc_id | data | cliente_nome | cliente_email | produto_nome | preco_unit | qtd
-- 1 | 2024-01-15 | João Silva | joao@example.com | Teclado | 89.99 | 1
-- 1 | 2024-01-15 | João Silva | joao@example.com | Rato | 29.99 | 1
-- 2 | 2024-01-16 | Maria Sousa | maria@example.com | Monitor | 299.99 | 1
-- 3 | 2024-01-16 | João Silva | joao@example.com | Livro SQL | 34.90 | 1
-- PK: (enc_id, produto_nome) → em 1NF ✓
-- PASSO 2 — Aplicar 2NF:
-- Dependências parciais detectadas:
-- produto_nome → preco_unit (depende só de produto_nome, não da PK completa)
-- enc_id → data, cliente_* (depende só de enc_id, não da PK completa)
-- Decompor:
-- Tabela PRODUTO: (produto_id PK, produto_nome, preco_base)
-- Tabela ENCOMENDA: (enc_id PK, data, cliente_nome, cliente_email)
-- Tabela LINHA: (enc_id, produto_id, qtd, preco_unit) — em 2NF ✓
-- PASSO 3 — Aplicar 3NF:
-- Dependência transitiva detectada em ENCOMENDA:
-- enc_id → cliente_email → cliente_nome (cliente_nome depende de cliente_email)
-- Decompor:
-- Tabela CLIENTE: (cliente_id PK, email UNIQUE, nome)
-- Tabela ENCOMENDA: (enc_id PK, data, cliente_id FK) — em 3NF ✓
-- Resultado final (3NF):
CREATE TABLE cliente (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nome VARCHAR(100) NOT NULL
);
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL UNIQUE,
preco_base NUMERIC(10,2) NOT NULL
);
CREATE TABLE encomenda (
id SERIAL PRIMARY KEY,
data DATE NOT NULL DEFAULT CURRENT_DATE,
cliente_id INT NOT NULL REFERENCES cliente(id)
);
CREATE TABLE linha_encomenda (
encomenda_id INT NOT NULL REFERENCES encomenda(id) ON DELETE CASCADE,
produto_id INT NOT NULL REFERENCES produto(id),
quantidade INT NOT NULL DEFAULT 1,
preco_unit NUMERIC(10,2) NOT NULL,
PRIMARY KEY (encomenda_id, produto_id)
);
REFRESH MATERIALIZED VIEW CONCURRENTLY requer um índice UNIQUE na view — adicionar antes do primeiro refresh.