Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL6 min
Subqueries
- Subquery correlacionada vs não correlacionada: qual roda uma vez por linha?
- Quando uma subquery vira join sem perda — e quando não vira?
- Por que
NOT INcomNULLno resultado devolve conjunto vazio? - Quando uma CTE é só legibilidade e quando ela muda o plano de execução?
Conceito
Uma subquery é uma consulta dentro de outra. A classificação que importa é se ela depende da linha externa:
| Tipo | Depende da linha externa? | Custo conceitual |
|---|---|---|
| não correlacionada | não | roda uma vez |
| correlacionada | sim (referencia coluna de fora) | roda uma vez por linha |
"Uma vez por linha" é o modelo mental — o otimizador frequentemente reescreve para join ou para uma varredura única. Mas é o modelo certo para prever quando vai doer.
Onde uma subquery aparece:
- escalar (
SELECT (SELECT ...) FROM) — devolve um valor; erro se devolver mais de um - no
WHEREcomIN,EXISTS,ANY,ALL - no
FROM(tabela derivada) — precisa de alias - CTE (
WITH) — subquery nomeada, legível e reutilizável na mesma consulta
Quando vira join sem perda: IN com subquery não correlacionada é equivalente a um
INNER JOIN + DISTINCT. Quando não vira: quando você precisa da semântica de
"existe/não existe" sem multiplicar linhas — aí EXISTS é mais direto e mais rápido,
porque ele para no primeiro acerto.
NOT IN com NULL devolve conjunto vazioEste é o bug de SQL mais silencioso que existe.
x NOT IN (1, 2, NULL) significa x <> 1 AND x <> 2 AND x <> NULL. A última comparação é
desconhecida, e verdadeiro AND desconhecido = desconhecido — que não é verdadeiro.
Resultado: nenhuma linha, para qualquer x.
Um único NULL na subquery anula a consulta inteira.
A correção é NOT EXISTS, que usa semântica de existência em vez de comparação.
CTE: legibilidade ou barreira de otimização? Depende do banco:
- Postgres até 11 — CTE era sempre materializada: uma barreira de otimização, às vezes útil, frequentemente um freio
- Postgres 12+ — CTE é inlined por padrão;
MATERIALIZEDforça o comportamento antigo - SQLite, MySQL 8+ — inline por padrão
Ou seja: em banco moderno, CTE é legibilidade quase de graça. Em Postgres antigo, era decisão de performance disfarçada de organização.
Na prática
Verificado. Cenário: 4 clientes, carla sem nenhum pedido.
NOT IN funcionando, enquanto não há NULL na subquery:
SELECT nome FROM cliente WHERE id NOT IN (SELECT cliente_id FROM pedido);
nome
carla <- correto
Agora inserindo UM pedido com cliente_id NULL:
INSERT INTO pedido VALUES (5, NULL, 100, 'pago');
SELECT nome FROM cliente WHERE id NOT IN (SELECT cliente_id FROM pedido);
(vazio) <- a carla desapareceu
NOT EXISTS, na mesma situação:
SELECT nome FROM cliente
WHERE NOT EXISTS (SELECT 1 FROM pedido p WHERE p.cliente_id = cliente.id);
nome
carla <- correto
E o mesmo efeito com uma lista literal — verificado:
SELECT nome FROM cliente WHERE uf NOT IN ('RJ');
nome
ana
davi <- a carla (uf NULL) NÃO aparece, embora sua uf não seja 'RJ'
A carla não é do RJ e mesmo assim foi excluída. É o mesmo mecanismo: NULL <> 'RJ' é
desconhecido.
Em Go
O risco em Go não é a subquery — é montar SQL dinâmico. E a regra é absoluta:
Nunca concatene valor em SQL. Sempre use placeholder.
// ERRADO: injeção de SQL
q := "SELECT * FROM cliente WHERE uf = '" + uf + "'"
// CERTO
db.QueryContext(ctx, "SELECT * FROM cliente WHERE uf = $1", uf)
Os placeholders variam por driver: $1, $2 (Postgres), ? (MySQL, SQLite),
:nome (Oracle). database/sql não traduz entre eles.
O caso difícil: IN com uma lista de tamanho variável. database/sql não expande slice
em placeholders. As três saídas:
// 1. gerar os placeholders (o número de ? é dado por len, os VALORES continuam parametrizados)
marcas := strings.Repeat("?,", len(ids)-1) + "?"
args := make([]any, len(ids))
for i, v := range ids { args[i] = v }
rows, err := db.QueryContext(ctx, "SELECT * FROM pedido WHERE id IN ("+marcas+")", args...)
// 2. Postgres: passar um array (pgx ou lib/pq)
db.QueryContext(ctx, "SELECT * FROM pedido WHERE id = ANY($1)", pq.Array(ids))
// 3. sqlc: gera a função com o tipo certo
A opção 2 é a mais limpa e é específica de Postgres. Note que na opção 1 o número de
placeholders vem de len(ids), nunca os valores — a concatenação é da estrutura, não do
dado.
EXISTS em vez de COUNT(*) > 0: trazer a contagem para Go só para comparar com zero
faz o banco varrer tudo. SELECT EXISTS(SELECT 1 FROM ... WHERE ...) para no primeiro
acerto e devolve booleano.
Respostas às perguntas-guia
1. Subquery correlacionada vs não correlacionada: qual roda uma vez por linha?
A correlacionada (referencia coluna da consulta externa). A não correlacionada roda uma vez e pode ser materializada.
Em Go: nada de específico — mas é a diferença entre uma query de 5ms e uma de 5s, e você
só descobre com EXPLAIN.
2. Quando uma subquery vira join sem perda — e quando não vira?
IN não correlacionado ≡ INNER JOIN + DISTINCT. Não vira quando você quer
existência sem fan-out (use EXISTS), ou quando precisa de agregação por linha externa sem
multiplicar linhas (subquery escalar).
3. Por que NOT IN com NULL no resultado devolve conjunto vazio?
Porque NOT IN expande para uma cadeia de <> com AND, e x <> NULL é desconhecido —
que contamina o AND inteiro. Verificado acima. Use NOT EXISTS, ou
NOT IN (SELECT col FROM t WHERE col IS NOT NULL).
4. Quando uma CTE é só legibilidade e quando ela muda o plano de execução?
Em SQLite, MySQL 8+ e Postgres 12+, é legibilidade (inline). Em Postgres ≤ 11 era sempre
barreira de materialização. Em Postgres 12+, WITH x AS MATERIALIZED (...) força a barreira
de propósito — útil quando a CTE é caríssima e usada várias vezes.
Trade-offs
Do conceito:
- Subquery correlacionada compra clareza de intenção e cobra execução por linha (quando o otimizador não reescreve).
EXISTScompra parada antecipada e semântica correta com NULL;INcompra legibilidade para listas pequenas.- CTE compra legibilidade e, em banco antigo, cobra o plano.
Em Go:
- Placeholder é obrigatório e torna
INde tamanho variável desconfortável — a fricção é o preço da segurança. sqlcvalida a query contra o schema em tempo de build; o custo é um passo de geração.EXISTSdevolvendoboolevita trazer contagem para comparar com zero.
Comandos e consultas essenciais
-- NOT EXISTS: correto com NULL, e para no primeiro acerto
SELECT c.* FROM cliente c
WHERE NOT EXISTS (SELECT 1 FROM pedido p WHERE p.cliente_id = c.id);
-- se insistir em NOT IN, blinde contra NULL
SELECT * FROM cliente
WHERE id NOT IN (SELECT cliente_id FROM pedido WHERE cliente_id IS NOT NULL);
-- subquery escalar: agregado por linha, sem fan-out
SELECT c.nome, (SELECT count(*) FROM pedido p WHERE p.cliente_id = c.id) AS n FROM cliente c;
-- CTE: legibilidade; MATERIALIZED força barreira (Postgres 12+)
WITH pagos AS (SELECT * FROM pedido WHERE status = 'pago')
SELECT cliente_id, sum(total) FROM pagos GROUP BY cliente_id;
-- existência como booleano, sem contar tudo
SELECT EXISTS(SELECT 1 FROM pedido WHERE cliente_id = 1);
Relacionado
- 1. Joins — a alternativa, com fan-out
- 2. Agregações — NULL segue as mesmas regras aqui
- 5. Índices —
EXISTScom índice para no primeiro acerto - 7. Problema N+1 — subquery escalar é uma forma de evitá-lo
Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação