Jointure SQL: sélection des derniers enregistrements dans une relation un-à-plusieurs

298

Supposons que j'ai une table de clients et une table d'achats. Chaque achat appartient à un client. Je souhaite obtenir une liste de tous les clients ainsi que leur dernier achat dans une seule instruction SELECT. Quelle est la meilleure pratique? Des conseils sur la construction d'index?

Veuillez utiliser les noms de table / colonne dans votre réponse:

  • client: id, nom
  • achat: id, customer_id, item_id, date

Et dans des situations plus compliquées, serait-il (en termes de performances) bénéfique de dénormaliser la base de données en plaçant le dernier achat dans la table client?

S'il est garanti que l'ID (d'achat) est trié par date, les déclarations peuvent-elles être simplifiées en utilisant quelque chose comme LIMIT 1?

netvope
la source
Oui, cela peut valoir la peine d'être dénormalisé (s'il améliore beaucoup les performances, ce que vous ne pouvez découvrir qu'en testant les deux versions). Mais les inconvénients de la dénormalisation méritent généralement d’être évités.
Vince Bowdren

Réponses:

451

Ceci est un exemple du greatest-n-per-groupproblème qui est apparu régulièrement sur StackOverflow.

Voici comment je recommande généralement de le résoudre:

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;

Explication: étant donné une ligne p1, il ne devrait pas y avoir de ligne p2avec le même client et une date ultérieure (ou en cas d'égalité, une date ultérieure id). Lorsque nous constatons que cela est vrai, il p1s'agit de l'achat le plus récent pour ce client.

En ce qui concerne les indices, je crée un indice composé purchasesur les colonnes ( customer_id, date, id). Cela peut permettre à la jointure externe d'être effectuée à l'aide d'un index de recouvrement. Assurez-vous de tester sur votre plate-forme, car l'optimisation dépend de l'implémentation. Utilisez les fonctionnalités de votre SGBDR pour analyser le plan d'optimisation. Par exemple EXPLAINsur MySQL.


Certaines personnes utilisent des sous-requêtes au lieu de la solution que je montre ci-dessus, mais je trouve que ma solution facilite la résolution des liens.

Bill Karwin
la source
3
Favorablement, en général. Mais cela dépend de la marque de base de données que vous utilisez, ainsi que de la quantité et de la distribution des données dans votre base de données. La seule façon d'obtenir une réponse précise consiste à tester les deux solutions par rapport à vos données.
Bill Karwin
27
Si vous souhaitez inclure des clients qui n'ont jamais effectué d'achat, modifiez JOIN JOIN p1 ON (c.id = p1.customer_id) en LEFT JOIN Purchase p1 ON (c.id = p1.customer_id)
GordonM
5
@russds, vous avez besoin d'une colonne unique que vous pouvez utiliser pour résoudre le lien. Cela n'a aucun sens d'avoir deux lignes identiques dans une base de données relationnelle.
Bill Karwin
6
Quel est le but de "WHERE p2.id IS NULL"?
clu
3
cette solution ne fonctionne que s'il existe plusieurs enregistrements d'achat. Il existe un lien 1: 1, cela ne fonctionne PAS. il doit y être "O ((p2.id EST NUL ou p1.id = p2.id)
Bruno Jennrich
126

Vous pouvez également essayer de le faire en utilisant une sous-sélection

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

La sélection doit rejoindre tous les clients et leur dernière date d'achat.

Adriaan Stander
la source
4
Merci, cela m'a sauvé - cette solution semble plus faisable et maintenable que les autres répertoriées + ce n'est pas spécifique au produit
Daveo
Comment pourrais-je modifier cela si je voulais obtenir un client même s'il n'y avait pas d'achats?
clu
3
@clu: remplacez le INNER JOINpar a LEFT OUTER JOIN.
Sasha Chedygov
3
On dirait que cela suppose qu'il n'y a qu'un seul achat ce jour-là. S'il y en avait deux, vous obtiendriez deux lignes de sortie pour un client, je pense?
artfulrobot
1
@IstiaqueAhmed - le dernier INNER JOIN prend cette valeur Max (date) et la lie à la table source. Sans cette jointure, les seules informations que vous auriez dans la purchasetable sont la date et le client_id, mais la requête demande tous les champs de la table.
Laughing Vergil
26

Vous n'avez pas spécifié la base de données. Si elle permet des fonctions analytiques, il peut être plus rapide d'utiliser cette approche que celle de GROUP BY (certainement plus rapide dans Oracle, probablement plus rapide dans les dernières éditions de SQL Server, je ne sais pas pour les autres).

La syntaxe dans SQL Server serait:

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
la source
10
Il s'agit de la mauvaise réponse à la question car vous utilisez "RANK ()" au lieu de "ROW_NUMBER ()". RANK vous posera toujours le même problème de liens lorsque deux achats ont exactement la même date. C'est ce que fait la fonction de classement; si les 2 premiers correspondent, ils reçoivent tous deux la valeur 1 et le 3ème enregistrement obtient une valeur 3. Avec Row_Number, il n'y a pas de lien, il est unique pour toute la partition.
MikeTeeVee
4
En essayant l'approche de Bill Karwin contre l'approche de Madalina ici, avec les plans d'exécution activés sous SQL Server 2008, j'ai trouvé que l'approbation de Bill Karwin avait un coût de requête de 43% par opposition à l'approche de Madalina qui utilisait 57% - donc malgré la syntaxe plus élégante de cette réponse, je serait toujours en faveur de la version de Bill!
Shawson
26

Une autre approche consiste à utiliser une NOT EXISTScondition dans votre condition de jointure pour tester les achats ultérieurs:

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
la source
Pouvez-vous expliquer AND NOT EXISTS partie en termes simples?
Istiaque Ahmed
La sous-sélection vérifie simplement s'il y a une ligne avec un identifiant supérieur. Vous n'aurez qu'une ligne dans votre jeu de résultats, si aucune avec un identifiant supérieur n'est trouvée. Cela devrait être l'unique plus élevé.
Stefan Haberl
2
C'est pour moi la solution la plus lisible . Si c'est important.
fguillen
:) Merci. Je cherche toujours la solution la plus lisible, car cela est importante.
Stefan Haberl
19

J'ai trouvé ce fil comme solution à mon problème.

Mais quand je les ai essayés, les performances étaient faibles. Ci-dessous est ma suggestion pour de meilleures performances.

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 

J'espère que cela vous sera utile.

Mathee
la source
obtenir seulement 1 j'ai utilisé top 1et ordered it byMaxDatedesc
Roshna Omer
1
c'est une solution simple et directe, dans MON cas (de nombreux clients, quelques achats) 10% plus rapide que la solution de @Stefan Haberl et plus de 10 fois meilleure que la réponse acceptée
Juraj Bezručka
Grande suggestion utilisant des expressions de table communes (CTE) pour résoudre ce problème. Cela a considérablement amélioré les performances des requêtes dans de nombreuses situations.
AdamsTips
Meilleure réponse imo, facile à lire, la clause MAX () offre d'excellentes performances compartimentées à ORDER BY + LIMIT 1
mrj
10

Si vous utilisez PostgreSQL, vous pouvez utiliser DISTINCT ONpour trouver la première ligne d'un groupe.

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

Documents PostgreSQL - Distinct On

Notez que le ou les DISTINCT ONchamps - ici customer_id- doivent correspondre au (x) champ (s) le plus à gauche de la ORDER BYclause.

Mise en garde: Il s'agit d'une clause non standard.

Tate Thurston
la source
8

Essayez ceci, cela vous aidera.

Je l'ai utilisé dans mon projet.

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
la source
D'où vient l'alias "p"?
TiagoA
cela ne fonctionne pas bien .... a pris une éternité là où d'autres exemples ici ont pris 2 secondes sur l'ensemble de données que j'ai ....
Joel_J
3

Testé sur 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

La max()fonction d'agrégation s'assurera que le dernier achat est sélectionné dans chaque groupe (mais suppose que la colonne de date est dans un format où max () donne le dernier - ce qui est normalement le cas). Si vous souhaitez gérer les achats avec la même date, vous pouvez utiliser max(p.date, p.id).

En termes d'index, j'utiliserais un index lors de l'achat avec (customer_id, date, [toute autre colonne d'achat que vous souhaitez retourner dans votre sélection]).

Le LEFT OUTER JOIN(par opposition à INNER JOIN) s'assurera que les clients qui n'ont jamais effectué d'achat sont également inclus.

marque
la source
ne fonctionnera pas dans t-sql car le select c. * a des colonnes qui ne sont pas dans la clause group by
Joel_J
1

Veuillez essayer ceci,

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
la source