Pivots statiques et dynamiques
Qu’est-ce qu’un pivot?
Le pivot est une opération par laquelle des données sous une colonne deviennent des noms de colonnes, de sorte que visuellement une sorte de rotation à 90° est opérée: ce qui se lisait verticalement de haut en bas se retrouve ordonné horizontalement de gauche à droite. Les termes “transposition”, “requête analyse croisée” ou “crosstab” sont aussi utilisés pour désigner ce concept, qui vient instiller un peu de la vision “tableur” des données dans l’approche relationnelle.
Dans le cas le plus simple, on part d’une colonne qui est fonction d’une autre. Considérons un exemple météorologique à 2 colonnes: l’année, et la pluviométrie correspondante, exprimée en nombre de jours où les précipitations ont dépassé 1mm.
Avant pivot:
Année | Pluie |
----------+----------+
2012 | 112 |
2013 | 116 |
2014 | 111 |
2015 | 80 |
2016 | 110 |
2017 | 102 |
Après pivot:
2012 | 2013 | 2014 | 2015 | 2016 | 2017
------+------+------+------+------+------
112 | 116 | 111 | 80 | 110 | 102
Souvent, il y a une deuxième dimension, c’est-à-dire qu’on part de 3 colonnes, dont une dépend fonctionnellement des deux autres: (dimension X, dimension Y)=>Valeur.
Dans l’exemple de la pluviométrie, la seconde dimension pourrait être un nom de ville, comme ci-dessous:
Avant pivot:
Année | Ville | Jours
-------+-----------+-------
2012 | Lyon | 112
2013 | Lyon | 116
2014 | Lyon | 111
... | ... | ...
2014 | Toulouse | 111
2015 | Toulouse | 83
Considérons un jeu de données réduit de 13 villes x 6 ans. La série ci-dessus ferait donc 78 lignes. (un dump SQL pour cet exemple est disponible en téléchargement ici: exemple-pluviometrie.sql; les données brutes sont à l’échelle du mois et proviennent originellement de https://www.infoclimat.fr/climatologie/).
Et voici une présentation typique après pivot:
Ville | 2012 | 2013 | 2014 | 2015 | 2016 | 2017
-----------+------+------+------+------+------+------
Ajaccio | 69 | 91 | 78 | 48 | 81 | 51
Bordeaux | 116 | 138 | 137 | 101 | 117 | 110
Brest | 178 | 161 | 180 | 160 | 165 | 144
Dijon | 114 | 124 | 116 | 93 | 116 | 103
Lille | 153 | 120 | 136 | 128 | 138 | 113
Lyon | 112 | 116 | 111 | 80 | 110 | 102
Marseille | 47 | 63 | 68 | 53 | 54 | 43
Metz | 98 | 120 | 110 | 93 | 122 | 115
Nantes | 124 | 132 | 142 | 111 | 106 | 110
Nice | 53 | 77 | 78 | 50 | 52 | 43
Paris | 114 | 111 | 113 | 85 | 120 | 110
Perpignan | 48 | 56 | 54 | 48 | 69 | 48
Toulouse | 86 | 116 | 111 | 83 | 102 | 89
(13 lignes)
Pour exprimer ça de manière générale, le résultat pivoté d’une série de tuples (X,Y,V) est une grille de N+1 colonnes fois M lignes, où:
-
N est le nombre de valeurs distinctes de X
-
M est le nombre de valeurs distinctes de Y.
-
la 1ere colonne (la plus à gauche) porte les valeurs distinctes de Y, généralement dans un ordre défini, par exemple ici les noms de ville dans l’ordre alphabétique.
-
les noms des autres colonnes sont constituées des valeurs distinctes de X, également dans un ordre défini. Dans l’exemple ci-dessus ce sont les années, par ordre croissant ou décroissant.
-
pour chaque couple (X,Y), si un tuple (X,Y,V) est présent dans le jeu de données avant pivot, la valeur V est placée dans la grille au croisement de la colonne de nom X et de la ligne commençant par Y. C’est pourquoi on parle de tableau croisé (crosstab). Si à (X,Y) ne correspond pas de V dans le jeu de données, la case correspondante reste vide (NULL), ou à une valeur spécifique si on préfère.
Quand le nombre de colonnes reste raisonnable, cette représentation a quelques avantages visuels par rapport à l’affichage en ligne des tuples (X,Y,V):
- elle occupe mieux l’espace 2D.
- elle est plus intuitive, parce qu’il n’y a pas de répétition des dimensions.
- l’absence éventuelle de valeur dans une case saute aux yeux.
- il y a deux axes de tri indépendants.
Quelles requêtes pour pivoter un jeu de données?
La forme canonique
Contrairement à Oracle ou MS-SQL Server, PostgreSQL n’a pas de clause PIVOT dans son dialecte SQL, mais cette clause n’est pas essentielle. Une requête pour pivoter ces données à 3 colonnes (x,y,v) peut s’écrire sous cette forme:
SELECT
y,
(CASE WHEN x='valeur 1' THEN v END) "valeur 1",
(CASE WHEN x='valeur 2' THEN v END) "valeur 2",
...à répéter pour chaque valeur de x devenant une colonne
FROM table ou sous-requête
[ORDER BY 1]
Assez fréquemment une requête pivot agrège en même temps qu’elle pivote: La forme sera alors plutôt de ce genre-là:
SELECT
y,
AGG(v) FILTER (WHERE x='valeur 1') AS "valeur 1",
AGG(v) FILTER (WHERE x='valeur 2') AS "valeur 2",
...à répéter pour chaque valeur de x devenant une colonne
FROM table ou sous-requête
GROUP BY y [ORDER BY 1];
La clause FILTER est une nouveauté de PostgreSQL 9.4, sinon il est toujours possible d’utiliser une expression CASE WHEN. AGG(v) symbolise une fonction d’agrégation, qui pourrait être typiquement SUM(v) pour cumuler des valeurs, ou COUNT(v) pour compter des occurrences, ou encore MIN() ou MAX().
Pour la table d’exemple de pluviométrie sur 6 ans, si on a une mesure par mois et qu’on veut afficher un pivot Ville/Année, la requête serait la suivante:
La forme utilisant crosstab()
L’extension tablefunc
de contrib fournit entre autres une fonction:
crosstab(text source_sql, text category_sql)
qui est souvent citée en premier dans les questions sur les pivots avec Postgres.
Le premier argument de crosstab est une requête renvoyant les
données avant pivot. Le deuxième argument est une autre requête
renvoyant les noms des colonnes après pivot, dans l’ordre désiré. La
fonction renvoyant un type SETOF RECORD, en pratique il faut
re-spécifier ces colonnes via une clause AS (col1 type, col2 type,
etc...)
pour qu’elles soient interprétables comme telles par le moteur
SQL.
Exemple:
Les limites des pivots statiques
L’ennui avec ces deux formes de requêtes, aussi bien celle qui a recours à la fonction crosstab() que celle construite sur autant d’expressions que de colonnes, c’est qu’il faut lister les colonnes et que dès qu’il y a une donnée en plus à pivoter, l’ajouter manuellement à la liste. Sinon avec la première forme la nouvelle donnée sera ignorée, et avec crosstab() elle provoquera une erreur.
D’autre part, ces requêtes ne sont pas malléables: si on veut présenter les colonnes dans l’ordre inverse, ou bien pivoter sur une autre colonne (ici en l’occurrence mettre des villes horizontalement au lieu des années), elles doivent être modifiées presque intégralement.
Enfin, certains pivots génèrent des centaines de colonnes, et la perspective de gérer ça à la main en SQL paraît absurde.
Souvent, en tant qu’utilisateur de SQL, on voudrait faire ce qu’on pourrait appeler un pivot dynamique, c’est-à-dire une requête polymorphe qui, sans modification du SQL, se trouverait automatiquement avoir toutes les colonnes du résultat à partir des lignes correspondantes. Ce serait utile d’une part si ces données sont susceptibles de changer, et d’autre part quand il y a beaucoup de colonnes et qu’il est trop fastidieux de les spécifier.
Mais il se trouve qu’une requête SQL ne peut pas avoir des colonnes dynamiques
au sens où il le faudrait pour un pivot dynamique.
On pourra objecter que dans un SELECT * FROM table
, le *
est bien
remplacé dynamiquement par liste des colonnes, mais la différence est
que cette opération se fait dans l’étape d’analyse de la requête, pas
dans la phase d’exécution. Avant l’exécution, le moteur SQL doit impérativement
savoir quels sont le nombre, les types et les noms des colonnes de la requête
et des sous-requêtes qui la composent. C’est d’ailleurs pour ça
que la sortie de crosstab()
, au même titre que n’importe quelle fonction qui renvoie
un SETOF RECORD, doit être qualifiée statiquement par une liste de noms
de colonnes avec leurs types sous la forme AS (col1 type1, col2 type2...)
Méthodes pour des pivots dynamiques
La difficulté du pivot dynamique peut être résumée ainsi: pour toute requête SQL, il faut que le type du résultat (noms et types de toutes les colonnes) soit connu avant la phase d’exécution. Or pour savoir quelles sont les colonnes composant le résultat pivoté, il faudrait exécuter la requête: c’est un cercle vicieux, et pour s’en sortir il faut forcément changer un peu les termes du problème.
Résultat encapsulé dans une colonne
Une première solution est que la requête SQL renvoie le résultat pivoté non pas en colonnes séparées, mais encapsulé dans une seule colonne avec un type multi-dimensionnel: array[text], JSON, ou XML. Cette solution est par exemple intégrée dans Oracle avec sa clause PIVOT XML. C’est en une seule étape, mais le résultat a une structure non-tabulaire qui ne correspond pas forcément à ce que les utilisateurs espèrent.
Voici un exemple en PostgreSQL moderne avec JSON:
Sans avoir à lister les années dans cette requête, on retrouve notre résultat précédent complet, mais sous forme de deux colonnes, une pour l’“axe vertical”, l’autre pour tout le reste au format JSON:
ville | json_object_agg
-----------+----------------------------------------------------------------------------------------
Ajaccio | { "2012" : 69, "2013" : 91, "2014" : 78, "2015" : 48, "2016" : 81, "2017" : 51 }
Bordeaux | { "2012" : 116, "2013" : 138, "2014" : 137, "2015" : 101, "2016" : 117, "2017" : 110 }
Brest | { "2012" : 178, "2013" : 161, "2014" : 180, "2015" : 160, "2016" : 165, "2017" : 144 }
Dijon | { "2012" : 114, "2013" : 124, "2014" : 116, "2015" : 93, "2016" : 116, "2017" : 103 }
Lille | { "2012" : 153, "2013" : 120, "2014" : 136, "2015" : 128, "2016" : 138, "2017" : 113 }
Lyon | { "2012" : 112, "2013" : 116, "2014" : 111, "2015" : 80, "2016" : 110, "2017" : 102 }
Marseille | { "2012" : 47, "2013" : 63, "2014" : 68, "2015" : 53, "2016" : 54, "2017" : 43 }
Metz | { "2012" : 98, "2013" : 120, "2014" : 110, "2015" : 93, "2016" : 122, "2017" : 115 }
Nantes | { "2012" : 124, "2013" : 132, "2014" : 142, "2015" : 111, "2016" : 106, "2017" : 110 }
Nice | { "2012" : 53, "2013" : 77, "2014" : 78, "2015" : 50, "2016" : 52, "2017" : 43 }
Paris | { "2012" : 114, "2013" : 111, "2014" : 113, "2015" : 85, "2016" : 120, "2017" : 110 }
Perpignan | { "2012" : 48, "2013" : 56, "2014" : 54, "2015" : 48, "2016" : 69, "2017" : 48 }
Toulouse | { "2012" : 86, "2013" : 116, "2014" : 111, "2015" : 83, "2016" : 102, "2017" : 89 }
(13 lignes)
C’est déjà pas mal, mais visuellement ça manque d’alignement, et surtout si le but est de copier-coller dans un tableur, on voit bien que ça ne va pas vraiment le faire.
Résultat tabulaire obtenu en deux temps
Les autres solutions via requête SQL tournent autour de l’idée de procéder en deux temps:
-
une première requête construit le résultat avec toutes ses colonnes, et renvoie une référence indirecte à ce résultat.
-
une deuxième requête va ramener réellement le résultat, sa structure étant maintenant connue par le moteur SQL du fait de l’étape précédente.
A ce niveau, il faut rappeler qu’encapsuler ces deux étapes en une seule
fonction annulerait l’intérêt de la solution:
car pour appeler cette fonction en SQL, il faudrait obligatoirement
spécifier avec une clause AS(...)
toutes les colonnes du résultat,
et dans ce cas autant utiliser crosstab().
La référence créée par la première étape peut être un curseur: dans ce cas la
requête SQL est un appel de fonction prenant le même genre d’arguments
que crosstab() mais renvoyant un REFCURSOR. La fonction créé
dynamiquement une requête pivot, et instancie un curseur sur son
résultat. Le code client peut alors parcourir ce résultat avec
FETCH. C’est la solution mise en oeuvre dans la fonction
dynamic_pivot()
dont le code est un peu plus bas.
Autre variante: la requête SQL est un appel de fonction prenant le
même genre d’arguments que crosstab() mais créant une vue dynamique
ou une table, temporaire ou permanente, avec les données
pivotées. Dans un second temps, le code client exécute un SELECT sur
cette table ou vue, puis la supprime. Une implémentation en plpgsql
pourrait être assez similaire à celle renvoyant un curseur, sauf
qu’une fois établie la requête dynamique, on exécuterait
CREATE [TEMPORARY] TABLE (ou VIEW) nom AS ...
suivi de la requête.
Dans le code ci-dessous, je vous propose une fonction renvoyant un REFCURSOR qui peut être utilisée telle quelle, mais qui pourrait aussi servir de base pour une variante.
Ses arguments sont les mêmes que crosstab()
:
- une requête principale sortant 3 colonnes avant pivot dans l’ordre (catégorie, valeur à transposer, valeur centrale).
- une requête sortant la liste des colonnes dans l’ordre attendu.
La fonction instancie et renvoie un curseur contenant le résultat,
lequel doit être consommé dans la même transaction (quoiqu’on pourrait
le déclarer WITH HOLD
si on voulait garder le résultat toute la session).
Malheureusement la requête principale doit être exécutée en interne deux fois
par cette implémentation, car elle est incorporée en sous-requête
dans deux requêtes totalement distinctes.
De plus, le type des colonnes en sortie est forcé à text
, faute de
pouvoir accéder à l’information du type des données source en
plpgsql. Une version en langage C lèverait potentiellement ces
inconvénients, qui sont liés aux limitations du plpgsql (et encore,
sans l’existence de row_to_json
, ajoutée en version 9.2, je ne crois
pas qu’il aurait été possible du tout de trouver les noms des colonnes comme
le fait la première étape de la fonction). Quoiqu’il en soit, une
version plpgsql a un avantage considérable ici: elle n’exige qu’une
quarantaine de lignes de code pour faire ce travail, que voici:
Exemple d’utilisation:
=> BEGIN;
-- étape 1: obtenir le curseur (le nom du curseur est généré par Postgres)
=> SELECT dynamic_pivot(
'SELECT ville,annee,SUM(pluvio)
FROM pluviometrie GROUP BY ville,annee
ORDER BY ville',
'SELECT DISTINCT annee FROM pluviometrie ORDER BY 1'
) AS curseur
\gset
-- étape 2: extraire les résultats du curseur
=> FETCH ALL FROM :"curseur";
ville | 2012 | 2013 | 2014 | 2015 | 2016 | 2017
-----------+------+------+------+------+------+------
Ajaccio | 69 | 91 | 78 | 48 | 81 | 51
Bordeaux | 116 | 138 | 137 | 101 | 117 | 110
Brest | 178 | 161 | 180 | 160 | 165 | 144
Dijon | 114 | 124 | 116 | 93 | 116 | 103
Lille | 153 | 120 | 136 | 128 | 138 | 113
Lyon | 112 | 116 | 111 | 80 | 110 | 102
Marseille | 47 | 63 | 68 | 53 | 54 | 43
Metz | 98 | 120 | 110 | 93 | 122 | 115
Nantes | 124 | 132 | 142 | 111 | 106 | 110
Nice | 53 | 77 | 78 | 50 | 52 | 43
Paris | 114 | 111 | 113 | 85 | 120 | 110
Perpignan | 48 | 56 | 54 | 48 | 69 | 48
Toulouse | 86 | 116 | 111 | 83 | 102 | 89
(13 lignes)
=> CLOSE :"curseur";
=> COMMIT; -- libérera automatiquement le curseur si pas déjà fait par CLOSE.
Pivot par le code client
La couche de présentation côté client peut aussi se charger de transposer les lignes en colonnes, sur la base d’un jeu de résultat non pivoté. En effet certains voient la transposition comme une pure question de présentation, et pour l’essentiel c’est un point de vue qui se tient.
L’application psql propose une solution basée sur cette approche depuis la version 9.6, via la commande \crosstabview.
En usage interactif, cette méthode est la plus rapide pour obtenir des résultats immédiatement visibles.
Par exemple, admettons qu’on veuille examiner dans le cadre de notre exemple, les couples (ville,année) dépassant 120 jours de pluie:
ville | 2012 | 2013 | 2014 | 2015 | 2016 | 2017
----------+------+------+------+------+------+------
Brest | 178 | 161 | 180 | 160 | 165 | 144
Nantes | 124 | 132 | 142 | | |
Lille | 153 | | 136 | 128 | 138 |
Dijon | | 124 | | | |
Bordeaux | | 138 | 137 | | |
Metz | | | | | 122 |
L’axe horizontal est alimenté par la 2eme colonne de la source mais
pour avoir la transposition inverse, il suffit de mettre les colonnes
annee
ville
en argument de crosstabview dans cet ordre, sans rien changer
à la requête:
=# \crosstabview annee ville
annee | Brest | Nantes | Lille | Dijon | Bordeaux | Metz
-------+-------+--------+-------+-------+----------+------
2012 | 178 | 124 | 153 | | |
2013 | 161 | 132 | | 124 | 138 |
2014 | 180 | 142 | 136 | | 137 |
2015 | 160 | | 128 | | |
2016 | 165 | | 138 | | | 122
2017 | 144 | | | | |
Ci-dessus on n’a pas de tri particulier des villes. Mais on peut trier ces colonnes, y compris sur un critère complexe, à travers le 4ème argument de la commande. Le code suivant trie les colonnes-villes par rang de pluviosité, en l’ajoutant comme 4ème colonne à la requête et à crosstabview:
Résultat:
annee | Metz | Dijon | Nantes | Bordeaux | Lille | Brest
-------+------+-------+--------+----------+-------+-------
2012 | | | 124 | | 153 | 178
2013 | | 124 | 132 | 138 | | 161
2014 | | | 142 | 137 | 136 | 180
2015 | | | | | 128 | 160
2016 | 122 | | | | 138 | 165
2017 | | | | | | 144
On voit que les nombres se répartissent maintenant de telle sorte qu’en lisant de gauche à droite on a des villes globalement de plus en plus pluvieuses, et notamment Brest le gagnant incontestable de ce jeu de données.
Comment dé-pivoter un jeu de données?
L’opération UNPIVOT existe dans certains dialectes SQL, mais pas dans PostgreSQL. On peut toutefois dé-pivoter facilement avec Postgres et de manière générique, c’est-à-dire sans liste explicite des colonnes, en passant par une représentation intermédiaire du jeu de données en JSON.
Imaginons par exemple que les données de pluviométrie se présentent comme ci-dessous, avec une colonne distincte par mois de l’année, type “tableur”
=> \d pluvmois
Column | Type |
--------+---------+
ville | text |
annee | integer |
m1 | integer |
m2 | integer |
m3 | integer |
m4 | integer |
m5 | integer |
m6 | integer |
m7 | integer |
m8 | integer |
m9 | integer |
m10 | integer |
m11 | integer |
m12 | integer |
En appliquant la fonction json_each_text
à chaque ligne de la table
mise au format JSON avec row_to_json
, on va obtenir toutes les colonnes
sous forme de tuples (key,value):
Pour avoir notre résultat final dé-pivoté, il ne reste plus qu’à enrichir cette requête pour garder l’année et la ville associée à chaque mesure, et filtrer et re-typer les colonnes de mois, comme ci-dessous:
Résultat:
ville | annee | mois | pluvio
-----------+-------+------+--------
Lille | 2017 | 1 | 9
Lille | 2017 | 2 | 10
Lille | 2017 | 3 | 9
etc...
(936 lignes)
On retrouve bien nos 13 villes x 6 ans x 12 mois du jeu de données initial.