Vous utilisez LIMIT dans GROUP BY pour obtenir N résultats par groupe?


388

La requête suivante:

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

rendements:

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

Ce que j'aimerais, c'est seulement les 5 premiers résultats pour chaque identifiant:

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-t-il un moyen de le faire en utilisant une sorte de modificateur de type LIMIT qui fonctionne dans le GROUP BY?


10
Cela peut être fait dans MySQL, mais ce n'est pas aussi simple que d'ajouter une LIMITclause. Voici un article qui explique le problème en détail: Comment sélectionner la première / moins / max ligne par groupe dans SQL C'est un bon article - il présente une solution élégante mais naïve au problème "Top N par groupe", puis progressivement s'améliore.
danben

SELECT * FROM (SELECT année, id, taux FROM h OERE année entre 2000 et 2009 ET id IN (SELECT débarrassé du tableau 2) GROUP BY id, année ORDER BY id, taux DESC) LIMIT 5
Mixcoatl

Réponses:


115

Vous pouvez utiliser la fonction agrégée GROUP_CONCAT pour regrouper toutes les années dans une seule colonne, regroupées idet triées par rate:

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

Résultat:

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

Et puis vous pouvez utiliser FIND_IN_SET , qui retourne la position du premier argument à l'intérieur du second, par exemple.

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

En utilisant une combinaison de GROUP_CONCATet FIND_IN_SET, et en filtrant par la position retournée par find_in_set, vous pouvez ensuite utiliser cette requête qui ne renvoie que les 5 premières années pour chaque 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;

Veuillez voir le violon ici .

Veuillez noter que si plusieurs lignes peuvent avoir le même taux, vous devriez envisager d'utiliser GROUP_CONCAT (taux DISTINCT ORDER BY) sur la colonne taux au lieu de la colonne année.

La longueur maximale de la chaîne renvoyée par GROUP_CONCAT est limitée, donc cela fonctionne bien si vous devez sélectionner quelques enregistrements pour chaque groupe.


3
C'est magnifiquement performant, relativement simple et une excellente explication; Merci beaucoup. Pour votre dernier point, où une longueur maximale raisonnable peut être calculée, on peut utiliser SET SESSION group_concat_max_len = <maximum length>;dans le cas de l'OP, un non-problème (puisque la valeur par défaut est 1024), mais à titre d'exemple, group_concat_max_len doit être d'au moins 25: 4 (max longueur d'une chaîne d'année) + 1 (caractère séparateur), multiplié par 5 (5 premières années). Les chaînes sont tronquées plutôt que de générer une erreur, alors faites attention aux avertissements tels que 1054 rows in set, 789 warnings (0.31 sec).
Timothy Johns

Si je veux récupérer exactement 2 lignes plutôt que 1 à 5, que dois-je utiliser avec FIND_IN_SET(). J'ai essayé FIND_IN_SET() =2mais ne montrant pas le résultat comme prévu.
Amogh

FIND_IN_SET ENTRE 1 et 5 prendra les 5 premières positions de GROUP_CONCAT définies si la taille est supérieure ou égale à 5. Donc FIND_IN_SET = 2 ne prendra que les données avec la 2e position dans votre GROUP_CONCAT. En obtenant 2 rangées, vous pouvez essayer entre 1 et 2 pour la 1ère et la 2ème position en supposant que l'ensemble a 2 lignes à donner.
jDub9

Cette solution a des performances bien meilleures que celles de Salman pour les grands ensembles de données. J'ai quand même donné un coup de pouce aux deux pour des solutions aussi intelligentes. Merci!!
tiomno

105

La requête d'origine utilisait des variables utilisateur et ORDER BYsur des tables dérivées; le comportement des deux bizarreries n'est pas garanti. Réponse révisée comme suit.

Dans MySQL 5.x, vous pouvez utiliser le classement du pauvre sur la partition pour obtenir le résultat souhaité. Il suffit de joindre la table avec elle-même et pour chaque ligne, compter le nombre de lignes de moins qu'elle. Dans le cas ci-dessus, la ligne la plus petite est celle avec le taux le plus élevé:

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

Démo et résultat :

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

Notez que si les taux avaient des liens, par exemple:

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

La requête ci-dessus renverra 6 lignes:

100, 90, 90, 80, 80, 80

Changez pour HAVING COUNT(DISTINCT l.rate) < 5obtenir 8 lignes:

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

Ou changez pour ON t.id = l.id AND (t.rate < l.rate OR (t.rate = l.rate AND t.pri_key > l.pri_key))obtenir 5 lignes:

 100, 90, 90, 80, 80

Dans MySQL 8 ou version ultérieure, utilisez simplement les fonctions 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

7
Je pense qu'il vaut la peine de mentionner que la partie clé est de COMMANDER PAR id car tout changement de la valeur de id recommencera à compter en rang.
ruuter

Pourquoi devrais-je l'exécuter deux fois pour obtenir la réponse WHERE rank <=5? Pour la première fois, je n'obtiens pas 5 lignes de chaque identifiant, mais après cela, je peux obtenir comme vous l'avez dit.
Brenno Leal

@BrennoLeal Je pense que vous oubliez la SETdéclaration (voir première requête). Il est nécessaire.
Salman A

3
Dans les versions plus récentes, le ORDER BYdans la table dérivée peut, et sera souvent, ignoré. Cela bat l'objectif. On trouve ici des groupes efficaces .
Rick James

1
+1 votre réécriture de réponse est très valide, car les versions modernes de MySQL / MariaDB suivent les normes ANSI / ISO SQL 1992/1999/2003 davantage là où il n'a jamais vraiment été autorisé à utiliser ORDER BYdans des délivrances / sous-requêtes comme ça .. C'est la raison pour laquelle les versions modernes de MySQL / MariaDB ignorent les ORDER BYsous -requêtes sans utiliser LIMIT, je crois que les normes ANSI / ISO SQL 2008/2011/2016 rendent les ORDER BYlivraisons / sous-requêtes légales lors de leur utilisation en combinaison avecFETCH FIRST n ROWS ONLY
Raymond Nijland

21

Pour moi quelque chose comme

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

marche parfaitement. Pas de requête compliquée.


par exemple: obtenez le top 1 pour chaque groupe

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;

Votre solution a parfaitement fonctionné, mais je souhaite également récupérer l'année et d'autres colonnes de la sous-requête, comment pouvons-nous procéder?
MaNn

9

Non, vous ne pouvez pas LIMITER les sous-requêtes de manière arbitraire (vous pouvez le faire dans une mesure limitée dans les nouveaux MySQL, mais pas pour 5 résultats par groupe).

Il s'agit d'une requête de type groupe maximum, ce qui n'est pas trivial à faire en SQL. Il existe différentes façons de résoudre ce problème, ce qui peut être plus efficace dans certains cas, mais pour le top-n en général, vous voudrez regarder la réponse de Bill à une question précédente similaire.

Comme avec la plupart des solutions à ce problème, il peut renvoyer plus de cinq lignes s'il existe plusieurs lignes avec la même ratevaleur, vous devrez donc peut-être encore une quantité de post-traitement pour vérifier cela.


9

Cela nécessite une série de sous-requêtes pour classer les valeurs, les limiter, puis effectuer la somme lors du regroupement

@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

Essaye ça:

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
colonne inconnue a.type dans la liste des champs
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 sous-requête est presque identique à votre requête. Seul le changement ajoute

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

8
C'est bien mais MySQL n'a pas de fonctions de fenêtre (comme ROW_NUMBER()).
ypercubeᵀᴹ

3
Depuis MySQL 8.0, row_number()est disponible .
erickg

4

Créez les colonnes virtuelles (comme RowID dans Oracle)

table:

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

Les données:

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 comme ceci:

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 supprimez la clause where dans t3, cela se présente comme ceci:

entrez la description de l'image ici

GET "TOP N Record" -> ajouter le "rownum <= 3" dans la clause where (la clause where de t3);

CHOISIR "l'année" -> ajouter la "ENTRE 2000 ET 2009" dans la clause where (la clause where du t3);


Si vous avez des taux qui se répètent pour le même identifiant, cela ne fonctionnera pas car votre nombre rowNum augmentera plus haut; vous n'obtiendrez pas 3 par ligne, vous pouvez obtenir 0, 1 ou 2. Pouvez-vous penser à une solution à cela?
starvator

@starvator changez le "t1.rate <= t2.rate" en "t1.rate <t2.rate", si le meilleur taux a les mêmes valeurs dans le même id, tous ont le même rownum mais n'augmenteront pas plus haut; comme "rate 8 in id p01", s'il se répète, en utilisant "t1.rate <t2.rate", les deux de "rate 8 in id p01" ont le même rownum 0; si vous utilisez "t1.rate <= t2.rate", le rownum est 2;
Wang Wen'an

3

J'ai pris un peu de travail, mais je pense que ma solution serait quelque chose à partager car elle semble élégante et assez rapide.

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

Notez que cet exemple est spécifié aux fins de la question et peut être modifié assez facilement à d'autres fins similaires.


2

Le message suivant: sql: sélection du premier enregistrement N par groupe décrit la manière compliquée d'y parvenir sans sous-requêtes.

Il améliore les autres solutions proposées ici par:

  • Tout faire en une seule requête
  • Être capable d'utiliser correctement les index
  • Éviter les sous-requêtes, notoirement connues pour produire de mauvais plans d'exécution dans MySQL

Ce n'est cependant pas joli. Une bonne solution serait réalisable si les fonctions de fenêtre (alias fonctions analytiques) étaient activées dans MySQL - mais elles ne le sont pas. L'astuce utilisée dans ce post utilise GROUP_CONCAT, qui est parfois décrit comme "les fonctions de fenêtre du pauvre pour MySQL".


1

pour ceux comme moi qui ont expiré. J'ai fait ci-dessous pour utiliser les limites et tout le reste par un groupe spécifique.

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

il parcourt une liste de domaines et insère ensuite une limite de 200 chacun


1

Essaye ça:

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

Veuillez essayer la procédure stockée ci-dessous. J'ai déjà vérifié. J'obtiens un résultat correct mais sans l'utiliser 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
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.