Junção SQL: selecionando os últimos registros em um relacionamento um para muitos

298

Suponha que eu tenha uma tabela de clientes e uma tabela de compras. Cada compra pertence a um cliente. Desejo obter uma lista de todos os clientes, juntamente com sua última compra, em uma instrução SELECT. Qual é a melhor prática? Algum conselho sobre a criação de índices?

Use estes nomes de tabela / coluna na sua resposta:

  • cliente: id, nome
  • compra: id, customer_id, item_id, date

E em situações mais complicadas, seria benéfico (em termos de desempenho) desnormalizar o banco de dados colocando a última compra na tabela de clientes?

Se for garantido que o ID (compra) seja classificado por data, as instruções podem ser simplificadas usando algo como LIMIT 1?

netvope
fonte
Sim, pode valer a pena desnormalizar (se melhorar muito o desempenho, que você só pode descobrir testando as duas versões). Mas geralmente vale a pena evitar as desvantagens da desnormalização.
precisa saber é o seguinte

Respostas:

451

Este é um exemplo do greatest-n-per-groupproblema que apareceu regularmente no StackOverflow.

Aqui está como eu geralmente recomendo resolvê-lo:

SELECT c.*, p1.*
FROM customer c
JOIN purchase p1 ON (c.id = p1.customer_id)
LEFT OUTER JOIN purchase p2 ON (c.id = p2.customer_id AND 
    (p1.date < p2.date OR (p1.date = p2.date AND p1.id < p2.id)))
WHERE p2.id IS NULL;

Explicação: dada uma linha p1, não deve haver linha p2com o mesmo cliente e uma data posterior (ou, no caso de empates, posteriormente id). Quando achamos que isso é verdade, então p1é a compra mais recente para esse cliente.

Em relação índices, eu criar um índice composto em purchasesobre as colunas ( customer_id, date, id). Isso pode permitir que a junção externa seja feita usando um índice de cobertura. Teste sua plataforma, pois a otimização depende da implementação. Use os recursos do seu RDBMS para analisar o plano de otimização. Por exemplo, EXPLAINno MySQL.


Algumas pessoas usam subconsultas em vez da solução mostrada acima, mas acho que minha solução facilita a resolução de vínculos.

Bill Karwin
fonte
3
Favoravelmente, em geral. Mas isso depende da marca do banco de dados que você usa e da quantidade e distribuição de dados no seu banco de dados. A única maneira de obter uma resposta precisa é testar as duas soluções em relação aos seus dados.
Bill Karwin
27
Se você deseja incluir clientes que nunca fizeram uma compra, altere JOIN compra p1 ON (c.id = p1.customer_id) para LEFT JOIN compra p1 ON (c.id = p1.customer_id)
GordonM
5
@russds, você precisa de uma coluna única que possa ser usada para resolver o empate. Não faz sentido ter duas linhas idênticas em um banco de dados relacional.
Bill Karwin
6
Qual é o objetivo de "WHERE p2.id IS NULL"?
clu
3
essa solução funciona apenas se houver mais de 1 registro de compra. ist existe 1: 1 link, ele não funciona. não tem que ser "WHERE (p2.id é nulo ou p1.id = p2.id)
de Bruno Jennrich
126

Você também pode tentar fazer isso usando um sub-select

SELECT  c.*, p.*
FROM    customer c INNER JOIN
        (
            SELECT  customer_id,
                    MAX(date) MaxDate
            FROM    purchase
            GROUP BY customer_id
        ) MaxDates ON c.id = MaxDates.customer_id INNER JOIN
        purchase p ON   MaxDates.customer_id = p.customer_id
                    AND MaxDates.MaxDate = p.date

A seleção deve ingressar em todos os clientes e na data da última compra.

Adriaan Stander
fonte
4
Graças isso só me salvou - esta solução parece mais reasable e sustentável, em seguida, os outros listados + não é produto específico
Daveo
Como eu modificaria isso se quisesse obter um cliente, mesmo que não houvesse compras?
clu
3
@clu: altere INNER JOINpara a LEFT OUTER JOIN.
Sasha Chedygov
3
Parece que isso pressupõe que há apenas uma compra nesse dia. Se houvesse dois, você obteria duas linhas de saída para um cliente, eu acho?
precisa
1
@IstiaqueAhmed - o último INNER JOIN pega esse valor de Max (data) e o vincula novamente à tabela de origem. Sem essa junção, as únicas informações que você teria da purchasetabela são a data e o customer_id, mas a consulta solicita todos os campos da tabela.
Vergil rindo 22/04/19
26

Você não especificou o banco de dados. Se alguém permitir funções analíticas, pode ser mais rápido usar essa abordagem do que a abordagem GROUP BY (definitivamente mais rápida no Oracle, provavelmente mais rápida nas edições finais do SQL Server, não conheço outras).

A sintaxe no SQL Server seria:

SELECT c.*, p.*
FROM customer c INNER JOIN 
     (SELECT RANK() OVER (PARTITION BY customer_id ORDER BY date DESC) r, *
             FROM purchase) p
ON (c.id = p.customer_id)
WHERE p.r = 1
Madalina Dragomir
fonte
10
Esta é a resposta errada para a pergunta porque você está usando "RANK ()" em vez de "ROW_NUMBER ()". RANK ainda fornecerá o mesmo problema de empate quando duas compras tiverem exatamente a mesma data. É isso que a função Ranking faz; se as duas principais coincidirem, ambas receberão o valor 1 e o terceiro registro obterá o valor 3. Com Row_Number, não há empate, é exclusivo para toda a partição.
precisa saber é o seguinte
4
Tentando a abordagem de Bill Karwin contra a abordagem de Madalina aqui, com os planos de execução ativados no servidor sql 2008, descobri que a abordagem de Bill Karwin tinha um custo de consulta de 43%, em oposição à abordagem de Madalina, que usava 57% - portanto, apesar da sintaxe mais elegante desta resposta, eu ainda favoreceria a versão de Bill!
Shawson 12/04
26

Outra abordagem seria usar uma NOT EXISTScondição em sua condição de associação para testar compras posteriores:

SELECT *
FROM customer c
LEFT JOIN purchase p ON (
       c.id = p.customer_id
   AND NOT EXISTS (
     SELECT 1 FROM purchase p1
     WHERE p1.customer_id = c.id
     AND p1.id > p.id
   )
)
Stefan Haberl
fonte
Você pode explicar a AND NOT EXISTSparte em palavras fáceis?
Istiaque Ahmed
A sub-seleção apenas verifica se há uma linha com um ID mais alto. Você receberá apenas uma linha no seu conjunto de resultados, se nenhum com um ID maior for encontrado. Esse deve ser o único mais alto.
Stefan Haberl
2
Isso para mim é a solução mais legível . Se isso é importante.
Fguillen
:) Obrigado. Eu sempre busco a solução mais legível, porque isso é importante.
Stefan Haberl
19

Encontrei este tópico como uma solução para o meu problema.

Mas quando os experimentei, o desempenho foi baixo. Abaixo está a minha sugestão para um melhor desempenho.

With MaxDates as (
SELECT  customer_id,
                MAX(date) MaxDate
        FROM    purchase
        GROUP BY customer_id
)

SELECT  c.*, M.*
FROM    customer c INNER JOIN
        MaxDates as M ON c.id = M.customer_id 

Espero que isso seja útil.

Mathee
fonte
para obter apenas 1 i usado top 1e ordered it byMaxDatedesc
Roshna Omer
1
esta é uma solução fácil e simples, no meu caso (muitos clientes, poucas compras) 10% mais rápido, então a solução da @Stefan Haberl e mais de 10 vezes melhor do que resposta aceita
Juraj Bezručka
Ótima sugestão usando expressões comuns de tabela (CTE) para resolver esse problema. Isso melhorou drasticamente o desempenho das consultas em muitas situações.
AdamsTips
Melhor resposta imo, fácil de ler, a cláusula MAX () oferece ótimo desempenho dividido em ORDER BY + LIMIT 1
mrj 28/02
10

Se você estiver usando o PostgreSQL, poderá DISTINCT ONencontrar a primeira linha de um grupo.

SELECT customer.*, purchase.*
FROM customer
JOIN (
   SELECT DISTINCT ON (customer_id) *
   FROM purchase
   ORDER BY customer_id, date DESC
) purchase ON purchase.customer_id = customer.id

PostgreSQL Docs - Distinto em

Observe que os DISTINCT ONcampos - aqui customer_id- devem corresponder aos campos mais à esquerda no campoORDER BY cláusula.

Advertência: Esta é uma cláusula fora do padrão.

Tate Thurston
fonte
8

Tente isso, vai ajudar.

Eu usei isso no meu projeto.

SELECT 
*
FROM
customer c
OUTER APPLY(SELECT top 1 * FROM purchase pi 
WHERE pi.customer_id = c.Id order by pi.Id desc) AS [LastPurchasePrice]
Rahul Murari
fonte
De onde vem o pseudônimo "p"?
TiagoA
isto não executar bem .... levou uma eternidade, onde outros exemplos aqui tomou 2 segundos sobre o conjunto de dados que eu tenho ....
Joel_J
3

Testado em SQLite:

SELECT c.*, p.*, max(p.date)
FROM customer c
LEFT OUTER JOIN purchase p
ON c.id = p.customer_id
GROUP BY c.id

A max()função agregada garantirá que a compra mais recente seja selecionada de cada grupo (mas assume que a coluna da data esteja em um formato no qual max () forneça a mais recente - o que normalmente é o caso). Se você quiser lidar com compras com a mesma data, poderá usarmax(p.date, p.id) .

Em termos de índices, eu usaria um índice na compra com (customer_id, date, [qualquer outra coluna de compra que você deseje retornar no seu select]).

O LEFT OUTER JOIN(ao contrário de INNER JOIN) garantirá que os clientes que nunca fizeram uma compra também sejam incluídos.

Marca
fonte
. não vai correr em t-sql como o seleto c * tem colunas não no grupo pela cláusula
Joel_J
1

Por favor, tente isso,

SELECT 
c.Id,
c.name,
(SELECT pi.price FROM purchase pi WHERE pi.Id = MAX(p.Id)) AS [LastPurchasePrice]
FROM customer c INNER JOIN purchase p 
ON c.Id = p.customerId 
GROUP BY c.Id,c.name;
Milad Shahbazi
fonte