Antes de escrever uma única linha de SQL, é necessário modelar o domínio — identificar as entidades que existem no sistema, os atributos que as descrevem, e as relações entre elas. O modelo Entidade-Relação (ER) é a linguagem visual que formaliza esse pensamento. A tradução do modelo ER para tabelas SQL é um processo sistemático, e a escolha dos tipos de dados correctos para cada coluna é a primeira decisão de performance e integridade que tomamos num schema.
Uma entidade é um objecto do mundo real com existência independente — um cliente, uma encomenda, um produto. Um atributo é uma propriedade dessa entidade — o nome do cliente, a data da encomenda, o preço do produto. Uma relação é uma associação semântica entre duas ou mais entidades — um cliente faz encomendas, uma encomenda contém produtos.
-- Domínio de exemplo: sistema de encomendas de uma loja online
--
-- Entidades:
-- Cliente — representa uma pessoa que pode fazer encomendas
-- Encomenda — representa uma compra feita por um cliente
-- Produto — representa um artigo disponível para venda
-- Categoria — representa um grupo temático de produtos
--
-- Relações:
-- Cliente ──< Encomenda (1 cliente pode ter N encomendas)
-- Encomenda >──< Produto (N encomendas podem conter N produtos — relação M:N)
-- Produto >── Categoria (N produtos pertencem a 1 categoria)
--
-- Notação crow's foot (usada em diagramas ER):
-- |o── zero ou um
-- ||── exactamente um (obrigatório)
-- |<── um ou muitos
-- o<── zero ou muitos
--
-- Exemplo de diagrama textual:
--
-- CLIENTE ||──o< ENCOMENDA >o──|| LINHA_ENCOMENDA ||──o< PRODUTO >o──|| CATEGORIA
--
-- Leitura:
-- ─ Um CLIENTE tem zero ou mais ENCOMENDAS
-- ─ Uma ENCOMENDA pertence a exactamente um CLIENTE
-- ─ Uma ENCOMENDA tem uma ou mais LINHAS_ENCOMENDA
-- ─ Um PRODUTO pode aparecer em zero ou mais LINHAS_ENCOMENDA
-- ─ Um PRODUTO pertence a exactamente uma CATEGORIA
A cardinalidade descreve quantos de um lado da relação podem existir para cada instância do outro lado. A obrigatoriedade (ou participação) descreve se a relação é obrigatória ou opcional. As duas dimensões combinadas determinam as constraints do schema.
-- Tipos de relação por cardinalidade:
-- ── 1:1 — Um para Um ─────────────────────────────────────────────────────────
-- Cada instância de A está associada a no máximo uma instância de B e vice-versa
-- Exemplo: Utilizador ──── Perfil (cada utilizador tem exactamente um perfil)
-- Implementação: chave estrangeira em qualquer uma das tabelas, com UNIQUE constraint
CREATE TABLE utilizador (
id SERIAL PRIMARY KEY,
nome VARCHAR(100) NOT NULL
);
CREATE TABLE perfil (
id SERIAL PRIMARY KEY,
utilizador_id INT NOT NULL UNIQUE, -- UNIQUE → garante relação 1:1
bio TEXT,
avatar_url VARCHAR(500),
CONSTRAINT fk_perfil_utilizador
FOREIGN KEY (utilizador_id) REFERENCES utilizador(id)
ON DELETE CASCADE
);
-- ── 1:N — Um para Muitos ──────────────────────────────────────────────────────
-- Cada instância de A pode estar associada a N instâncias de B
-- Mas cada instância de B está associada a no máximo 1 instância de A
-- Exemplo: Cliente ──< Encomenda (um cliente tem várias encomendas)
-- Implementação: chave estrangeira no lado N (na tabela filha)
CREATE TABLE cliente (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nome VARCHAR(100) NOT NULL
);
CREATE TABLE encomenda (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL, -- lado N — chave estrangeira
data TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total NUMERIC(10, 2) NOT NULL,
CONSTRAINT fk_encomenda_cliente
FOREIGN KEY (cliente_id) REFERENCES cliente(id)
ON DELETE RESTRICT -- não apagar clientes com encomendas
);
-- ── M:N — Muitos para Muitos ──────────────────────────────────────────────────
-- Cada instância de A pode estar associada a N instâncias de B e vice-versa
-- Exemplo: Encomenda >──< Produto (uma encomenda tem vários produtos,
-- um produto aparece em várias encomendas)
-- Implementação: tabela de junção (junction table / associative entity)
-- A tabela de junção tem SEMPRE atributos próprios (quantidade, preço aplicado, etc.)
-- Se não tiver atributos, é sinal de que o modelo pode estar incompleto
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
preco_base NUMERIC(10, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0
);
CREATE TABLE linha_encomenda ( -- tabela de junção M:N
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 (pode mudar depois)
PRIMARY KEY (encomenda_id, produto_id), -- chave composta — cada par é único
CONSTRAINT fk_linha_encomenda
FOREIGN KEY (encomenda_id) REFERENCES encomenda(id) ON DELETE CASCADE,
CONSTRAINT fk_linha_produto
FOREIGN KEY (produto_id) REFERENCES produto(id) ON DELETE RESTRICT
);
-- ── Chave Primária (PRIMARY KEY) ─────────────────────────────────────────────
-- Identifica univocamente cada linha de uma tabela
-- Implicitamente: NOT NULL + UNIQUE + índice B-tree
-- Opção 1: SERIAL / BIGSERIAL — inteiro auto-incrementado (simples, popular)
CREATE TABLE categoria (
id SERIAL PRIMARY KEY, -- SERIAL = INTEGER com sequência automática
nome VARCHAR(100) NOT NULL UNIQUE
);
-- SERIAL é um atalho para:
-- id INTEGER NOT NULL DEFAULT nextval('categoria_id_seq')
-- Opção 2: BIGSERIAL — para tabelas com muitas linhas (> 2 milhões)
CREATE TABLE evento_log (
id BIGSERIAL PRIMARY KEY, -- 64-bit: até 9.2 × 10^18 linhas
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
mensagem TEXT
);
-- Opção 3: UUID — identificador universal único
-- Vantagens: sem colisões em sistemas distribuídos, opaco (não revela contagem)
-- Desvantagens: 16 bytes vs 4-8, índices ligeiramente maiores
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- PostgreSQL
CREATE TABLE sessao (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
utilizador_id INT NOT NULL,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Chave primária composta — usada em tabelas de junção:
CREATE TABLE permissao_role (
utilizador_id INT NOT NULL,
role_id INT NOT NULL,
concedida_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (utilizador_id, role_id) -- combinação única, nenhum campo é único sozinho
);
-- ── Chave Estrangeira (FOREIGN KEY) ──────────────────────────────────────────
-- Garante integridade referencial — não podes referenciar uma linha que não existe
-- ON DELETE / ON UPDATE comportamentos:
-- RESTRICT — impede a remoção/alteração se existirem linhas dependentes (mais seguro)
-- CASCADE — propaga a remoção/alteração automaticamente para as linhas dependentes
-- SET NULL — define a chave estrangeira como NULL quando o pai é removido
-- SET DEFAULT — define o valor default quando o pai é removido
-- NO ACTION — como RESTRICT mas verificado no final da transacção (permite deferral)
CREATE TABLE comentario (
id SERIAL PRIMARY KEY,
post_id INT, -- pode ser NULL → comentário pode existir sem post (raro)
autor_id INT NOT NULL,
texto TEXT NOT NULL,
CONSTRAINT fk_comentario_post
FOREIGN KEY (post_id) REFERENCES post(id)
ON DELETE SET NULL, -- se o post for apagado, comentários ficam sem post
CONSTRAINT fk_comentario_autor
FOREIGN KEY (autor_id) REFERENCES utilizador(id)
ON DELETE RESTRICT -- não apagar utilizadores com comentários
);
A escolha do tipo de dados correcto para cada coluna é uma decisão de integridade e performance. Um tipo demasiado largo desperdiça espaço em disco e memória (relevante para índices); um tipo demasiado estreito causa erros de truncamento ou overflow. Os exemplos abaixo usam sintaxe PostgreSQL — os tipos SQL standard são portáveis entre a maioria dos SGBD.
-- ── Tipos Numéricos ──────────────────────────────────────────────────────────
-- Inteiros:
SMALLINT -- 2 bytes, -32768 a 32767 — raramente necessário
INTEGER (INT) -- 4 bytes, -2.1B a 2.1B — chave primária, contadores
BIGINT -- 8 bytes, -9.2×10^18 a 9.2×10^18 — IDs em sistemas de grande escala
-- Decimais exactos (financeiro):
NUMERIC(p, s) -- p = total de dígitos, s = casas decimais
DECIMAL(p, s) -- sinónimo de NUMERIC no PostgreSQL
-- Exemplo: NUMERIC(10, 2) → até 99 999 999.99 — ideal para preços e valores monetários
-- NUNCA usar FLOAT/REAL para valores financeiros — representação binária causa erros:
-- 0.1 + 0.2 = 0.30000000000000004 em ponto flutuante
-- Ponto flutuante (cálculo científico, coordenadas aproximadas):
REAL -- 4 bytes, ~6 dígitos de precisão
DOUBLE PRECISION -- 8 bytes, ~15 dígitos de precisão
-- ── Tipos de Texto ────────────────────────────────────────────────────────────
VARCHAR(n) -- texto de comprimento variável, máximo n caracteres
CHAR(n) -- texto de comprimento fixo, preenchido com espaços — evitar em geral
TEXT -- texto de comprimento ilimitado — sem overhead vs VARCHAR(n) no PostgreSQL
-- Guideline de escolha:
-- VARCHAR(n) quando existe um limite semântico real (email: 255, nome: 100)
-- TEXT para conteúdo livre (descrições, comentários, posts)
-- CHAR(n) apenas para códigos de comprimento fixo garantido (ISO country: CHAR(2))
-- Exemplo:
CREATE TABLE utilizador (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, -- limite real: spec RFC 5321 = 254 chars
nome VARCHAR(100) NOT NULL,
bio TEXT, -- sem limite — conteúdo livre
pais CHAR(2) -- ISO 3166-1 alpha-2: 'PT', 'US', 'GB'
);
-- ── Tipos de Data e Hora ──────────────────────────────────────────────────────
DATE -- apenas data: '2024-01-15'
TIME -- apenas hora: '14:30:00'
TIMESTAMP -- data + hora sem timezone: '2024-01-15 14:30:00'
TIMESTAMPTZ -- data + hora COM timezone (recomendado para aplicações distribuídas)
INTERVAL -- duração: '3 days', '2 hours 30 minutes'
-- Regra de ouro: SEMPRE usar TIMESTAMPTZ em vez de TIMESTAMP
-- TIMESTAMP armazena a hora "como está" — sem contexto de timezone
-- TIMESTAMPTZ converte para UTC internamente, converte de volta ao ler
-- Resultado: dois servidores em timezones diferentes vêem sempre o mesmo momento
CREATE TABLE encomenda (
id SERIAL PRIMARY KEY,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- momento exacto da criação
data_entrega DATE, -- só a data importa (sem hora)
tempo_prep INTERVAL -- duração do processamento
);
-- Funções úteis:
SELECT NOW(); -- timestamp actual com timezone
SELECT CURRENT_DATE; -- data actual
SELECT AGE(NOW(), criada_em) FROM encomenda; -- intervalo desde a criação
SELECT DATE_TRUNC('month', criada_em) FROM encomenda; -- truncar para início do mês
-- ── Tipos Booleano e Binário ──────────────────────────────────────────────────
BOOLEAN -- TRUE / FALSE / NULL
BYTEA -- dados binários em bruto (imagens, ficheiros pequenos)
-- para ficheiros grandes: armazenar path, guardar no sistema de ficheiros
-- Nota: NULL em BOOLEAN é "desconhecido" — diferente de FALSE
-- WHERE activo = TRUE → exclui NULL e FALSE
-- WHERE activo IS TRUE → exclui NULL e FALSE (semanticamente mais correcto)
-- WHERE activo IS NOT FALSE → inclui TRUE e NULL
-- ── Tipos Especiais PostgreSQL ────────────────────────────────────────────────
UUID -- identificador universal único: '550e8400-e29b-41d4-a716-446655440000'
JSON -- texto JSON sem validação de estrutura
JSONB -- JSON binário — indexável, mais rápido para queries, maior espaço
ARRAY -- array de qualquer tipo: INTEGER[], TEXT[], etc.
-- JSONB vs colunas normais:
-- JSONB: útil para atributos semi-estruturados ou que variam por registo
-- Colunas: preferível quando o campo é consultado frequentemente ou precisa de índice simples
-- Regra: se fizeres WHERE sobre um campo dentro do JSON frequentemente → coluna normal
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
preco NUMERIC(10, 2) NOT NULL,
tags TEXT[], -- array de tags: '{"electrónica", "portátil"}'
atributos JSONB -- especificações variáveis por categoria de produto
);
-- Aceder a JSONB:
SELECT atributos->>'cor' FROM produto WHERE id = 1; -- valor como texto
SELECT atributos->'dimensoes'->>'largura' FROM produto; -- valor aninhado
SELECT * FROM produto WHERE atributos @> '{"cor": "azul"}'; -- contém JSON (usa índice GIN)
-- Constraints garantem a integridade dos dados ao nível do schema
-- São verificadas em cada INSERT, UPDATE e DELETE — erros são lançados como excepções
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL, -- NOT NULL: valor obrigatório
preco NUMERIC(10, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
sku VARCHAR(50) NOT NULL UNIQUE, -- UNIQUE: sem duplicados
categoria_id INT NOT NULL,
-- CHECK: condição que o valor deve satisfazer
CONSTRAINT chk_preco_positivo CHECK (preco > 0),
CONSTRAINT chk_stock_nao_neg CHECK (stock >= 0),
CONSTRAINT chk_nome_len CHECK (char_length(nome) >= 2),
CONSTRAINT fk_produto_categoria
FOREIGN KEY (categoria_id) REFERENCES categoria(id)
ON DELETE RESTRICT
);
-- Constraint entre múltiplas colunas:
CREATE TABLE horario (
id SERIAL PRIMARY KEY,
sala_id INT NOT NULL,
inicio TIMESTAMPTZ NOT NULL,
fim TIMESTAMPTZ NOT NULL,
CONSTRAINT chk_horario_valido CHECK (fim > inicio), -- fim deve ser depois de início
CONSTRAINT uq_sala_horario UNIQUE (sala_id, inicio) -- sem sobreposição de início
);
-- Adicionar constraint a tabela existente:
ALTER TABLE produto
ADD CONSTRAINT chk_sku_formato CHECK (sku ~ '^[A-Z0-9\-]+$');
-- ~ é regex no PostgreSQL: SKU só pode ter maiúsculas, dígitos e hífens
-- Ver todas as constraints de uma tabela:
SELECT conname, contype, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'produto'::regclass;
-- Schema completo do sistema de encomendas — pronto para executar no PostgreSQL
CREATE TABLE categoria (
id SERIAL PRIMARY KEY,
nome VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE produto (
id SERIAL PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
descricao TEXT,
preco_base NUMERIC(10, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
sku VARCHAR(50) NOT NULL UNIQUE,
categoria_id INT NOT NULL,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_preco_positivo CHECK (preco_base > 0),
CONSTRAINT chk_stock_nao_neg CHECK (stock >= 0),
CONSTRAINT fk_produto_cat
FOREIGN KEY (categoria_id) REFERENCES categoria(id) ON DELETE RESTRICT
);
CREATE TABLE cliente (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nome VARCHAR(100) NOT NULL,
pais CHAR(2) NOT NULL DEFAULT 'PT',
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE encomenda (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'pendente',
total NUMERIC(10, 2) NOT NULL DEFAULT 0,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
entregue_em TIMESTAMPTZ,
CONSTRAINT chk_estado
CHECK (estado IN ('pendente', 'pago', 'enviado', 'entregue', 'cancelado')),
CONSTRAINT fk_encomenda_cliente
FOREIGN KEY (cliente_id) REFERENCES cliente(id) ON DELETE RESTRICT
);
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,
PRIMARY KEY (encomenda_id, produto_id),
CONSTRAINT chk_qtd_positiva CHECK (quantidade > 0),
CONSTRAINT fk_linha_enc
FOREIGN KEY (encomenda_id) REFERENCES encomenda(id) ON DELETE CASCADE,
CONSTRAINT fk_linha_prod
FOREIGN KEY (produto_id) REFERENCES produto(id) ON DELETE RESTRICT
);
-- Inserir dados de teste:
INSERT INTO categoria (nome) VALUES ('Electrónica'), ('Vestuário'), ('Livros');
INSERT INTO produto (nome, preco_base, stock, sku, categoria_id)
VALUES
('Teclado Mecânico', 89.99, 50, 'TEC-001', 1),
('T-shirt Algodão', 19.99, 200, 'VES-001', 2),
('Clean Code', 34.90, 30, 'LIV-001', 3);
INSERT INTO cliente (email, nome) VALUES ('joao@example.com', 'João Silva');
INSERT INTO encomenda (cliente_id, total) VALUES (1, 124.89);
INSERT INTO linha_encomenda (encomenda_id, produto_id, quantidade, preco_unit)
VALUES (1, 1, 1, 89.99), (1, 3, 1, 34.90);
BIGSERIAL para tabelas com potencial de crescimento significativo; UUID para sistemas distribuídos.NUMERIC(p,s) para valores monetários — nunca FLOAT ou REAL.TIMESTAMPTZ em vez de TIMESTAMP — sempre armazenar com contexto de timezone.TEXT para conteúdo livre; VARCHAR(n) quando existe um limite semântico real.JSONB para atributos semi-estruturados; colunas normais quando o campo é consultado em WHERE frequentemente.ON DELETE RESTRICT como default — é a opção mais segura. Usar CASCADE apenas quando a dependência é existencial (linhas de encomenda sem encomenda não fazem sentido).CHECK constraints para validações de domínio (preço positivo, estado em lista de valores) — não depender apenas da aplicação.