Usando LIMIT in GROUP BY per ottenere N risultati per gruppo?


388

La seguente query:

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

rendimenti:

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

Quello che mi piacerebbe sono solo i primi 5 risultati per ogni 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

C'è un modo per farlo usando un qualche tipo di modificatore come LIMIT che funziona all'interno di GROUP BY?


10
Questo può essere fatto in MySQL, ma non è così semplice come aggiungere una LIMITclausola. Ecco un articolo che spiega in dettaglio il problema: Come selezionare la prima / minima / max riga per gruppo in SQL È un buon articolo - introduce una soluzione elegante ma ingenua al problema "Top N per gruppo", e poi gradualmente migliora su di esso.
danben,

SELEZIONA * DA (SELEZIONA anno, ID, tasso DA h DOVE anno TRA IL 2000 E IL 2009 E ID IN (SELEZIONA DALLA tabella2) Raggruppa per ID, anno ORDINA PER ID, tasso DESC) LIMIT 5
Mixcoatl

Risposte:


115

È possibile utilizzare la funzione aggregata GROUP_CONCAT per raggruppare tutti gli anni in un'unica colonna, raggruppati ide ordinati per rate:

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

Risultato:

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

E quindi potresti usare FIND_IN_SET , che restituisce la posizione del primo argomento all'interno del secondo, ad es.

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 combinazione di GROUP_CONCATe FIND_IN_SET, e filtrando dalla posizione restituito da find_in_set, si potrebbe quindi utilizzare questa query che restituisce solo i primi 5 anni per ogni 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;

Si prega di vedere il violino qui .

Tieni presente che se più di una riga può avere la stessa tariffa, dovresti considerare l'utilizzo di GROUP_CONCAT (tariffa DISTINCT ORDER BY) nella colonna della tariffa anziché nella colonna dell'anno.

La lunghezza massima della stringa restituita da GROUP_CONCAT è limitata, quindi funziona bene se è necessario selezionare alcuni record per ogni gruppo.


3
È una spiegazione meravigliosamente performante, relativamente semplice e ottima; grazie mille. All'ultimo punto, dove può essere calcolata una lunghezza massima ragionevole, si può usare SET SESSION group_concat_max_len = <maximum length>;Nel caso del PO, un non-problema (poiché il valore predefinito è 1024), ma a titolo di esempio, group_concat_max_len dovrebbe essere almeno 25: 4 (max lunghezza di una stringa dell'anno) + 1 (carattere separatore), volte 5 (primi 5 anni). Le stringhe vengono troncate anziché generare un errore, quindi fai attenzione ad avvisi come 1054 rows in set, 789 warnings (0.31 sec).
Timothy Johns,

Se voglio recuperare esattamente 2 righe anziché da 1 a 5 rispetto a cosa dovrei usare FIND_IN_SET(). Ho provato FIND_IN_SET() =2ma non ho mostrato il risultato come previsto.
Amogh

FIND_IN_SET TRA 1 e 5 assumeranno le prime 5 posizioni di GROUP_CONCAT impostate se la dimensione è uguale o maggiore di 5. Quindi FIND_IN_SET = 2 prenderà solo i dati con la 2a posizione nel tuo GROUP_CONCAT. Ottenere 2 file puoi provare TRA 1 e 2 per la 1a e 2a posizione, supponendo che il set abbia 2 file da dare.
jDub9

Questa soluzione offre prestazioni molto migliori di quelle di Salman per set di dati di grandi dimensioni. Ho comunque dato un pollice in alto a entrambe per soluzioni così intelligenti. Grazie!!
tiomno,

105

La query originale utilizzava variabili utente e ORDER BYsu tabelle derivate; il comportamento di entrambe le stranezze non è garantito. Risposta modificata come segue.

In MySQL 5.x puoi usare il rango di uomo povero sulla partizione per ottenere il risultato desiderato. Appena esterno unisci la tabella con se stesso e per ogni riga, conta il numero di righe in meno di esso. Nel caso precedente, la riga minore è quella con un tasso più elevato:

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

Demo e risultati :

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

Nota che se le tariffe avevano legami, ad esempio:

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

La query sopra restituirà 6 righe:

100, 90, 90, 80, 80, 80

Passare a HAVING COUNT(DISTINCT l.rate) < 5per ottenere 8 righe:

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

Oppure modifica per ON t.id = l.id AND (t.rate < l.rate OR (t.rate = l.rate AND t.pri_key > l.pri_key))ottenere 5 righe:

 100, 90, 90, 80, 80

In MySQL 8 o poi semplicemente utilizzare i RANK, DENSE_RANKoROW_NUMBER funzioni:

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

7
Penso che valga la pena ricordare che la parte fondamentale è ORDER BY id poiché qualsiasi modifica del valore di id ricomincerà a contare in classifica.
ruuter,

Perché dovrei eseguirlo due volte per ottenere la risposta WHERE rank <=5? Per la prima volta non ottengo 5 righe da ciascun ID, ma dopo riesco a ottenere come hai detto.
Brenno Leal,

@BrennoLeal Penso che tu stia dimenticando l' SETaffermazione (vedi prima query). È necessario.
Salman A

3
Nelle versioni più recenti, la ORDER BYtabella derivata può e spesso verrà ignorata. Questo sconfigge l'obiettivo. Qui si trova un efficiente gruppo .
Rick James,

1
+1 la riscrittura della tua risposta è molto valida, poiché le moderne versioni di MySQL / MariaDB seguono gli standard ANSI / ISO SQL 1992/1999/2003 più dove non è mai stato permesso di usarlo ORDER BYin consegne / sottoquery del genere .. Questo è il motivo per cui le moderne versioni di MySQL / MariaDB ignorano la ORDER BYsottoquery senza usare LIMIT, credo che gli standard SQL ANSI / ISO 2008/2011/2016 rendano ORDER BYlegali le consegne / sottoquery quando lo si utilizza in combinazione conFETCH FIRST n ROWS ONLY
Raymond Nijland

21

Per me qualcosa del genere

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

funziona perfettamente. Nessuna query complicata.


ad esempio: ottieni i primi 1 per ciascun gruppo

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;

La tua soluzione ha funzionato perfettamente, ma voglio anche recuperare anni e altre colonne dalla sottoquery. Come possiamo farlo?
MaNn

9

No, non puoi LIMITARE arbitrariamente le subquery (puoi farlo in misura limitata nei nuovi MySQL, ma non per 5 risultati per gruppo).

Questa è una query di tipo massimo a livello di gruppo, che non è banale da fare in SQL. Esistono vari modi per affrontare ciò che può essere più efficiente in alcuni casi, ma per top-n in generale vorrai guardare la risposta di Bill a una domanda precedente simile.

Come con la maggior parte delle soluzioni a questo problema, può restituire più di cinque righe se ci sono più righe con lo stesso ratevalore, quindi potrebbe essere necessaria una quantità di post-elaborazione per verificarlo.


9

Ciò richiede una serie di sottoquery per classificare i valori, limitarli, quindi eseguire la somma durante il raggruppamento

@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

Prova questo:

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
colonna sconosciuta a. tipo nell'elenco dei campi
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 subquery è quasi identica alla tua query. L'unica modifica è l'aggiunta

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

8
Questo è carino ma MySQL non ha funzioni di finestra (come ROW_NUMBER()).
ypercubeᵀᴹ

3
A partire da MySQL 8.0, row_number()è disponibile .
Erickg,

4

Costruisci le colonne virtuali (come RowID in Oracle)

tavolo:

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

dati:

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 come questo:

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 cancella la clausola where in t3, si presenta così:

inserisci qui la descrizione dell'immagine

OTTIENI "TOP N Record" -> aggiungi il "rownum <= 3" nella clausola where (la clausola where di t3);

SCEGLI "l'anno" -> aggiungi "TRA 2000 E 2009" nella clausola where (la clausola where di t3);


Se hai tariffe che si ripetono per lo stesso ID, questo non funzionerà perché il conteggio RowNum aumenterà più in alto; non otterrai 3 per riga, puoi ottenere 0, 1 o 2. Riesci a pensare a una soluzione a questo?
digiuno il

@starvator cambia "t1.rate <= t2.rate" in "t1.rate <t2.rate", se la tariffa migliore ha gli stessi valori nello stesso ID, tutti hanno lo stesso rownum ma non aumenteranno più in alto; come "rate 8 in id p01", se si ripete, usando "t1.rate <t2.rate", entrambi di "rate 8 in id p01" hanno lo stesso rownum 0; se si utilizza "t1.rate <= t2.rate", il rownum è 2;
Wang Wen'an,

3

Ho preso un po 'di lavoro, ma penso che la mia soluzione sarebbe qualcosa da condividere in quanto sembra elegante e abbastanza veloce.

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

Si noti che questo esempio è specificato ai fini della domanda e può essere modificato abbastanza facilmente per altri scopi simili.


2

Il seguente post: sql: selezione del record N più alto per gruppo descrive il modo complicato di raggiungere questo obiettivo senza subquery.

Migliora altre soluzioni offerte qui da:

  • Fare tutto in una sola query
  • Essere in grado di utilizzare correttamente gli indici
  • Evitare le subquery, notoriamente note per produrre piani di cattiva esecuzione in MySQL

Tuttavia non è carino. Una buona soluzione sarebbe realizzabile se le funzioni Window (ovvero le funzioni analitiche) fossero abilitate in MySQL, ma non lo sono. Il trucco usato in detto post utilizza GROUP_CONCAT, che a volte viene descritto come "Funzioni della finestra dei poveri per MySQL".


1

per quelli come me che avevano domande scadute. Ho fatto quanto segue per usare i limiti e qualsiasi altra cosa da un gruppo specifico.

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 $$

scorre un elenco di domini e quindi inserisce solo un limite di 200 ciascuno


1

Prova questo:

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

Prova di seguito la procedura memorizzata. Ho già verificato. Sto ottenendo il risultato corretto ma senza usare 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
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.