SQL Schema Normalização
SQL

Normalização de Tabelas

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.

O Problema: Anomalias de Modificação

-- 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

1NF — Primeira Forma Normal

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)
);

2NF — Segunda Forma Normal

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)

3NF — Terceira Forma Normal

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

BCNF — Forma Normal de Boyce-Codd

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

Quando Desnormalizar Intencionalmente

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

Processo de Normalização Passo a Passo

-- 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)
);

Checklist Normalização