Regroupement

Vous savez donc que les fonctions d'agrégation groupent plusieurs lignes ensemble. Jusqu'à maintenant, toutes les lignes étaient chaque fois regroupées en une seule. Mais ce qui est particulièrement intéressant avec ces fonctions, c'est qu'il est possible de regrouper les lignes en fonction d'un critère, et d'avoir ainsi plusieurs groupes distincts. Par exemple, avec la fonction COUNT(*), vous pouvez compter le nombre de lignes que vous avez dans la table Animal. Mais que diriez-vous de faire des groupes par espèces, et donc de savoir en une seule requête combien vous avez de chats, chiens, etc. ? Un simple groupement, et c'est fait !

Au programme de ce chapitre :

  • les règles et la syntaxe à appliquer pour regrouper des lignes ;
  • le groupement sur plusieurs critères ;
  • les "super-agrégats" ;
  • la sélection de certains groupes sur la base de critères.

Regroupement sur un critère

Pour regrouper les lignes selon un critère, il faut utiliser GROUP BY, qui se place après l'éventuelle clause WHERE (sinon directement après FROM), suivi du nom de la colonne à utiliser comme critère de regroupement.

1
2
3
4
SELECT ...
FROM nom_table
[WHERE condition]
GROUP BY nom_colonne;

Exemple 1 : comptons les lignes dans la table Animal, en regroupant sur le critère de l'espèce (donc avec la colonne espece_id).

1
2
3
SELECT COUNT(*) AS nb_animaux
FROM Animal
GROUP BY espece_id;

nb_animaux

21

20

15

4

Exemple 2 : Même chose, mais on ne prend que les mâles cette fois-ci.

1
2
3
4
SELECT COUNT(*) AS nb_males
FROM Animal
WHERE sexe = 'M'
GROUP BY espece_id;

nb_males

10

9

4

3

C'est déjà intéressant, mais nous n'allons pas en rester là. En effet, il serait quand même mieux de savoir à quelle espèce correspond quel nombre !

Voir d'autres colonnes

Pour savoir à quoi correspond chaque nombre, il suffit d'afficher également le critère qui a permis de regrouper les lignes. Dans notre cas, espece_id.

1
2
3
SELECT espece_id, COUNT(*) AS nb_animaux
FROM Animal
GROUP BY espece_id;

espece_id

nb_animaux

1

21

2

20

3

15

4

4

C'est déjà mieux, mais l'idéal serait d'avoir le nom des espèces directement. Qu'à cela ne tienne, il suffit de faire une jointure ! Sans oublier de changer le critère et de mettre nom_courant à la place de espece_id.

1
2
3
4
SELECT nom_courant, COUNT(*) AS nb_animaux
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant;

nom_courant

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Tortue d'Hermann

15

Colonnes sélectionnées

La règle SQL

Lorsque l'on fait un groupement dans une requête, avec GROUP BY, on ne peut sélectionner que deux types d'éléments dans la clause SELECT :

  • une ou des colonnes ayant servi de critère pour le regroupement ;
  • une fonction d'agrégation (agissant sur n'importe quelle colonne).

Cette règle est d'ailleurs logique. Imaginez la requête suivante :

1
2
3
4
SELECT nom_courant, COUNT(*) AS nb_animaux, date_naissance
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant;

Que vient faire la date de naissance dans cette histoire ? Et surtout, quelle date de naissance espère-t-on sélectionner ? Chaque ligne représente une espèce puisque l'on a regroupé les lignes sur la base de Espece.nom_courant. Donc date_naissance n'a aucun sens par rapport aux groupes formés, une espèce n'ayant pas de date de naissance. Il en est de même pour les colonnes sexe ou commentaires par exemple.

Qu'en est-il des colonnes Espece.id, Animal.espece_id, ou Espece.nom_latin ? En groupant sur le nom courant de l'espèce, ces différentes colonnes ont un sens, et pourraient donc être utiles. Il a cependant été décidé que par sécurité, la sélection de colonnes n'étant pas dans les critères de groupement serait interdite. Cela afin d'éviter les situations comme au-dessus, où les colonnes sélectionnées n'ont aucun sens par rapport au groupement fait. Par conséquent, si vous voulez afficher également l'id de l'espèce et son nom latin, il vous faudra grouper sur les trois colonnes : Espece.nom_latin, Espece.nom_courant et Espece.id. Les groupes créés seront les mêmes qu'en groupant uniquement sur Espece.nom_courant, mais votre requête respectera les standards SQL.

1
2
3
4
SELECT Espece.id, nom_courant, nom_latin, COUNT(*) AS nb_animaux
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant, Espece.id, nom_latin;

id

nom_courant

nom_latin

nb_animaux

2

Chat

Felis silvestris

20

1

Chien

Canis canis

21

4

Perroquet amazone

Alipiopsitta xanthops

4

3

Tortue d'Hermann

Testudo hermanni

15

Le cas MySQL

On ne le répétera jamais assez : MySQL est un SGBD extrêmement permissif. Dans certains cas, c'est bien pratique, mais c'est toujours dangereux. Et notamment en ce qui concerne GROUP BY, MySQL ne sera pas perturbé pour un sou si vous sélectionnez une colonne qui n'est pas dans les critères de regroupement. Reprenons la requête qui sélectionne la colonne date_naissance alors que le regroupement se fait sur la base de l'espece_id. J'insiste, cette requête ne respecte pas la norme SQL, et n'a aucun sens. La plupart des SGBD vous renverront une erreur si vous tentez de l'exécuter.

1
2
3
4
SELECT nom_courant, COUNT(*) AS nb_animaux, date_naissance
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY espece_id;

Pourtant, loin de rouspéter, MySQL donne le résultat suivant :

nom_courant

nb_animaux

date_naissance

Chat

20

2010-03-24 02:23:00

Chien

21

2010-04-05 13:43:00

Perroquet amazone

4

2007-03-04 19:36:00

Tortue d'Hermann

15

2009-08-03 05:12:00

MySQL a tout simplement pris n'importe quelle valeur parmi celles du groupe pour la date de naissance. D'ailleurs, il est tout à fait possible que vous ayez obtenu des valeurs différentes des miennes. Soyez donc très prudents lorsque vous utilisez GROUP BY. Vous faites peut-être des requêtes qui n'ont aucun sens, et MySQL ne vous en avertira pas !

Tri des données

Par défaut dans MySQL, les données sont triées sur la base du critère de regroupement. C'est la raison pour laquelle, dans la requête précédente, la colonne nom_courant est triée par ordre alphabétique : c'est le premier critère de regroupement. MySQL permet d'utiliser les mots-clés ASC et DESC dans une clause GROUP BY pour choisir un tri ascendant (par défaut) ou descendant.

1
2
3
4
SELECT Espece.id, nom_courant, nom_latin, COUNT(*) AS nb_animaux
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant DESC, Espece.id, nom_latin;  -- On trie par ordre anti-alphabétique sur le nom_courant

id

nom_courant

nom_latin

nb_animaux

3

Tortue d'Hermann

Testudo hermanni

15

4

Perroquet amazone

Alipiopsitta xanthops

4

1

Chien

Canis canis

21

2

Chat

Felis silvestris

20

Mais rien n'empêche d'utiliser une clause ORDER BY classique, après la clause GROUP BY. L'ORDER BY sera prioritaire sur l'ordre défini par la clause de regroupement.

Dans le même ordre d'idées, il n'est possible de faire un tri des données qu'à partir d'une colonne qui fait partie des critères de regroupement, ou à partir d'une fonction d'agrégation. Ça n'a pas plus de sens de trier les espèces par date de naissance que de sélectionner une date de naissance par espèce.

Vous pouvez par contre parfaitement écrire ceci :

1
2
3
4
5
SELECT Espece.id, nom_courant, nom_latin, COUNT(*) AS nb_animaux
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant, Espece.id, nom_latin
ORDER BY nb_animaux;

id

nom_courant

nom_latin

nb_animaux

4

Perroquet amazone

Alipiopsitta xanthops

4

3

Tortue d'Hermann

Testudo hermanni

15

2

Chat

Felis silvestris

20

1

Chien

Canis canis

21

Notez que la norme SQL veut que l'on n'utilise pas d'expressions (fonction, opération mathématique,…) dans GROUP BY ou ORDER BY. C'est la raison pour laquelle j'ai mis ORDER BY nb_animaux et non pas ORDER BY COUNT(*), bien qu'avec MySQL les deux fonctionnent. Pensez donc à utiliser des alias pour ces situations.

Et les autres espèces ?

La requête suivante nous donne le nombre d'animaux qu'on possède pour chaque espèce dont on possède au moins un animal. Comment peut-on faire pour afficher également les autres espèces ?

1
2
3
4
SELECT Espece.nom_courant, COUNT(*) AS nb_animaux
FROM Animal
INNER JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant;

Essayons donc avec une jointure externe, puisqu'il faut tenir compte de toutes les espèces, même celles qui n'ont pas de correspondance dans la table Animal.

1
2
3
4
SELECT Espece.nom_courant, COUNT(*) AS nb_animaux
FROM Animal
RIGHT JOIN Espece ON Animal.espece_id = Espece.id -- RIGHT puisque la table Espece est à droite.
GROUP BY nom_courant;

nom_courant

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Rat brun

1

Tortue d'Hermann

15

Les rats bruns apparaissent bien. En revanche, ce n'est pas 1 qu'on attend, mais 0, puisqu'on n'a pas de rats dans notre élevage. Cela dit, ce résultat est logique : avec la jointure externe, on aura une ligne correspondant aux rats bruns, avec NULL dans toutes les colonnes de la table Animal. Donc ce qu'il faudrait, c'est avoir les cinq espèces, mais ne compter que lorsqu'il y a un animal correspondant. Pour ce faire, il suffit de faire COUNT(Animal.espece_id) par exemple.

1
2
3
4
SELECT Espece.nom_courant, COUNT(Animal.espece_id) AS nb_animaux
FROM Animal
RIGHT JOIN Espece ON Animal.espece_id = Espece.id
GROUP BY nom_courant;

nom_courant

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Rat brun

0

Tortue d'Hermann

15

C'est pas magique ça ? :magicien:

Regroupement sur plusieurs critères

J'ai mentionné le fait qu'il était possible de grouper sur plusieurs colonnes, mais jusqu'à maintenant, cela n'a servi qu'à pouvoir afficher correctement les colonnes voulues, sans que ça n'influe sur les groupes. On n'avait donc en fait qu'un seul critère, représenté par plusieurs colonnes. Voyons maintenant un exemple avec deux critères différents (qui ne créent pas les mêmes groupes).

Les deux requêtes suivantes permettent de savoir combien d'animaux de chaque espèce vous avez dans la table Animal, ainsi que combien de mâles et de femelles, toutes espèces confondues.

1
2
3
4
5
6
7
8
SELECT nom_courant, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant;

SELECT sexe, COUNT(*) as nb_animaux
FROM Animal
GROUP BY sexe;

nom_courant

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Tortue d'Hermann

15

sexe

nb_animaux

NULL

3

F

31

M

26

En faisant un regroupement multicritère, il est possible de savoir facilement combien de mâles et de femelles par espèce il y a dans la table Animal. Notez que l'ordre des critères a son importance.

Exemple 1 : on regroupe d'abord sur l'espèce, puis sur le sexe.

1
2
3
4
SELECT nom_courant, sexe, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant, sexe;

nom_courant

sexe

nb_animaux

Chat

NULL

2

Chat

F

9

Chat

M

9

Chien

F

11

Chien

M

10

Perroquet amazone

F

1

Perroquet amazone

M

3

Tortue d'Hermann

NULL

1

Tortue d'Hermann

F

10

Tortue d'Hermann

M

4

Exemple 2 : on regroupe d'abord sur le sexe, puis sur l'espèce.

1
2
3
4
SELECT nom_courant, sexe, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY sexe,nom_courant;

nom_courant

sexe

nb_animaux

Chat

NULL

2

Tortue d'Hermann

NULL

1

Chat

F

9

Chien

F

11

Perroquet amazone

F

1

Tortue d'Hermann

F

10

Chat

M

9

Chien

M

10

Perroquet amazone

M

3

Tortue d'Hermann

M

4

Étant donné que le regroupement par sexe donnait trois groupes différents, et le regroupement par espèce donnait quatre groupes différents, il peut y avoir jusqu'à douze (3 x 4) groupes lorsque l'on regroupe en se basant sur les deux critères. Ici, il y en aura moins puisque le sexe de tous les chiens et de tous les perroquets est défini (pas de NULL).

Super-agrégats

Parlons maintenant de l'option WITH ROLLUP de GROUP BY. Cette option va afficher des lignes supplémentaires dans la table de résultats. Ces lignes représenteront des "super-groupes" (ou super-agrégats), donc des "groupes de groupes". Deux petits exemples, et vous aurez compris !

Exemple avec un critère de regroupement

1
2
3
4
SELECT nom_courant, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant WITH ROLLUP;

nom_courant

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Tortue d'Hermann

15

NULL

60

Nous avons donc 20 chats, 21 chiens, 4 perroquets et 15 tortues. Et combien font 20 + 21 + 4 + 15 ? 60 ! Exactement. La ligne supplémentaire représente donc le regroupement de nos quatre groupes basé sur le critère GROUP BY nom_courant.

Exemple avec deux critères de regroupement

1
2
3
4
5
SELECT nom_courant, sexe, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
WHERE sexe IS NOT NULL                            
GROUP BY nom_courant, sexe WITH ROLLUP;

nom_courant

sexe

nb_animaux

Chat

F

9

Chat

M

9

Chat

NULL

18

Chien

F

11

Chien

M

10

Chien

NULL

21

Perroquet amazone

F

1

Perroquet amazone

M

3

Perroquet amazone

NULL

4

Tortue d'Hermann

F

10

Tortue d'Hermann

M

4

Tortue d'Hermann

NULL

14

NULL

NULL

57

Les deux premières lignes correspondent aux nombres de chats mâles et femelles. Jusque-là, rien de nouveau. Par contre, la troisième ligne est une ligne insérée par WITH ROLLUP, et contient le nombre de chats (mâles et femelles). Nous avons fait des groupes en séparant les espèces et les sexes, et WITH ROLLUP a créé des "super-groupes" en regroupant les sexes mais gardant les espèces séparées. Nous avons donc également le nombre de chiens à la sixième ligne, de perroquets à la neuvième, et de tortues à la douzième. Quant à la toute dernière ligne, c'est un "super-super-groupe" qui réunit tous les groupes ensemble.

C'est en utilisant WITH ROLLUP qu'on se rend compte que l'ordre des critères est vraiment important. En effet, voyons ce qui se passe si on échange les deux critères nom_courant et sexe.

1
2
3
4
5
SELECT nom_courant, sexe, COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
WHERE sexe IS NOT NULL
GROUP BY sexe, nom_courant WITH ROLLUP;

nom_courant

sexe

nb_animaux

Chat

F

9

Chien

F

11

Perroquet amazone

F

1

Tortue d'Hermann

F

10

NULL

F

31

Chat

M

9

Chien

M

10

Perroquet amazone

M

3

Tortue d'Hermann

M

4

NULL

M

26

NULL

NULL

57

Cette fois-ci, les super-groupes ne correspondent pas aux espèces, mais aux sexes, c'est-à-dire au premier critère. Le regroupement se fait bien dans l'ordre donné par les critères.

J'ai ajouté la condition WHERE sexe IS NOT NULL pour des raisons de lisibilité uniquement, étant donné que sans cette condition, d'autres NULL seraient apparus, rendant plus compliquée l'explication des super-agrégats.

NULL, c'est pas joli

Il est possible d'éviter d'avoir ces NULL dans les lignes des super-groupes. Pour cela, on peut utiliser la fonction COALESCE(). Cette fonction prend autant de paramètres que l'on veut, et renvoie le premier paramètre non NULL.

Exemples :

1
2
3
SELECT COALESCE(1, NULL, 3, 4); -- 1
SELECT COALESCE(NULL, 2);       -- 2
SELECT COALESCE(NULL, NULL, 3); -- 3

Voici comment l'utiliser dans le cas des super-agrégats.

1
2
3
4
SELECT COALESCE(nom_courant, 'Total'), COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant WITH ROLLUP;

COALESCE(nom_courant, 'Total')

nb_animaux

Chat

20

Chien

21

Perroquet amazone

4

Tortue d'Hermann

15

Total

60

Tant qu'il s'agit de simples groupes, nom_courant contient bien le nom de l'espèce. COALESCE() renvoie donc celui-ci. Par contre, quand il s'agit du super-groupe, la colonne nom_courant du résultat contient NULL, et donc COALESCE() va renvoyer "Total".

Si vous utilisez COALESCE() dans ce genre de situation, il est impératif que vos critères de regroupement ne contiennent pas NULL (ou que vous éliminiez ces lignes-là). Sinon, vous aurez "Total" à des lignes qui ne sont pas des super-groupes.

Exemple : groupons sur le sexe, sans éliminer les lignes pour lesquelles le sexe n'est pas défini.

1
2
3
4
SELECT COALESCE(sexe, 'Total'), COUNT(*) as nb_animaux
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY sexe WITH ROLLUP;

COALESCE(sexe, 'Total')

nb_animaux

Total

3

F

31

M

26

Total

60

Conditions sur les fonctions d'agrégation

Il n'est pas possible d'utiliser la clause WHERE pour faire des conditions sur une fonction d'agrégation. Donc, si l'on veut afficher les espèces dont on possède plus de 15 individus, la requête suivante ne fonctionnera pas.

1
2
3
4
5
SELECT nom_courant, COUNT(*)
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
WHERE COUNT(*) > 15
GROUP BY nom_courant;
1
ERROR 1111 (HY000): Invalid use of group function

Il faut utiliser une clause spéciale : HAVING. Cette clause se place juste après le GROUP BY.

1
2
3
4
5
SELECT nom_courant, COUNT(*)
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant
HAVING COUNT(*) > 15;

nom_courant

COUNT(*)

Chat

20

Chien

21

Il est également possible d'utiliser un alias dans une condition HAVING :

1
2
3
4
5
SELECT nom_courant, COUNT(*) as nombre
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant
HAVING nombre > 15;

nom_courant

nombre

Chat

20

Chien

21

Optimisation

Les conditions données dans la clause HAVING ne doivent pas nécessairement comporter une fonction d'agrégation. Les deux requêtes suivantes donneront par exemple des résultats équivalents :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
SELECT nom_courant, COUNT(*) as nombre
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
GROUP BY nom_courant
HAVING nombre > 6 AND SUBSTRING(nom_courant, 1, 1) = 'C'; -- Deux conditions dans HAVING

SELECT nom_courant, COUNT(*) as nombre
FROM Animal
INNER JOIN Espece ON Espece.id = Animal.espece_id
WHERE SUBSTRING(nom_courant, 1, 1) = 'C'                  -- Une condition dans WHERE
GROUP BY nom_courant
HAVING nombre > 6;                                        -- Et une dans HAVING

Il est cependant préférable, et de loin, d'utiliser la clause WHERE autant que possible, c'est-à-dire pour toutes les conditions, sauf celles utilisant une fonction d'agrégation. En effet, les conditions HAVING ne sont absolument pas optimisées, au contraire des conditions WHERE.


En résumé

  • Utiliser GROUP BY en combinaison avec une fonction d'agrégation permet de regrouper les lignes selon un ou plusieurs critères.
  • Si l'on groupe sur un seul critère, on aura autant de groupes (donc de lignes) que de valeurs différentes dans la colonne utilisée comme critère.
  • Si l'on groupe sur plusieurs critères, le nombre maximum de groupes résultant est obtenu en multipliant le nombre de valeurs différentes dans chacune des colonnes utilisées comme critère.
  • Selon la norme SQL, si l'on utilise GROUP BY, on ne peut avoir dans la clause SELECT que des fonctions d'agrégation, ou des colonnes utilisées comme critère dans le GROUP BY.
  • L'option WITH ROLLUP permet de créer des super-groupes (ou groupes de groupe).
  • Une condition sur le résultat d'une fonction d'agrégation se place dans une clause HAVING, et non dans la clause WHERE.