trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL6 min

Joins

Perguntas-guia
  • inner, left, right, full, cross: você sabe desenhar o resultado de cada um?
  • Por que left join com filtro no WHERE vira 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 ON e filtrar no WHERE?

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 JOIN

O 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 purocarla 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 WHEREcarla 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 ONcarla 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 outer
  • WHERE — 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 JOIN compra 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.
  • COALESCE no SQL compra simplicidade em Go e esconde a distinção entre "ausente" e "vazio" — que às vezes importa.
  • sqlc gera 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


Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas