O que é um JOIN?
O problema do recibo
Você administra uma pequena livraria online. Todo dia, clientes fazem pedidos. Seu software de contabilidade armazena duas listas separadas: uma para clientes (nome, email, telefone) e outra para pedidos (número do pedido, total, data). No final do mês, você precisa de um relatório mostrando cada pedido junto com o cliente que o fez.
Você poderia copiar e colar nomes de clientes na planilha de pedidos manualmente. Mas isso cria dados duplicados, e quando um cliente muda de email, você teria que atualizar em dezenas de lugares. Em vez disso, cada pedido armazena uma única referência: o número de ID do cliente. Para construir seu relatório, você combina o ID do cliente de cada pedido com a lista de clientes.
Essa operação de combinação é o que um JOIN em SQL faz.
Por que as tabelas são separadas
Bancos de dados relacionais dividem dados em múltiplas tabelas de propósito. Esse princípio de design se chama normalização, e ele resolve três problemas concretos:
- Redundância: Sem normalização, você armazenaria "Alice, [email protected]" em cada pedido que ela faz. Cinquenta pedidos significa cinquenta cópias do email dela.
- Anomalias de atualização: Se Alice mudar de email, você precisaria atualizar todas as cinquenta linhas. Perca uma, e seus dados ficam inconsistentes.
- Anomalias de exclusão: Se você deletar o único pedido da Alice, você perde o registro dela como cliente por completo.
A normalização separa responsabilidades: a tabela users é dona dos dados do usuário, a tabela orders é dona dos dados do pedido. Cada tabela tem uma única responsabilidade: o mesmo princípio que você segue no Rails com DDD.
users orders
+----+-------+--------+ +----+---------+--------+
| id | name | email | | id | user_id | total |
+----+-------+--------+ +----+---------+--------+
| 1 | Alice | [email protected] | | 10 | 1 | 29.99 |
| 2 | Bob | [email protected] | | 11 | 1 | 15.50 |
| 3 | Carol | [email protected] | | 12 | 2 | 42.00 |
+----+-------+--------+ +----+---------+--------+
A coluna user_id na tabela orders é uma chave estrangeira: um ponteiro que diz "este pedido pertence ao usuário #1." Chaves estrangeiras são a cola que mantém um banco de dados relacional unido.
O contrato da chave estrangeira
Uma chave estrangeira é mais do que apenas um número em uma coluna. É um contrato entre duas tabelas:
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL
REFERENCES users(id),
total DECIMAL(10,2) NOT NULL
);
A cláusula REFERENCES users(id) diz ao PostgreSQL: "Todo valor em orders.user_id deve existir em users.id." Se você tentar inserir um pedido com user_id = 999 e nenhum usuário com id = 999 existir, o PostgreSQL rejeita a inserção com uma violação de chave estrangeira.
Essa restrição garante integridade referencial: você nunca terá um pedido órfão apontando para um usuário inexistente. Quando você escreve um JOIN, você está confiando nessa garantia.
No Rails, esse relacionamento é expresso através de associações do ActiveRecord. Se este app também vendesse livros (ele não tem tabela orders), os models ficariam assim:
# app/models/identity/user.rb
class Identity::User < ApplicationRecord
has_many :orders
end
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user, class_name: "Identity::User"
end
O lado belongs_to sempre possui a chave estrangeira. O lado has_many é a tabela referenciada. O ActiveRecord usa esse mapeamento para gerar queries JOIN nos bastidores.
Seu primeiro JOIN
Para combinar dados de users e orders, você escreve um JOIN:
SELECT users.name, orders.id AS order_id,
orders.total
FROM users
JOIN orders ON orders.user_id = users.id;
Resultado:
name | order_id | total
-------+----------+-------
Alice | 10 | 29.99
Alice | 11 | 15.50
Bob | 12 | 42.00
(3 rows)
Perceba: Carol não tem pedidos, então ela não aparece. Alice tem dois pedidos, então ela aparece duas vezes. O JOIN não inventou nem removeu dados: ele combinou linhas baseado na condição orders.user_id = users.id e uniu as colunas de ambas as tabelas.
Como o banco de dados processa um JOIN
Quando você submete uma query com JOIN, o motor do banco de dados não "procura as coisas." Ele segue um processo de múltiplas etapas, e a estratégia física que ele escolhe tem um impacto enorme na performance. Existem três algoritmos principais:
Nested loop join
A abordagem mais simples. Para cada linha na tabela A, percorre todas as linhas na tabela B procurando correspondências.
Para cada linha em users: -- loop externo
Para cada linha em orders: -- loop interno
Se orders.user_id = users.id:
Produz linha combinada
Com 3 usuários e 3 pedidos, são 3 x 3 = 9 comparações. Com 10.000 usuários e 100.000 pedidos, são 1 bilhão de comparações. Isso fica lento rápido: a menos que exista um índice na coluna de join da tabela interna, que permite ao loop interno pular diretamente para as linhas correspondentes.
Hash join
O banco de dados constrói uma tabela hash a partir da tabela menor, depois consulta essa hash com cada linha da tabela maior.
Etapa 1: Construir tabela hash de users
{ 1 => Alice, 2 => Bob, 3 => Carol }
Etapa 2: Para cada linha em orders:
Consultar orders.user_id na tabela hash
Se encontrado → produzir linha combinada
Hash joins são rápidos para tabelas grandes sem índice útil. A contrapartida é memória: a tabela hash deveria caber no work_mem. Se não couber, o PostgreSQL divide o trabalho em lotes no disco, o que ainda funciona, só que mais devagar.
Merge join
Ambas as tabelas são ordenadas pela coluna de join, então o banco de dados percorre as duas listas ordenadas simultaneamente, como mesclar dois baralhos de cartas ordenados.
users (ordenado por id): 1, 2, 3
orders (ordenado por user_id): 1, 1, 2
Ponteiro A → users[1], Ponteiro B → orders[1]
Match → produz (Alice, pedido 10)
Ponteiro B avança → orders[1]
Match → produz (Alice, pedido 11)
Ponteiro B avança → orders[2]
Ponteiro A avança → users[2]
Match → produz (Bob, pedido 12)
Merge joins são extremamente eficientes quando ambas as entradas já estão ordenadas (por exemplo, por um índice). Eles não precisam de memória extra além dos buffers de ordenação.
Qual o PostgreSQL escolhe?
O query planner decide. Ele estima o custo de cada estratégia baseado no tamanho das tabelas, índices disponíveis e estatísticas sobre a distribuição dos dados. Você pode ver a escolha dele usando EXPLAIN:
EXPLAIN SELECT users.name, orders.total
FROM users
JOIN orders ON orders.user_id = users.id;
Você vai aprender a ler a saída do EXPLAIN na Aula 6. Por enquanto, guarde isto: JOINs não são mágica. São algoritmos com características reais de performance que dependem do tamanho dos seus dados e da indexação.
Sintaxe de JOIN: explícita vs. implícita
Você pode encontrar uma sintaxe mais antiga que coloca a condição de join na cláusula WHERE:
-- Join implícito (estilo antigo, evite)
SELECT users.name, orders.total
FROM users, orders
WHERE orders.user_id = users.id;
-- Join explícito (moderno, preferido)
SELECT users.name, orders.total
FROM users
JOIN orders ON orders.user_id = users.id;
Ambos produzem o mesmo resultado. Mas a sintaxe explícita JOIN ... ON é superior porque:
- Ela separa a condição de join (como as tabelas se conectam) das condições de filtro (quais linhas você quer).
- Ela torna a query mais fácil de ler e depurar.
- Ela previne cross joins acidentais (produtos cartesianos) quando você esquece a cláusula WHERE.
Sempre use a sintaxe explícita de JOIN.
A armadilha do produto cartesiano
Se você escrever um JOIN sem cláusula ON (ou esquecer o WHERE na sintaxe implícita), você obtém um produto cartesiano: cada linha da tabela A combinada com cada linha da tabela B:
-- Perigo: sem condição de join
SELECT users.name, orders.total
FROM users
CROSS JOIN orders;
Resultado: 3 usuários x 3 pedidos = 9 linhas, a maioria sem sentido. Com tabelas de produção de 100.000 linhas cada, são 10 bilhões de linhas. Isso vai travar sua query ou consumir toda a memória disponível.
JOINs no Rails
O ActiveRecord fornece o método joins para gerar queries INNER JOIN:
# Encontrar todos os usuários que têm pelo menos um pedido
Identity::User.joins(:orders)
# SQL: SELECT "users".* FROM "users"
# INNER JOIN "orders"
# ON "orders"."user_id" = "users"."id"
# Encontrar usuários com pedidos acima de R$30
Identity::User.joins(:orders)
.where(orders: { total: 30.. })
# SQL: SELECT "users".* FROM "users"
# INNER JOIN "orders"
# ON "orders"."user_id" = "users"."id"
# WHERE "orders"."total" >= 30
O ActiveRecord lê a associação has_many :orders para determinar o nome da tabela, chave estrangeira e condição de join automaticamente. Você nunca escreve SQL cru para joins padrão: o ActiveRecord cuida disso.
Neste codebase do LMS, o mesmo padrão aparece em todo lugar:
# Encontrar todas as aulas de cursos nível junior
Catalog::Lesson.joins(:course)
.where(courses: { level: :junior })
O método joins gera um INNER JOIN. Na Aula 7, você vai aprender sobre left_joins, includes e eager_load: cada um gerando tipos diferentes de JOIN para diferentes casos de uso.
Erros comuns
Erro: achar que um JOIN sempre retorna o mesmo número de linhas da tabela da esquerda.
Realidade: Um JOIN pode retornar mais linhas que qualquer uma das tabelas (quando há múltiplas correspondências) ou menos linhas (quando algumas linhas não têm correspondência). Alice tem dois pedidos, então ela aparece duas vezes no resultado. Carol não tem pedidos, então ela desaparece completamente.
Erro: Esquecer de qualificar nomes de colunas quando ambas as tabelas têm uma coluna com o mesmo nome.
Correção: Sempre prefixe com o nome da tabela: users.id vs orders.id. Referências ambíguas de coluna causam erros no PostgreSQL.
Erro: Usar um JOIN quando você só precisa verificar existência.
Abordagem melhor: Se você só quer saber se um usuário tem pedidos (não os detalhes do pedido), use EXISTS ou uma subquery em vez de um JOIN. JOINs trazem colunas de ambas as tabelas: se você não precisa delas, está desperdiçando trabalho.
Resumo
| Conceito | Ponto Chave |
|---|---|
| Normalização | Dados são divididos em tabelas para prevenir redundância e anomalias |
| Chave estrangeira | Uma coluna que referencia a chave primária de outra tabela: a cola entre tabelas |
| JOIN | Combina linhas de duas tabelas baseado em uma condição de correspondência |
| Nested loop join | O(n*m): rápido com índices, lento sem eles |
| Hash join | Constrói uma tabela hash da tabela menor: rápido para tabelas grandes sem índice |
| Merge join | Percorre duas listas ordenadas simultaneamente: mais rápido quando os dados estão pré-ordenados |
| Query planner | Escolhe automaticamente o melhor algoritmo de join baseado nas estatísticas das tabelas |
| Sintaxe explícita de JOIN | Sempre use JOIN ... ON em vez de tabelas separadas por vírgula com WHERE |
| Produto cartesiano | Um JOIN sem condição: produz n*m linhas, quase sempre é um bug |
ActiveRecord joins |
Gera INNER JOIN a partir das definições de associação: sem necessidade de SQL cru |
Continue com a aula 2
INNER JOIN: linhas correspondentes
Mostra como o INNER JOIN mantém só linhas correspondentes, multiplica linhas e usa aliases e DISTINCT.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.