Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL6 min
Joins
- inner, left, right, full, cross: você sabe desenhar o resultado de cada um?
- Por que left join com filtro no
WHEREvira inner join sem você pedir? - Como um join sem condição correta multiplica linhas em vez de dar erro?
- Qual a diferença entre filtrar no
ONe filtrar noWHERE?
Conceito
Um join é, conceitualmente, o produto cartesiano de duas tabelas filtrado por uma condição. Toda a variedade de joins é sobre o que fazer com as linhas que não casam.
| Join | Mantém linhas sem par |
|---|---|
INNER |
nenhuma |
LEFT |
todas da esquerda (direita vem NULL) |
RIGHT |
todas da direita |
FULL |
de ambos os lados |
CROSS |
não há condição: produto cartesiano puro |
A ordem lógica de avaliação explica quase todo comportamento surpreendente:
FROM / JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
O join acontece antes do WHERE. É daí que vem a armadilha mais comum de SQL.
LEFT JOIN com filtro no WHERE vira INNER JOINO LEFT JOIN produz linhas com NULL do lado direito. O WHERE roda depois — e
qualquer comparação com NULL é desconhecido, que não é verdadeiro. Então as linhas
preservadas pelo LEFT JOIN são descartadas pelo WHERE.
Você escreveu LEFT e obteve INNER, sem erro e sem aviso.
A correção é mover o filtro para o ON, que é avaliado durante o join.
Fan-out: o join que multiplica linhas. Se um cliente tem 3 pedidos, juntar cliente com
pedido produz 3 linhas por cliente. Isso é correto — e destrói qualquer agregação feita
depois: SUM(cliente.limite) soma o limite três vezes. É a causa nº 1 de "o relatório está
com valores inflados".
Na prática
Verificado com 4 clientes (a carla não tem nenhum pedido) e 4 pedidos:
LEFT JOIN puro — carla aparece:
SELECT c.nome, count(p.id) AS pedidos
FROM cliente c LEFT JOIN pedido p ON p.cliente_id = c.id
GROUP BY c.nome;
nome pedidos
ana 2
bruno 1
carla 0 <- preservada
davi 1
O mesmo, com filtro no WHERE — carla desaparece:
... LEFT JOIN pedido p ON p.cliente_id = c.id
WHERE p.status = 'pago';
nome
ana
ana
davi <- carla SUMIU. Virou INNER JOIN.
O mesmo filtro, no ON — carla permanece, com NULL:
... LEFT JOIN pedido p ON p.cliente_id = c.id AND p.status = 'pago';
nome status
ana pago
ana pago
bruno <- pedido existe mas é 'pendente': filtrado no ON, linha preservada
carla <- nenhum pedido
davi pago
Repare no bruno: ele tem pedido, mas 'pendente'. O filtro no ON excluiu o pedido e
o LEFT JOIN preservou o cliente. Essa é a diferença semântica exata entre ON e WHERE.
Em Go
Go não tem ORM na biblioteca padrão, então você escreve o join — o que é bom (7. Problema N+1) e coloca dois problemas no seu colo.
Problema 1: o lado NULL do outer join. Scan em *string ou string falha com NULL:
var nome string
err := row.Scan(&nome) // sql: Scan error: converting NULL to string is unsupported
As duas saídas idiomáticas:
var status sql.NullString // .String + .Valid
var status *string // nil quando NULL
sql.NullString é explícito e verboso; *string é conciso e transfere a checagem de nil
para você. Desde o Go 1.22 existe sql.Null[T] genérico.
A terceira opção, e frequentemente a melhor: resolver no SQL com
COALESCE(p.status, ''). Aí Go nunca vê NULL.
Problema 2: fan-out ao montar objetos. Um join de cliente com pedidos devolve N linhas por cliente, e você tem que agrupar em memória:
clientes := map[int]*Cliente{}
for rows.Next() {
var cid int; var nome string; var pid sql.NullInt64
rows.Scan(&cid, &nome, &pid)
c, ok := clientes[cid]
if !ok { c = &Cliente{ID: cid, Nome: nome}; clientes[cid] = c }
if pid.Valid { c.Pedidos = append(c.Pedidos, int(pid.Int64)) }
}
Esse laço é exatamente o que um ORM faz por você — e é a razão de ORMs existirem. Em Go você escreve 10 linhas e sabe o que acontece.
Sempre feche o rows e sempre verifique rows.Err() — o erro de iteração não
aparece no Scan:
defer rows.Close()
for rows.Next() { ... }
if err := rows.Err(); err != nil { ... } // esquecido em metade do código Go
Respostas às perguntas-guia
1. inner, left, right, full, cross: você sabe desenhar o resultado de cada um?
INNER só o que casa; LEFT/RIGHT preservam um lado com NULL do outro; FULL preserva
os dois; CROSS é produto cartesiano.
Em Go: o que muda é o tipo do destino do Scan — qualquer coluna do lado preservável
precisa de sql.Null*, *T ou COALESCE.
2. Por que left join com filtro no WHERE vira inner join sem você pedir?
Porque o WHERE roda depois do join, e a linha preservada tem NULL na coluna filtrada —
comparação com NULL é desconhecido, então a linha cai. Verificado acima.
3. Como um join sem condição correta multiplica linhas em vez de dar erro?
Porque o produto cartesiano é uma operação válida. Sem condição (ou com condição incompleta numa chave composta), cada linha da esquerda casa com várias da direita. O banco não tem como saber que você não queria isso.
Em Go, o sintoma é o for rows.Next() produzindo mais objetos do que deveria, ou somas
inflacionadas. A defesa é SELECT count(*) antes de confiar no relatório.
4. Qual a diferença entre filtrar no ON e filtrar no WHERE?
ON— filtra durante o join: decide o que casa, preservando linhas do lado outerWHERE— filtra depois: descarta linhas do resultado
Para INNER JOIN são equivalentes. Para outer join, são semânticas diferentes — e é o
bruno do exemplo acima que mostra a diferença.
Trade-offs
Do conceito:
- Um join grande compra uma ida ao banco e cobra fan-out e memória.
- Várias queries pequenas compram simplicidade e cobram latência × N (7. Problema N+1).
LEFT JOINcompra completude e cobra NULLs para tratar em toda a cadeia.
Em Go:
- Escrever o SQL à mão compra controle total do plano e cobra o código de agrupamento.
COALESCEno SQL compra simplicidade em Go e esconde a distinção entre "ausente" e "vazio" — que às vezes importa.sqlcgera os tipos a partir do schema e valida o SQL em tempo de build, o que pega coluna renomeada antes de rodar.
Comandos e consultas essenciais
-- filtro no ON preserva o lado outer
SELECT c.nome, p.status FROM cliente c
LEFT JOIN pedido p ON p.cliente_id = c.id AND p.status = 'pago';
-- detectar fan-out antes de confiar na agregação
SELECT count(*) FROM a JOIN b ON ...; -- compare com count(*) de a
-- agregar sem fan-out: subquery em vez de join
SELECT c.nome, (SELECT count(*) FROM pedido p WHERE p.cliente_id = c.id) FROM cliente c;
-- anti-join: quem NÃO tem correspondência
SELECT c.* FROM cliente c LEFT JOIN pedido p ON p.cliente_id = c.id WHERE p.id IS NULL;
O último é o idioma do anti-join, e note que aqui o WHERE ... IS NULL é intencional
— é o único caso em que filtrar o lado outer no WHERE é o que você quer.
Relacionado
- 2. Agregações — fan-out estraga agregação
- 3. Subqueries — a alternativa ao join quando você só quer contar
- 7. Problema N+1 — o que acontece quando você evita joins demais
- 4. Modelagem e Normalização — normalizar cria joins; é o custo assumido
Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação