SQL DML Transacções
SQL

DML + DTL — Manipulação & Transacções

DML (Data Manipulation Language) é o conjunto de comandos que lê e escreve dados — INSERT, UPDATE, DELETE e MERGE. DTL (Data Transaction Language) controla a atomicidade dessas operações — BEGIN, COMMIT, ROLLBACK e SAVEPOINT. Os dois são inseparáveis: um UPDATE que modifica dados financeiros sem uma transacção explícita é um risco — se o processo falhar a meio, os dados ficam num estado inconsistente. Perceber ACID, isolation levels e deadlocks é perceber por que razão as bases de dados relacionais são fiáveis por design.

INSERT

-- ── Inserção simples ──────────────────────────────────────────────────────────
INSERT INTO categoria (nome) VALUES ('Electrónica');

-- Múltiplas linhas numa só instrução (muito mais eficiente que um INSERT por linha):
INSERT INTO categoria (nome) VALUES
    ('Electrónica'),
    ('Vestuário'),
    ('Livros'),
    ('Desporto');

-- RETURNING — retorna valores gerados (id auto-increment, defaults, computed columns):
INSERT INTO utilizador (email, nome, password_hash)
VALUES ('joao@example.com', 'João Silva', '$2b$12$...')
RETURNING id, criado_em;
-- Útil para obter o id gerado sem um SELECT separado
-- Em Java/JDBC: Statement.RETURN_GENERATED_KEYS ou PreparedStatement com RETURNING

-- Inserir o resultado de uma query (INSERT ... SELECT):
INSERT INTO produto_arquivo (id, nome, preco, arquivado_em)
SELECT id, nome, preco, NOW()
FROM produto
WHERE activo = FALSE AND criado_em < NOW() - INTERVAL '1 year';
-- Move produtos inactivos antigos para tabela de arquivo


-- ── UPSERT — INSERT com tratamento de conflito ────────────────────────────────
-- Fundamental para operações idempotentes: "inserir se não existir, actualizar se existir"

-- ON CONFLICT DO NOTHING — ignorar conflitos silenciosamente:
INSERT INTO produto_tag (produto_id, tag_id)
VALUES (1, 5)
ON CONFLICT DO NOTHING;
-- Se (produto_id=1, tag_id=5) já existir, não faz nada e não lança erro

-- ON CONFLICT DO UPDATE — actualizar em caso de conflito (verdadeiro UPSERT):
INSERT INTO stock (produto_id, quantidade)
VALUES (42, 100)
ON CONFLICT (produto_id)
DO UPDATE SET
    quantidade   = stock.quantidade + EXCLUDED.quantidade,
    -- EXCLUDED: refere-se aos valores que tentámos inserir (os que causaram o conflito)
    actualizado_em = NOW();
-- Se produto_id=42 já existe: adiciona 100 ao stock existente
-- Se não existe: insere com quantidade=100

-- UPSERT com chave composta:
INSERT INTO permissao (utilizador_id, recurso, pode_ler, pode_escrever)
VALUES (7, 'relatorios', TRUE, FALSE)
ON CONFLICT (utilizador_id, recurso)
DO UPDATE SET
    pode_ler      = EXCLUDED.pode_ler,
    pode_escrever = EXCLUDED.pode_escrever,
    actualizado_em = NOW()
WHERE permissao.actualizado_em < NOW() - INTERVAL '1 hour';
-- WHERE no DO UPDATE: só actualiza se a condição for verdadeira
-- Se for falsa: não actualiza nem lança erro (como DO NOTHING)

UPDATE

-- ── UPDATE básico ────────────────────────────────────────────────────────────
UPDATE produto
SET preco_base = preco_base * 1.05   -- aumentar preços 5%
WHERE categoria_id = 1
  AND activo = TRUE;

-- RETURNING no UPDATE (PostgreSQL):
UPDATE utilizador
SET ultimo_login = NOW()
WHERE id = 42
RETURNING id, email, ultimo_login;


-- ── UPDATE com JOIN (UPDATE ... FROM) ─────────────────────────────────────────
-- Actualizar baseado em dados de outra tabela:
UPDATE produto p
SET preco_base = p.preco_base * (1 + a.percentagem / 100)
FROM ajuste_preco a
WHERE a.categoria_id = p.categoria_id
  AND a.activo = TRUE;
-- Equivalente ao UPDATE com JOIN no MySQL: UPDATE p JOIN a ON ...
-- No PostgreSQL: UPDATE ... FROM (sem JOIN explícito — a condição fica no WHERE)


-- ── UPDATE com subquery ───────────────────────────────────────────────────────
UPDATE encomenda
SET total = (
    SELECT COALESCE(SUM(quantidade * preco_unit), 0)
    FROM linha_encomenda
    WHERE encomenda_id = encomenda.id
)
WHERE estado = 'pendente';
-- Recalcula o total de todas as encomendas pendentes
-- COALESCE: retorna 0 se não houver linhas (evita NULL)


-- ── UPDATE em massa com CTE ───────────────────────────────────────────────────
-- Common Table Expression para clareza em updates complexos:
WITH encomendas_a_fechar AS (
    SELECT id
    FROM encomenda
    WHERE estado = 'pendente'
      AND criada_em < NOW() - INTERVAL '30 days'
)
UPDATE encomenda
SET estado = 'cancelado',
    cancelado_em = NOW()
FROM encomendas_a_fechar
WHERE encomenda.id = encomendas_a_fechar.id
RETURNING encomenda.id, encomenda.cliente_id;


-- ── Armadilha comum: UPDATE sem WHERE ────────────────────────────────────────
-- UPDATE produto SET preco_base = 0;  ← actualiza TODOS os produtos!
-- Em produção: sempre verificar com SELECT antes de UPDATE/DELETE:
SELECT COUNT(*) FROM produto WHERE categoria_id = 1 AND activo = TRUE;
-- Confirmar o número de linhas afectadas antes de executar o UPDATE

DELETE e MERGE

-- ── DELETE ────────────────────────────────────────────────────────────────────
DELETE FROM sessao
WHERE expira_em < NOW();
-- Apaga sessões expiradas

-- DELETE com RETURNING:
DELETE FROM notificacao
WHERE lida = TRUE AND criada_em < NOW() - INTERVAL '90 days'
RETURNING id, utilizador_id;

-- DELETE com subquery:
DELETE FROM linha_encomenda
WHERE encomenda_id IN (
    SELECT id FROM encomenda WHERE estado = 'cancelado'
);

-- DELETE com JOIN (DELETE ... USING no PostgreSQL):
DELETE FROM linha_encomenda l
USING encomenda e
WHERE l.encomenda_id = e.id
  AND e.estado = 'cancelado'
  AND e.cancelado_em < NOW() - INTERVAL '1 year';

-- Soft delete — padrão comum em produção:
-- Em vez de apagar, marcar como inactivo para preservar integridade referencial e auditoria
ALTER TABLE utilizador ADD COLUMN apagado_em TIMESTAMPTZ;

UPDATE utilizador
SET apagado_em = NOW()
WHERE id = 42;
-- "Apagar" o utilizador sem remover a linha
-- Todas as queries de leitura filtram: WHERE apagado_em IS NULL


-- ── MERGE (SQL:2003 standard) — upsert declarativo ───────────────────────────
-- PostgreSQL 15+ suporta MERGE nativo. Para versões anteriores, usar INSERT ... ON CONFLICT

MERGE INTO stock AS destino
USING (VALUES (42, 50), (43, 0), (44, 200)) AS origem(produto_id, quantidade)
    ON destino.produto_id = origem.produto_id
WHEN MATCHED AND origem.quantidade = 0 THEN
    DELETE                                    -- remover stock zero
WHEN MATCHED THEN
    UPDATE SET quantidade = origem.quantidade, -- actualizar stock existente
               actualizado_em = NOW()
WHEN NOT MATCHED THEN
    INSERT (produto_id, quantidade)           -- inserir novo produto no stock
    VALUES (origem.produto_id, origem.quantidade);
-- MERGE avalia cada linha da origem contra o destino e aplica a acção correspondente
-- Mais legível que múltiplos INSERT/UPDATE/DELETE separados em lógica ETL

Transacções e ACID

Uma transacção é um conjunto de operações que o SGBD trata como uma unidade atómica. ACID é o acrónimo das quatro propriedades que garantem a fiabilidade das transacções em bases de dados relacionais.

-- ── As quatro propriedades ACID ──────────────────────────────────────────────

-- ATOMICIDADE: tudo ou nada
-- Se qualquer operação dentro da transacção falhar, NENHUMA é confirmada.
-- Nunca ficamos com "metade" de uma transferência bancária.

-- CONSISTÊNCIA: o schema permanece válido
-- Constraints, foreign keys, checks são verificados no COMMIT.
-- Uma transacção não pode deixar a base de dados em estado inválido.

-- ISOLAMENTO: transacções concorrentes não se interferem (depende do isolation level)
-- Duas transacções a editar os mesmos dados não se vêem mutuamente até ao COMMIT.

-- DURABILIDADE: após o COMMIT, os dados persistem
-- Mesmo que o servidor falhe imediatamente após o COMMIT, os dados estão em disco
-- (garantido pelo WAL — Write-Ahead Log).


-- ── BEGIN / COMMIT / ROLLBACK ─────────────────────────────────────────────────
BEGIN;  -- iniciar transacção
-- (no PostgreSQL, pode usar também START TRANSACTION)

    UPDATE conta SET saldo = saldo - 500 WHERE id = 1;  -- débito
    UPDATE conta SET saldo = saldo + 500 WHERE id = 2;  -- crédito

    -- Verificar que nenhuma conta ficou negativa:
    DO $$
    BEGIN
        IF EXISTS (SELECT 1 FROM conta WHERE saldo < 0) THEN
            RAISE EXCEPTION 'Saldo insuficiente';
        END IF;
    END;
    $$;

COMMIT;  -- confirmar — ambos os UPDATEs são tornados permanentes
-- ou:
ROLLBACK;  -- reverter — nenhum UPDATE é aplicado

-- Em caso de erro, o PostgreSQL faz ROLLBACK automático da transacção.
-- Auto-commit: por default, cada statement SQL é uma transacção implícita.
-- Para agrupar vários statements: BEGIN explícito é necessário.


-- ── SAVEPOINT — ponto de retorno parcial dentro de uma transacção ─────────────
BEGIN;

    INSERT INTO encomenda (cliente_id, total) VALUES (42, 0) RETURNING id;
    -- id = 101

    SAVEPOINT sp_encomenda_criada;
    -- Ponto de retorno — podemos voltar aqui sem cancelar toda a transacção

    INSERT INTO linha_encomenda (encomenda_id, produto_id, quantidade, preco_unit)
    VALUES (101, 5, 2, 89.99);

    -- Algo correu mal com a linha:
    ROLLBACK TO SAVEPOINT sp_encomenda_criada;
    -- A encomenda ainda existe, a linha foi revertida

    -- Tentar com dados corrigidos:
    INSERT INTO linha_encomenda (encomenda_id, produto_id, quantidade, preco_unit)
    VALUES (101, 5, 1, 89.99);

    UPDATE encomenda SET total = 89.99 WHERE id = 101;

    RELEASE SAVEPOINT sp_encomenda_criada;
    -- Liberta o savepoint (opcional — é libertado automaticamente no COMMIT)

COMMIT;

Isolation Levels

O nível de isolamento controla que anomalias de concorrência são possíveis. Mais isolamento significa menos anomalias mas mais contention — o SGBD tem de bloquear mais recursos para garantir as propriedades. PostgreSQL implementa os quatro níveis do standard SQL.

-- ── Anomalias de concorrência ────────────────────────────────────────────────

-- Dirty Read: ler dados não confirmados de outra transacção
-- T1 actualiza, T2 lê o valor novo, T1 faz ROLLBACK → T2 leu dados que nunca existiram

-- Non-Repeatable Read: ler o mesmo dado duas vezes e obter resultados diferentes
-- T1 lê linha X (valor=10), T2 actualiza X para 20 e faz COMMIT, T1 lê X novamente (valor=20)

-- Phantom Read: uma query retorna linhas diferentes quando executada duas vezes
-- T1 conta linhas (n=5), T2 insere uma linha nova e faz COMMIT, T1 conta novamente (n=6)

-- ── Níveis de isolamento e anomalias que previnem ─────────────────────────────
--
-- Nível                | Dirty Read | Non-Repeatable | Phantom Read
-- ─────────────────────┼────────────┼────────────────┼─────────────
-- READ UNCOMMITTED     |  possível  |    possível    |   possível
-- READ COMMITTED       |  ✗ never   |    possível    |   possível   ← default PostgreSQL
-- REPEATABLE READ      |  ✗ never   |    ✗ never     |   possível*
-- SERIALIZABLE         |  ✗ never   |    ✗ never     |   ✗ never
--
-- * PostgreSQL REPEATABLE READ também previne Phantom Reads (implementação SSI)

-- Definir isolation level para uma transacção:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- default
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Ou após BEGIN:
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... operações ...
COMMIT;


-- ── Quando usar cada nível ────────────────────────────────────────────────────

-- READ COMMITTED (default):
-- A maioria das aplicações web — operações independentes, sem leitura-modificação-escrita
-- Exemplo: inserir uma encomenda, actualizar um perfil

-- REPEATABLE READ:
-- Relatórios que lêem os mesmos dados múltiplas vezes na mesma transacção
-- Exportação de dados consistente
-- Exemplo: gerar um relatório PDF que lê várias tabelas

-- SERIALIZABLE:
-- Operações críticas que dependem de ausência de inserções concorrentes
-- Transferências bancárias, reservas de lugares, stock limitado
-- Exemplo:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SELECT quantidade FROM stock WHERE produto_id = 42;
    -- Se quantidade >= 1: prosseguir
    UPDATE stock SET quantidade = quantidade - 1 WHERE produto_id = 42;
    INSERT INTO linha_encomenda (...) VALUES (...);
COMMIT;
-- SERIALIZABLE garante que duas transacções concorrentes não vendem o mesmo último item

Deadlocks

-- Um deadlock ocorre quando duas transacções esperam uma pela outra indefinidamente
-- O PostgreSQL detecta deadlocks automaticamente e cancela uma das transacções

-- ── Como ocorre um deadlock ───────────────────────────────────────────────────
--
-- Transacção A          | Transacção B
-- ──────────────────────┼──────────────────────
-- BEGIN;                | BEGIN;
-- UPDATE conta          |
--   SET saldo=saldo-100 |
--   WHERE id=1;         |                       ← A obtém lock na linha id=1
--                       | UPDATE conta
--                       |   SET saldo=saldo-200
--                       |   WHERE id=2;         ← B obtém lock na linha id=2
-- UPDATE conta          |
--   SET saldo=saldo+100 |
--   WHERE id=2;         |                       ← A aguarda B libertar id=2
--                       | UPDATE conta
--                       |   SET saldo=saldo+200
--                       |   WHERE id=1;         ← B aguarda A libertar id=1
-- ← DEADLOCK DETECTADO — PostgreSQL cancela uma das transacções com:
-- "ERROR: deadlock detected
--  DETAIL: Process 1234 waits for ShareLock on transaction 5678;
--          blocked by process 5678.
--          Process 5678 waits for ShareLock on transaction 1234;
--          blocked by process 1234."


-- ── Como prevenir deadlocks ───────────────────────────────────────────────────

-- Regra 1: aceder sempre às tabelas/linhas na mesma ordem em todas as transacções
-- Se A e B acedem sempre a id=1 antes de id=2, nunca ocorre deadlock:

-- Transacção A (corrigida):
BEGIN;
    UPDATE conta SET saldo = saldo - 100 WHERE id = 1;  -- sempre id=1 primeiro
    UPDATE conta SET saldo = saldo + 100 WHERE id = 2;  -- depois id=2
COMMIT;

-- Transacção B (corrigida):
BEGIN;
    UPDATE conta SET saldo = saldo - 200 WHERE id = 1;  -- sempre id=1 primeiro
    UPDATE conta SET saldo = saldo + 200 WHERE id = 2;  -- depois id=2
COMMIT;

-- Regra 2: SELECT ... FOR UPDATE — obter o lock na fase de leitura
BEGIN;
    -- Obter locks em ordem determinística:
    SELECT * FROM conta WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
    -- ORDER BY id garante que os locks são obtidos sempre na mesma ordem
    UPDATE conta SET saldo = saldo - 100 WHERE id = 1;
    UPDATE conta SET saldo = saldo + 100 WHERE id = 2;
COMMIT;

-- FOR UPDATE: bloqueia as linhas para escrita — outras transacções aguardam
-- FOR SHARE: bloqueia as linhas para leitura — permite outras leituras, bloqueia escritas
-- FOR NO KEY UPDATE: como FOR UPDATE mas permite FK inserts nas linhas bloqueadas
-- NOWAIT: em vez de aguardar, lança erro imediatamente se o lock não estiver disponível
-- SKIP LOCKED: salta as linhas bloqueadas — útil para job queues

-- Regra 3: transacções curtas — quanto menos tempo uma transacção mantém locks,
-- menor a probabilidade de conflito
-- Evitar: trabalho pesado de aplicação (chamadas HTTP, processamento) dentro de BEGIN/COMMIT

-- Tratar o erro de deadlock na aplicação (Java):
-- try {
--     executeTransaction();
-- } catch (SQLException e) {
--     if ("40P01".equals(e.getSQLState())) {  // PostgreSQL deadlock error code
--         // Retry com backoff exponencial
--         Thread.sleep(retryDelayMs);
--         executeTransaction();
--     }
-- }

Padrões Práticos com Transacções

-- ── Padrão: transferência bancária segura ────────────────────────────────────
CREATE OR REPLACE FUNCTION transferir(
    p_origem  INTEGER,
    p_destino INTEGER,
    p_valor   NUMERIC
) RETURNS VOID AS $$
BEGIN
    -- Validar saldo (com lock para evitar race condition):
    IF (SELECT saldo FROM conta WHERE id = p_origem FOR UPDATE) < p_valor THEN
        RAISE EXCEPTION 'Saldo insuficiente na conta %', p_origem;
    END IF;

    UPDATE conta SET saldo = saldo - p_valor WHERE id = p_origem;
    UPDATE conta SET saldo = saldo + p_valor WHERE id = p_destino;

    INSERT INTO movimento (conta_id, tipo, valor, timestamp)
    VALUES (p_origem,  'debito',  p_valor, NOW()),
           (p_destino, 'credito', p_valor, NOW());
END;
$$ LANGUAGE plpgsql;

-- Chamar dentro de uma transacção:
BEGIN;
    SELECT transferir(1, 2, 500.00);
COMMIT;


-- ── Padrão: job queue com SKIP LOCKED ────────────────────────────────────────
-- Múltiplos workers a processar uma fila sem conflitos:

CREATE TABLE job_queue (
    id          BIGSERIAL PRIMARY KEY,
    tipo        VARCHAR(50) NOT NULL,
    payload     JSONB       NOT NULL,
    estado      VARCHAR(20) NOT NULL DEFAULT 'pendente',
    criado_em   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processado_em TIMESTAMPTZ
);

-- Cada worker executa:
BEGIN;
    SELECT id, tipo, payload
    FROM job_queue
    WHERE estado = 'pendente'
    ORDER BY criado_em
    LIMIT 1
    FOR UPDATE SKIP LOCKED;  -- obtém o próximo job livre, salta os bloqueados por outros workers
-- (guardar o id retornado: job_id)

    UPDATE job_queue
    SET estado = 'a_processar'
    WHERE id = :job_id;
COMMIT;

-- ... processar o job ...

BEGIN;
    UPDATE job_queue
    SET estado = 'concluido',
        processado_em = NOW()
    WHERE id = :job_id;
COMMIT;
-- SKIP LOCKED é a forma mais eficiente de implementar uma job queue em PostgreSQL
-- Sem SKIP LOCKED: todos os workers aguardam o mesmo lock → contention desnecessária

Checklist DML + DTL