Usando LIMIT no GROUP BY para obter N resultados por grupo?

388

A seguinte 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

rendimentos:

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

O que eu gostaria é apenas os 5 principais 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

Existe uma maneira de fazer isso usando algum tipo de LIMIT como modificador que funciona dentro do GROUP BY?

Wells
fonte
10
Isso pode ser feito no MySQL, mas não é tão simples quanto adicionar uma LIMITcláusula. Aqui está um artigo que explica o problema em detalhes: Como selecionar a primeira / menor / máxima linha por grupo no SQL É um bom artigo - ele apresenta uma solução elegante, porém ingênua, para o problema "Top N por grupo" e depois gradualmente melhora nisso.
danben
SELECT * FROM (ano SELECT, id, taxa de H ONDE ano entre 2000 e 2009 e no id (select livrar DE table2) GROUP BY id, ORDER BY ano id, DESC taxa) LIMITE DE 5
Mixcoatl

Respostas:

115

Você pode usar a função agregada GROUP_CONCAT para obter todos os anos em uma única coluna, agrupada ide 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                |
-----------------------------------------------------------

E então você pode usar FIND_IN_SET , que retorna a posição do primeiro argumento dentro do segundo, por exemplo.

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 uma combinação de GROUP_CONCATe FIND_IN_SET, e filtrar pela posição retornado por FIND_IN_SET, você poderia, então, usar essa consulta que retorna apenas os primeiros 5 anos 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, veja violino aqui .

Observe que se mais de uma linha puder ter a mesma taxa, considere usar GROUP_CONCAT (taxa DISTINCT de ORDER BY rate) na coluna de taxa, em vez da coluna do ano.

O comprimento máximo da sequência retornada por GROUP_CONCAT é limitado, portanto, isso funcionará bem se você precisar selecionar alguns registros para cada grupo.

fthiella
fonte
3
Isso é lindamente desempenho, comparativamente simples e ótima explicação; muito obrigado. Até o último ponto, onde um comprimento máximo razoável pode ser calculado, pode-se usar SET SESSION group_concat_max_len = <maximum length>;No caso do OP, um não problema (já que o padrão é 1024), mas a título de exemplo, group_concat_max_len deve ser de pelo menos 25: 4 (máximo comprimento de uma sequência de anos) + 1 (caractere separador), vezes 5 (primeiros 5 anos). As cadeias são truncadas em vez de gerar um erro; portanto, observe avisos como 1054 rows in set, 789 warnings (0.31 sec).
Timothy Johns
Se eu quiser buscar exatamente 2 linhas em vez de 1 a 5 do que o que devo usar FIND_IN_SET(). Eu tentei, FIND_IN_SET() =2mas não mostrando o resultado conforme o esperado.
Amogh
FIND_IN_SET ENTRE 1 e 5 ocupará as primeiras 5 posições de GROUP_CONCAT definidas se o tamanho for igual ou superior a 5. Portanto, FIND_IN_SET = 2 utilizará apenas os dados com a 2ª posição em seu GROUP_CONCAT. Obtendo 2 linhas, você pode tentar ENTRE 1 e 2 para a 1ª e a 2ª posição, assumindo que o conjunto tem 2 linhas para fornecer.
JDub9
Essa solução tem um desempenho muito melhor que o de Salman para grandes conjuntos de dados. De qualquer forma, dei um joinha aos dois por soluções tão inteligentes. Obrigado!!
Tiomno
105

A consulta original usou variáveis ​​do usuário e ORDER BYem tabelas derivadas; o comportamento de ambas as peculiaridades não é garantido. Resposta revisada da seguinte forma.

No MySQL 5.x, você pode usar a classificação do pobre homem sobre a partição para obter o resultado desejado. Apenas junte a tabela externamente e, para cada linha, conte o número de linhas menor que ele. No caso acima, linha menor é aquela com taxa mais 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

Demonstração e 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    |

Observe que se as taxas tiverem vínculos, por exemplo:

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

A consulta acima retornará 6 linhas:

100, 90, 90, 80, 80, 80

Mude para HAVING COUNT(DISTINCT l.rate) < 5para obter 8 linhas:

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

Ou mude para ON t.id = l.id AND (t.rate < l.rate OR (t.rate = l.rate AND t.pri_key > l.pri_key))para obter 5 linhas:

 100, 90, 90, 80, 80

No MySQL 8 ou posterior, basta usar as funções RANK, DENSE_RANKouROW_NUMBER :

SELECT *
FROM (
    SELECT *, RANK() OVER (PARTITION BY id ORDER BY rate DESC) AS rnk
    FROM t
) AS x
WHERE rnk <= 5
Salman A
fonte
7
Eu acho que vale a pena mencionar que a parte principal é ORDER BY id, pois qualquer alteração no valor de id reiniciará a contagem na classificação.
ruuter 27/08/2015
Por que devo executá-lo duas vezes para obter a resposta WHERE rank <=5? Pela primeira vez, não estou conseguindo 5 linhas de cada ID, mas depois disso eu consigo o que você disse.
Brenno Leal
@BrennoLeal Acho que você está esquecendo a SETdeclaração (consulte a primeira consulta). É necessário.
Salman A
3
Nas versões mais recentes, a ORDER BYtabela derivada pode, e geralmente será, ignorada. Isso derrota o objetivo. Grupo eficiente é encontrado aqui .
Rick James
11
+1 sua reescrita de resposta é muito válida, pois as versões modernas do MySQL / MariaDB seguem os padrões ANSI / ISO SQL 1992/1999/2003 mais onde nunca foi realmente permitido o uso ORDER BYem deliverd / subconsultas como essa. Essa é a razão pela qual MySQL moderna / versões MariaDB ignorar o ORDER BYna subconsulta sem usar LIMIT, eu acredito ANSI / ISO Standards SQL 2008/2011/2016 as marcas ORDER BYem deliverd / subconsultas legais quando usá-lo em combinação comFETCH FIRST n ROWS ONLY
Raymond Nijland
21

Para mim, algo como

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

funciona perfeitamente. Nenhuma consulta complicada.


por exemplo: obtenha o primeiro 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;
Vishal Kumar
fonte
Sua solução funcionou perfeitamente, mas também quero recuperar o ano e outras colunas da subconsulta. Como podemos fazer isso?
21419 MaNn
9

Não, você não pode LIMITAR subconsultas arbitrariamente (você pode fazê-lo de forma limitada nos MySQLs mais recentes, mas não para 5 resultados por grupo).

Essa é uma consulta do tipo máximo de grupo, que não é trivial para fazer no SQL. Existem várias maneiras de lidar com o que pode ser mais eficiente em alguns casos, mas para o top-n em geral, você deve considerar a resposta de Bill a uma pergunta anterior semelhante.

Como na maioria das soluções para esse problema, ele pode retornar mais de cinco linhas se houver várias linhas com o mesmo ratevalor; portanto, você ainda precisará de uma quantidade de pós-processamento para verificar isso.

bobince
fonte
9

Isso requer uma série de subconsultas para classificar os valores, limitá-los e executar a soma ao agrupar

@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;
Brian L Cartwright
fonte
9

Tente o seguinte:

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;
Saharsh Shah
fonte
11
a.type coluna desconhecido na lista de campo
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

A subconsulta é quase idêntica à sua consulta. Somente a mudança está adicionando

row_number() over (partition by id order by rate DESC)
Ricky Moreno
fonte
8
Isso é legal, mas o MySQL não possui funções de janela (como ROW_NUMBER()).
precisa saber é o seguinte
3
A partir do MySQL 8.0, row_number()está disponível .
precisa saber é o seguinte
4

Construa as colunas virtuais (como RowID no Oracle)

mesa:

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

dados:

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 assim:

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;

se excluir a cláusula where em t3, será exibida assim:

insira a descrição da imagem aqui

GET "TOP N Record" -> adicione o "rownum <= 3" na cláusula where (a cláusula where de t3);

ESCOLHA "o ano" -> adicione os "ENTRE 2000 E 2009" na cláusula where (a cláusula where de t3);

Wang Wen'an
fonte
Se você tiver taxas que se repetem para o mesmo ID, isso não funcionará porque sua contagem de rowNum aumentará mais; você não receberá 3 por linha, poderá obter 0, 1 ou 2. Você consegue encontrar alguma solução para isso?
starvator
@starvator altere "t1.rate <= t2.rate" para "t1.rate <t2.rate", se a melhor taxa tiver os mesmos valores no mesmo id, todos eles terão o mesmo rownum, mas não aumentarão mais; como "taxa 8 no id p01", se repetir, usando "t1.rate <t2.rate", ambos "taxa 8 no id p01" têm o mesmo rownum 0; se estiver usando "t1.rate <= t2.rate", o rownum é 2;
Wang Wen'an
3

Demorei um pouco para trabalhar, mas acho que minha solução seria algo para compartilhar, pois parece elegante e 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

Observe que este exemplo é especificado para o objetivo da pergunta e pode ser modificado facilmente para outros fins semelhantes.

John
fonte
2

A seguinte postagem: sql: selecionar o registro N superior por grupo descreve a maneira complicada de conseguir isso sem subconsultas.

Ele aprimora outras soluções oferecidas aqui por:

  • Fazendo tudo em uma única consulta
  • Ser capaz de utilizar adequadamente índices
  • Evitando subconsultas, conhecidas por produzirem planos de execução incorretos no MySQL

No entanto, não é bonito. Uma boa solução seria possível se o Window Functions (também conhecido como Analytic Functions) estivesse ativado no MySQL - mas não é. O truque usado no referido post utiliza GROUP_CONCAT, que às vezes é descrito como "Funções de Janela do pobre homem para MySQL".

Shlomi Noach
fonte
1

para aqueles como eu que tiveram tempo limite de consultas. Fiz o abaixo para usar limites e qualquer outra coisa de um 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 $$

ele percorre uma lista de domínios e insere apenas um limite de 200 cada

Dev-Ria
fonte
1

Tente o seguinte:

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;
MLF
fonte
0

Por favor, tente abaixo o procedimento armazenado. Eu já verifiquei. Estou obtendo resultado adequado, mas sem 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
Himanshu Patel
fonte