¿Usa LIMIT dentro de GROUP BY para obtener N resultados por grupo?


388

La siguiente consulta:

SELECT
year, id, rate
FROM h
WHERE year BETWEEN 2000 AND 2009
AND id IN (SELECT rid FROM table2)
GROUP BY id, year
ORDER BY id, rate DESC

rendimientos:

year    id  rate
2006    p01 8
2003    p01 7.4
2008    p01 6.8
2001    p01 5.9
2007    p01 5.3
2009    p01 4.4
2002    p01 3.9
2004    p01 3.5
2005    p01 2.1
2000    p01 0.8
2001    p02 12.5
2004    p02 12.4
2002    p02 12.2
2003    p02 10.3
2000    p02 8.7
2006    p02 4.6
2007    p02 3.3

Lo que me gustaría son solo los 5 mejores resultados para cada ID:

2006    p01 8
2003    p01 7.4
2008    p01 6.8
2001    p01 5.9
2007    p01 5.3
2001    p02 12.5
2004    p02 12.4
2002    p02 12.2
2003    p02 10.3
2000    p02 8.7

¿Hay alguna manera de hacer esto usando algún tipo de modificador como LIMIT que funcione dentro de GROUP BY?


10
Esto se puede hacer en MySQL, pero no es tan simple como agregar una LIMITcláusula. Aquí hay un artículo que explica el problema en detalle: Cómo seleccionar la primera / mínima / máxima fila por grupo en SQL Es un buen artículo: presenta una solución elegante pero ingenua para el problema "Top N por grupo", y luego gradualmente mejora en ello.
danben

SELECCIONAR * DESDE (SELECCIONAR año, id, tasa DESDE h DONDE año ENTRE 2000 Y 2009 E ID IN (SELECCIONAR de la tabla2) GRUPO POR id, año ORDENAR POR id, tasa DESC) LÍMITE 5
Mixcoatl

Respuestas:


115

Puede usar la función agregada GROUP_CONCAT para obtener todos los años en una sola columna, agrupada idy ordenada por rate:

SELECT   id, GROUP_CONCAT(year ORDER BY rate DESC) grouped_year
FROM     yourtable
GROUP BY id

Resultado:

-----------------------------------------------------------
|  ID | GROUPED_YEAR                                      |
-----------------------------------------------------------
| p01 | 2006,2003,2008,2001,2007,2009,2002,2004,2005,2000 |
| p02 | 2001,2004,2002,2003,2000,2006,2007                |
-----------------------------------------------------------

Y luego podría usar FIND_IN_SET , que devuelve la posición del primer argumento dentro del segundo, por ejemplo.

SELECT FIND_IN_SET('2006', '2006,2003,2008,2001,2007,2009,2002,2004,2005,2000');
1

SELECT FIND_IN_SET('2009', '2006,2003,2008,2001,2007,2009,2002,2004,2005,2000');
6

Usando una combinación de GROUP_CONCATy FIND_IN_SET, y filtrando por la posición devuelta por find_in_set, puede usar esta consulta que devuelve solo los primeros 5 años para cada id:

SELECT
  yourtable.*
FROM
  yourtable INNER JOIN (
    SELECT
      id,
      GROUP_CONCAT(year ORDER BY rate DESC) grouped_year
    FROM
      yourtable
    GROUP BY id) group_max
  ON yourtable.id = group_max.id
     AND FIND_IN_SET(year, grouped_year) BETWEEN 1 AND 5
ORDER BY
  yourtable.id, yourtable.year DESC;

Por favor, vea el violín aquí .

Tenga en cuenta que si más de una fila puede tener la misma tasa, debería considerar usar GROUP_CONCAT (tasa DISTINCT ORDER BY) en la columna de tasa en lugar de la columna del año.

La longitud máxima de la cadena devuelta por GROUP_CONCAT es limitada, por lo que funciona bien si necesita seleccionar algunos registros para cada grupo.


3
Esa es una bella explicación, relativamente simple y una gran explicación; Muchas gracias. En su último punto, donde se puede calcular una longitud máxima razonable, se puede usar SET SESSION group_concat_max_len = <maximum length>;en el caso del OP, un problema (ya que el valor predeterminado es 1024), pero a modo de ejemplo, group_concat_max_len debe ser al menos 25: 4 (máximo longitud de una cadena de año) + 1 (carácter separador), multiplicado por 5 (primeros 5 años). Las cadenas se truncan en lugar de arrojar un error, así que esté atento a advertencias como 1054 rows in set, 789 warnings (0.31 sec).
Timothy Johns

Si quiero obtener 2 filas exactas en lugar de 1 a 5 de lo que debería usar FIND_IN_SET(). Intenté FIND_IN_SET() =2pero no mostraba el resultado esperado.
Amogh

FIND_IN_SET ENTRE 1 y 5 tomarán las primeras 5 posiciones de GROUP_CONCAT establecidas si el tamaño es igual o mayor a 5. Por lo tanto, FIND_IN_SET = 2 tomará solo los datos con la segunda posición en su GROUP_CONCAT. Al obtener 2 filas, puede intentar ENTRE 1 y 2 para la 1ª y 2ª posición, suponiendo que el conjunto tenga 2 filas para dar.
jDub9

Esta solución tiene un rendimiento mucho mejor que Salman para grandes conjuntos de datos. Di un visto bueno a ambos para obtener soluciones tan inteligentes de todos modos. ¡¡Gracias!!
tiomno

105

La consulta original utilizaba variables de usuario y ORDER BYen tablas derivadas; El comportamiento de ambas peculiaridades no está garantizado. Respuesta revisada de la siguiente manera.

En MySQL 5.x puede usar el rango de pobre sobre la partición para lograr el resultado deseado. Solo unir externamente la tabla consigo mismo y para cada fila, contar el número de filas menor que él. En el caso anterior, la fila menor es la que tiene la tasa más alta:

SELECT t.id, t.rate, t.year, COUNT(l.rate) AS rank
FROM t
LEFT JOIN t AS l ON t.id = l.id AND t.rate < l.rate
GROUP BY t.id, t.rate, t.year
HAVING COUNT(l.rate) < 5
ORDER BY t.id, t.rate DESC, t.year

Demostración y resultado :

| id  | rate | year | rank |
|-----|------|------|------|
| p01 |  8.0 | 2006 | 0    |
| p01 |  7.4 | 2003 | 1    |
| p01 |  6.8 | 2008 | 2    |
| p01 |  5.9 | 2001 | 3    |
| p01 |  5.3 | 2007 | 4    |
| p02 | 12.5 | 2001 | 0    |
| p02 | 12.4 | 2004 | 1    |
| p02 | 12.2 | 2002 | 2    |
| p02 | 10.3 | 2003 | 3    |
| p02 |  8.7 | 2000 | 4    |

Tenga en cuenta que si las tasas tienen vínculos, por ejemplo:

100, 90, 90, 80, 80, 80, 70, 60, 50, 40, ...

La consulta anterior devolverá 6 filas:

100, 90, 90, 80, 80, 80

Cambie a HAVING COUNT(DISTINCT l.rate) < 5para obtener 8 filas:

100, 90, 90, 80, 80, 80, 70, 60

O cambie a ON t.id = l.id AND (t.rate < l.rate OR (t.rate = l.rate AND t.pri_key > l.pri_key))para obtener 5 filas:

 100, 90, 90, 80, 80

En MySQL 8 o posterior solo use las funciones RANK, DENSE_RANKoROW_NUMBER :

SELECT *
FROM (
    SELECT *, RANK() OVER (PARTITION BY id ORDER BY rate DESC) AS rnk
    FROM t
) AS x
WHERE rnk <= 5

77
Creo que vale la pena mencionar que la parte clave es ORDER BY id ya que cualquier cambio en el valor de id reiniciará el conteo en el rango.
ruuter

¿Por qué debería ejecutarlo dos veces para obtener la respuesta WHERE rank <=5? Por primera vez no obtengo 5 filas de cada ID, pero después puedo obtener lo que dijiste.
Brenno Leal

@BrennoLeal Creo que está olvidando la SETdeclaración (consulte la primera consulta). Es necesario.
Salman A

3
En las versiones más recientes, el contenido ORDER BYde la tabla derivada puede, y a menudo lo será, ignorarse. Esto derrota la meta. Eficiente en cuanto a grupos se encuentran aquí .
Rick James

1
+1 su respuesta de reescritura es muy válida, ya que las versiones modernas de MySQL / MariaDB siguen los estándares ANSI / ISO SQL 1992/1999/2003 más donde nunca se permitió su uso ORDER BYen entregas / subconsultas como esa ... Esa es la razón por la cual las versiones modernas de MySQL / MariaDB ignoran la consulta ORDER BYsecundaria sin usarla LIMIT, creo que las normas ANSI / ISO SQL 2008/2011/2016 hacen que las ORDER BYentregas / subconsultas sean legales cuando se usa en combinación conFETCH FIRST n ROWS ONLY
Raymond Nijland

21

Para mi algo como

SUBSTRING_INDEX(group_concat(col_name order by desired_col_order_name), ',', N) 

funciona perfectamente. No hay consulta complicada.


por ejemplo: obtenga el primer 1 para cada grupo

SELECT 
    *
FROM
    yourtable
WHERE
    id IN (SELECT 
            SUBSTRING_INDEX(GROUP_CONCAT(id
                            ORDER BY rate DESC),
                        ',',
                        1) id
        FROM
            yourtable
        GROUP BY year)
ORDER BY rate DESC;

Su solución funcionó a la perfección, pero también quiero recuperar el año y otras columnas de la subconsulta, ¿cómo podemos hacer eso?
MaNn

9

No, no puede LIMITAR las subconsultas de forma arbitraria (puede hacerlo de forma limitada en MySQL más recientes, pero no para obtener 5 resultados por grupo).

Esta es una consulta de tipo máximo grupal, que no es trivial de hacer en SQL. Hay varias formas de abordar lo que puede ser más eficiente en algunos casos, pero para top-n en general, querrá ver la respuesta de Bill a una pregunta anterior similar.

Como con la mayoría de las soluciones a este problema, puede devolver más de cinco filas si hay varias filas con el mismo ratevalor, por lo que aún puede necesitar una cantidad de procesamiento posterior para verificarlo.


9

Esto requiere una serie de subconsultas para clasificar los valores, limitarlos y luego realizar la suma mientras se agrupa

@Rnk:=0;
@N:=2;
select
  c.id,
  sum(c.val)
from (
select
  b.id,
  b.bal
from (
select   
  if(@last_id=id,@Rnk+1,1) as Rnk,
  a.id,
  a.val,
  @last_id=id,
from (   
select 
  id,
  val 
from list
order by id,val desc) as a) as b
where b.rnk < @N) as c
group by c.id;

9

Prueba esto:

SELECT h.year, h.id, h.rate 
FROM (SELECT h.year, h.id, h.rate, IF(@lastid = (@lastid:=h.id), @index:=@index+1, @index:=0) indx 
      FROM (SELECT h.year, h.id, h.rate 
            FROM h
            WHERE h.year BETWEEN 2000 AND 2009 AND id IN (SELECT rid FROM table2)
            GROUP BY id, h.year
            ORDER BY id, rate DESC
            ) h, (SELECT @lastid:='', @index:=0) AS a
    ) h 
WHERE h.indx <= 5;

1
columna desconocida tipo A. en la lista de campos
anu

5
SELECT year, id, rate
FROM (SELECT
  year, id, rate, row_number() over (partition by id order by rate DESC)
  FROM h
  WHERE year BETWEEN 2000 AND 2009
  AND id IN (SELECT rid FROM table2)
  GROUP BY id, year
  ORDER BY id, rate DESC) as subquery
WHERE row_number <= 5

La subconsulta es casi idéntica a su consulta. Solo el cambio es agregar

row_number() over (partition by id order by rate DESC)

8
Esto es bueno, pero MySQL no tiene funciones de ventana (como ROW_NUMBER()).
ypercubeᵀᴹ

3
A partir de MySQL 8.0, row_number()está disponible .
erickg

4

Construya las columnas virtuales (como RowID en Oracle)

mesa:

`
CREATE TABLE `stack` 
(`year` int(11) DEFAULT NULL,
`id` varchar(10) DEFAULT NULL,
`rate` float DEFAULT NULL) 
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`

datos:

insert into stack values(2006,'p01',8);
insert into stack values(2001,'p01',5.9);
insert into stack values(2007,'p01',5.3);
insert into stack values(2009,'p01',4.4);
insert into stack values(2001,'p02',12.5);
insert into stack values(2004,'p02',12.4);
insert into stack values(2005,'p01',2.1);
insert into stack values(2000,'p01',0.8);
insert into stack values(2002,'p02',12.2);
insert into stack values(2002,'p01',3.9);
insert into stack values(2004,'p01',3.5);
insert into stack values(2003,'p02',10.3);
insert into stack values(2000,'p02',8.7);
insert into stack values(2006,'p02',4.6);
insert into stack values(2007,'p02',3.3);
insert into stack values(2003,'p01',7.4);
insert into stack values(2008,'p01',6.8);

SQL como este:

select t3.year,t3.id,t3.rate 
from (select t1.*, (select count(*) from stack t2 where t1.rate<=t2.rate and t1.id=t2.id) as rownum from stack t1) t3 
where rownum <=3 order by id,rate DESC;

si elimina la cláusula where en t3, se muestra así:

ingrese la descripción de la imagen aquí

OBTENGA "TOP N Record" -> agregue el "rownum <= 3" en la cláusula where (la cláusula where de t3);

ELIJA "el año" -> agregue "ENTRE 2000 Y 2009" en la cláusula where (la cláusula where de t3);


Si tiene tasas que se repiten para la misma identificación, esto no funcionará porque su recuento de RowNum aumentará más alto; no obtendrá 3 por fila, puede obtener 0, 1 o 2. ¿Se le ocurre alguna solución para esto?
starvator

@starvator cambie el "t1.rate <= t2.rate" a "t1.rate <t2.rate", si la mejor tasa tiene los mismos valores en la misma identificación, todos tienen el mismo rownum pero no aumentarán más; como "tasa 8 en id p01", si se repite, usando "t1.rate <t2.rate", ambos "tasa 8 en id p01" tienen el mismo rownum 0; si usa "t1.rate <= t2.rate", el rownum es 2;
Wang Wen'an

3

Tomó algo de trabajo, pero creo que mi solución sería algo para compartir, ya que parece elegante y bastante rápido.

SELECT h.year, h.id, h.rate 
  FROM (
    SELECT id, 
      SUBSTRING_INDEX(GROUP_CONCAT(CONCAT(id, '-', year) ORDER BY rate DESC), ',' , 5) AS l
      FROM h
      WHERE year BETWEEN 2000 AND 2009
      GROUP BY id
      ORDER BY id
  ) AS h_temp
    LEFT JOIN h ON h.id = h_temp.id 
      AND SUBSTRING_INDEX(h_temp.l, CONCAT(h.id, '-', h.year), 1) != h_temp.l

Tenga en cuenta que este ejemplo se especifica para el propósito de la pregunta y se puede modificar con bastante facilidad para otros fines similares.


2

La siguiente publicación: sql: selección del registro N superior por grupo describe la forma complicada de lograr esto sin subconsultas.

Mejora en otras soluciones ofrecidas aquí por:

  • Haciendo todo en una sola consulta
  • Ser capaz de utilizar adecuadamente los índices
  • Evitar subconsultas, notoriamente conocidas por producir malos planes de ejecución en MySQL

Sin embargo, no es bonito. Una buena solución sería posible si las Funciones de Windows (también conocidas como Funciones Analíticas) estuvieran habilitadas en MySQL, pero no lo están. El truco utilizado en dicha publicación utiliza GROUP_CONCAT, que a veces se describe como "Funciones de ventana del pobre para MySQL".


1

para aquellos como yo que tenían dudas sobre el tiempo de espera. Hice lo siguiente para usar límites y cualquier otra cosa por un grupo específico.

DELIMITER $$
CREATE PROCEDURE count_limit200()
BEGIN
    DECLARE a INT Default 0;
    DECLARE stop_loop INT Default 0;
    DECLARE domain_val VARCHAR(250);
    DECLARE domain_list CURSOR FOR SELECT DISTINCT domain FROM db.one;

    OPEN domain_list;

    SELECT COUNT(DISTINCT(domain)) INTO stop_loop 
    FROM db.one;
    -- BEGIN LOOP
    loop_thru_domains: LOOP
        FETCH domain_list INTO domain_val;
        SET a=a+1;

        INSERT INTO db.two(book,artist,title,title_count,last_updated) 
        SELECT * FROM 
        (
            SELECT book,artist,title,COUNT(ObjectKey) AS titleCount, NOW() 
            FROM db.one 
            WHERE book = domain_val
            GROUP BY artist,title
            ORDER BY book,titleCount DESC
            LIMIT 200
        ) a ON DUPLICATE KEY UPDATE title_count = titleCount, last_updated = NOW();

        IF a = stop_loop THEN
            LEAVE loop_thru_domain;
        END IF;
    END LOOP loop_thru_domain;
END $$

recorre una lista de dominios y luego inserta solo un límite de 200 cada uno


1

Prueba esto:

SET @num := 0, @type := '';
SELECT `year`, `id`, `rate`,
    @num := if(@type = `id`, @num + 1, 1) AS `row_number`,
    @type := `id` AS `dummy`
FROM (
    SELECT *
    FROM `h`
    WHERE (
        `year` BETWEEN '2000' AND '2009'
        AND `id` IN (SELECT `rid` FROM `table2`) AS `temp_rid`
    )
    ORDER BY `id`
) AS `temph`
GROUP BY `year`, `id`, `rate`
HAVING `row_number`<='5'
ORDER BY `id`, `rate DESC;

0

Por favor, intente debajo del procedimiento almacenado. Ya lo he verificado. Estoy obteniendo el resultado adecuado pero sin usar groupby.

CREATE DEFINER=`ks_root`@`%` PROCEDURE `first_five_record_per_id`()
BEGIN
DECLARE query_string text;
DECLARE datasource1 varchar(24);
DECLARE done INT DEFAULT 0;
DECLARE tenants varchar(50);
DECLARE cur1 CURSOR FOR SELECT rid FROM demo1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;

    SET @query_string='';

      OPEN cur1;
      read_loop: LOOP

      FETCH cur1 INTO tenants ;

      IF done THEN
        LEAVE read_loop;
      END IF;

      SET @datasource1 = tenants;
      SET @query_string = concat(@query_string,'(select * from demo  where `id` = ''',@datasource1,''' order by rate desc LIMIT 5) UNION ALL ');

       END LOOP; 
      close cur1;

    SET @query_string  = TRIM(TRAILING 'UNION ALL' FROM TRIM(@query_string));  
  select @query_string;
PREPARE stmt FROM @query_string;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

END
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.