Unión SQL: selección de los últimos registros en una relación uno a muchos

298

Supongamos que tengo una tabla de clientes y una tabla de compras. Cada compra pertenece a un cliente. Quiero obtener una lista de todos los clientes junto con su última compra en una declaración SELECT. cual es la mejor practica? ¿Algún consejo sobre la creación de índices?

Utilice estos nombres de tabla / columna en su respuesta:

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

Y en situaciones más complicadas, ¿sería beneficioso (en términos de rendimiento) desnormalizar la base de datos colocando la última compra en la tabla de clientes?

Si se garantiza que la identificación (de compra) se ordenará por fecha, ¿se pueden simplificar las declaraciones usando algo como LIMIT 1?

netvope
fuente
Sí, puede valer la pena desnormalizar (si mejora mucho el rendimiento, lo que solo puede descubrir probando ambas versiones). Pero los inconvenientes de la desnormalización generalmente valen la pena evitarlos.
Vince Bowdren

Respuestas:

451

Este es un ejemplo del greatest-n-per-groupproblema que ha aparecido regularmente en StackOverflow.

Así es como generalmente recomiendo resolverlo:

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;

Explicación: dada una fila p1, no debe haber una fila p2con el mismo cliente y una fecha posterior (o en el caso de empates, una posterior id). Cuando descubrimos que eso es cierto, entonces p1es la compra más reciente para ese cliente.

En cuanto a los índices, que crearía un índice compuesto en purchasemás de las columnas ( customer_id, date, id). Eso puede permitir que la unión externa se realice utilizando un índice de cobertura. Asegúrese de probar en su plataforma, porque la optimización depende de la implementación. Use las funciones de su RDBMS para analizar el plan de optimización. Por ejemplo, EXPLAINen MySQL.


Algunas personas usan subconsultas en lugar de la solución que muestro arriba, pero creo que mi solución hace que sea más fácil resolver los lazos.

Bill Karwin
fuente
3
Favorablemente, en general. Pero eso depende de la marca de la base de datos que use, y la cantidad y distribución de datos en su base de datos. La única forma de obtener una respuesta precisa es que pruebe ambas soluciones con sus datos.
Bill Karwin el
27
Si desea incluir clientes que nunca realizaron una compra, cambie JOIN adquirir p1 ON (c.id = p1.customer_id) a IZQUIERDA JOIN adquirir p1 ON (c.id = p1.customer_id)
GordonM
55
@russds, necesita una columna única que pueda usar para resolver el empate. No tiene sentido tener dos filas idénticas en una base de datos relacional.
Bill Karwin el
66
¿Cuál es el propósito de "WHERE p2.id IS NULL"?
clu
3
Esta solución solo funciona si hay más de 1 registros de compra. Si hay un enlace 1: 1, NO funciona. allí tiene que estar "WHERE (p2.id IS NULL or p1.id = p2.id)
Bruno Jennrich
126

También puedes intentar hacer esto usando una sub selección

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

El select debe unirse a todos los clientes y su última fecha de compra.

Adriaan Stander
fuente
44
Gracias, esto solo me salvó: esta solución parece más razonable y mantenible que las otras enumeradas + no es específica del producto
Daveo
¿Cómo modificaría esto si quisiera conseguir un cliente incluso si no hubiera compras?
clu
3
@clu: cambie el INNER JOINa a LEFT OUTER JOIN.
Sasha Chedygov
3
Parece que esto supone que solo hay una compra ese día. Si hubiera dos, obtendría dos filas de salida para un cliente, creo.
artfulrobot
1
@IstiaqueAhmed: el último INNER JOIN toma ese valor Max (fecha) y lo vincula a la tabla de origen. Sin esa unión, la única información que tendría de la purchasetabla es la fecha y el customer_id, pero la consulta solicita todos los campos de la tabla.
Riendo Vergil
26

No ha especificado la base de datos. Si es uno que permite funciones analíticas, puede ser más rápido usar este enfoque que el GRUPO BY (definitivamente más rápido en Oracle, probablemente más rápido en las últimas ediciones de SQL Server, no conozco otros).

La sintaxis en SQL Server sería:

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
fuente
10
Esta es la respuesta incorrecta a la pregunta porque está usando "RANK ()" en lugar de "ROW_NUMBER ()". RANK aún le dará el mismo problema de lazos cuando dos compras tengan exactamente la misma fecha. Eso es lo que hace la función de Clasificación; si los 2 primeros coinciden, a ambos se les asigna el valor de 1 y el tercer registro obtiene un valor de 3. Con Row_Number, no hay empate, es único para toda la partición.
MikeTeeVee
44
Al probar el enfoque de Bill Karwin contra el enfoque de Madalina aquí, con los planes de ejecución habilitados en el servidor SQL 2008, descubrí que el aplazamiento de Bill Karwin tenía un costo de consulta del 43% en comparación con el enfoque de Madalina que usaba el 57%, por lo que a pesar de la sintaxis más elegante de esta respuesta, yo ¡Todavía estaría a favor de la versión de Bill!
Shawson
26

Otro enfoque sería usar una NOT EXISTScondición en su condición de unión para probar 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
fuente
¿Puedes explicar la AND NOT EXISTSparte en palabras fáciles?
Istiaque Ahmed
La sub selección solo verifica si hay una fila con una identificación más alta. Solo obtendrá una fila en su conjunto de resultados, si no se encuentra ninguno con una identificación más alta. Ese debería ser el único más alto.
Stefan Haberl
2
Esta para mí es la solución más legible . Si esto es importante.
fguillen
:) Gracias. Siempre me esfuerzo por la solución más legible, porque eso es importante.
Stefan Haberl
19

Encontré este hilo como una solución a mi problema.

Pero cuando los probé, el rendimiento fue bajo. A continuación es mi sugerencia para un mejor rendimiento.

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 

Esperamos que esto sea útil.

Mathee
fuente
para obtener solo 1 que usé top 1y ordered it byMaxDatedesc
Roshna Omer
1
esta es una solución fácil y directa, en mi caso (muchos clientes, pocas compras) 10% más rápido que la solución de @Stefan Haberl y más de 10 veces mejor que la respuesta aceptada
Juraj Bezručka
Gran sugerencia utilizando expresiones de tabla comunes (CTE) para resolver este problema. Esto ha mejorado drásticamente el rendimiento de las consultas en muchas situaciones.
AdamsTips
Mejor respuesta a la OMI, fácil de leer, la cláusula MAX () ofrece un gran rendimiento compartimentado para ORDER BY + LIMIT 1
mrj
10

Si está utilizando PostgreSQL, puede utilizarlo DISTINCT ONpara encontrar la primera fila de un 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

Documentos de PostgreSQL: distintivo en

Tenga en cuenta que los DISTINCT ONcampos (aquí customer_id) deben coincidir con los campos más a la izquierda de la ORDER BYcláusula.

Advertencia: esta es una cláusula no estándar.

Tate Thurston
fuente
8

Intenta esto, te ayudará.

He usado esto en mi proyecto.

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
fuente
¿De dónde viene el alias "p"?
TiagoA
ésto no funciona bien .... tardaba muchísimo en otros ejemplos aquí tomaron 2 segundos en el conjunto de datos que tengo ....
Joel_J
3

Probado en 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()función de agregado asegurará que se seleccione la última compra de cada grupo (pero se supone que la columna de fecha está en un formato en el que max () proporciona la última, que normalmente es el caso). Si desea manejar compras con la misma fecha, puede usarlas max(p.date, p.id).

En términos de índices, usaría un índice en la compra con (customer_id, date, [cualquier otra columna de compra que desee devolver en su selección]).

El LEFT OUTER JOIN(a diferencia de INNER JOIN) se asegurará de que los clientes que nunca hayan realizado una compra también estén incluidos.

marca
fuente
no se ejecutará en t-sql porque select c. * tiene columnas que no están en el grupo por cláusula
Joel_J
1

Por favor intente esto,

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
fuente