web-dev-qa-db-fra.com

Alternative dynamique pour pivoter avec CASE et GROUP BY

J'ai une table qui ressemble à ceci:

id    feh    bar
1     10     A
2     20     A
3      3     B
4      4     B
5      5     C
6      6     D
7      7     D
8      8     D

Et je veux que ça ressemble à ceci:

bar  val1   val2   val3
A     10     20 
B      3      4 
C      5        
D      6      7     8

J'ai cette requête qui fait ceci:

SELECT bar, 
   MAX(CASE WHEN abc."row" = 1 THEN feh ELSE NULL END) AS "val1",
   MAX(CASE WHEN abc."row" = 2 THEN feh ELSE NULL END) AS "val2",
   MAX(CASE WHEN abc."row" = 3 THEN feh ELSE NULL END) AS "val3"
FROM
(
  SELECT bar, feh, row_number() OVER (partition by bar) as row
  FROM "Foo"
 ) abc
GROUP BY bar

Il s'agit d'une approche très créative et devient difficile à gérer s'il y a beaucoup de nouvelles colonnes à créer. Je me demandais si les instructions CASE pouvaient être améliorées pour rendre cette requête plus dynamique? Aussi, j'aimerais voir d'autres approches pour y parvenir.

25
flipflop99

Si vous n'avez pas installé le module supplémentaire tablefunc , exécutez cette commande ne fois par base de données:

CREATE EXTENSION tablefunc;

Réponse à la question

Une solution de tableau croisé très basique pour votre cas:

SELECT * FROM crosstab(
  'SELECT bar, 1 AS cat, feh
   FROM   tbl_org
   ORDER  BY bar, feh')
 AS ct (bar text, val1 int, val2 int, val3 int);  -- more columns?

La difficulté spéciale est qu'il n'y a pas category (cat) dans la table de base. Pour la forme à 1 paramètre de base , nous pouvons simplement fournir une colonne fictive avec une valeur fictive servant de catégorie. La valeur est de toute façon ignorée.

C'est l'un des rares cas où le second paramètre pour la crosstab() n'est pas nécessaire , car toutes les valeurs NULL n'apparaissent que dans les colonnes pendantes à droite par définition de ce problème. Et l'ordre peut être déterminé par la valeur.

Si nous avions une colonne category réelle avec des noms déterminant l'ordre des valeurs dans le résultat, nous aurions besoin de la forme à 2 paramètres de crosstab(). Ici, je synthétise une colonne de catégorie à l'aide de la fonction de fenêtre row_number() , pour baser crosstab() sur:

SELECT * FROM crosstab(
   $$
   SELECT bar, val, feh
   FROM  (
      SELECT *, 'val' || row_number() OVER (PARTITION BY bar ORDER BY feh) AS val
      FROM tbl_org
      ) x
   ORDER BY 1, 2
   $$
 , $$VALUES ('val1'), ('val2'), ('val3')$$         -- more columns?
) AS ct (bar text, val1 int, val2 int, val3 int);  -- more columns?

Le reste est à peu près banal. Trouvez plus d'explications et de liens dans ces réponses étroitement liées.

Bases:
Lisez ceci en premier si vous n'êtes pas familier avec la fonction crosstab()!

Avancé:

Configuration de test appropriée

Voilà comment vous devez fournir un cas de test pour commencer:

CREATE TEMP TABLE tbl_org (id int, feh int, bar text);
INSERT INTO tbl_org (id, feh, bar) VALUES
   (1, 10, 'A')
 , (2, 20, 'A')
 , (3,  3, 'B')
 , (4,  4, 'B')
 , (5,  5, 'C')
 , (6,  6, 'D')
 , (7,  7, 'D')
 , (8,  8, 'D');

Tableau croisé dynamique?

Pas très dynamique, pourtant, comme @ Clodoaldo a commenté . Les types de retour dynamiques sont difficiles à obtenir avec plpgsql. Mais il y a sont ​​façons de le contourner - avec quelques limitations.

Donc, pour ne pas compliquer le reste, je démontre avec un plus simple cas de test:

CREATE TEMP TABLE tbl (row_name text, attrib text, val int);
INSERT INTO tbl (row_name, attrib, val) VALUES
   ('A', 'val1', 10)
 , ('A', 'val2', 20)
 , ('B', 'val1', 3)
 , ('B', 'val2', 4)
 , ('C', 'val1', 5)
 , ('D', 'val3', 8)
 , ('D', 'val1', 6)
 , ('D', 'val2', 7);

Appel:

SELECT * FROM crosstab('SELECT row_name, attrib, val FROM tbl ORDER BY 1,2')
AS ct (row_name text, val1 int, val2 int, val3 int);

Retour:

 row_name | val1 | val2 | val3
----------+------+------+------
 A        | 10   | 20   |
 B        |  3   |  4   |
 C        |  5   |      |
 D        |  6   |  7   |  8

Fonctionnalité intégrée du module tablefunc

Le module tablefunc fournit une infrastructure simple pour les appels génériques crosstab() sans fournir de liste de définition de colonne. Un certain nombre de fonctions écrites en C (généralement très rapide):

crosstabN()

crosstab1() - crosstab4() sont prédéfinis. Un point mineur: ils nécessitent et renvoient tous les text. Nous devons donc convertir nos valeurs integer. Mais cela simplifie l'appel:

SELECT * FROM crosstab4('SELECT row_name, attrib, val::text  -- cast!
                         FROM tbl ORDER BY 1,2')

Résultat:

 row_name | category_1 | category_2 | category_3 | category_4
----------+------------+------------+------------+------------
 A        | 10         | 20         |            |
 B        | 3          | 4          |            |
 C        | 5          |            |            |
 D        | 6          | 7          | 8          |

Fonction crosstab() personnalisée

Pour autres colonnes ou autres types de données, nous créons nos propres type composite et fonction (une fois).
Type:

CREATE TYPE tablefunc_crosstab_int_5 AS (
  row_name text, val1 int, val2 int, val3 int, val4 int, val5 int);

Une fonction:

CREATE OR REPLACE FUNCTION crosstab_int_5(text)
  RETURNS SETOF tablefunc_crosstab_int_5
AS '$libdir/tablefunc', 'crosstab' LANGUAGE c STABLE STRICT;

Appel:

SELECT * FROM crosstab_int_5('SELECT row_name, attrib, val   -- no cast!
                              FROM tbl ORDER BY 1,2');

Résultat:

 row_name | val1 | val2 | val3 | val4 | val5
----------+------+------+------+------+------
 A        |   10 |   20 |      |      |
 B        |    3 |    4 |      |      |
 C        |    5 |      |      |      |
 D        |    6 |    7 |    8 |      |

n fonction dynamique polymorphe pour tous

Cela va au-delà de ce qui est couvert par le module tablefunc.
Pour rendre le type de retour dynamique, j'utilise un type polymorphe avec une technique détaillée dans cette réponse connexe:

Formulaire à 1 paramètre:

CREATE OR REPLACE FUNCTION crosstab_n(_qry text, _rowtype anyelement)
  RETURNS SETOF anyelement AS
$func$
BEGIN
   RETURN QUERY EXECUTE 
   (SELECT format('SELECT * FROM crosstab(%L) t(%s)'
                , _qry
                , string_agg(quote_ident(attname) || ' ' || atttypid::regtype
                           , ', ' ORDER BY attnum))
    FROM   pg_attribute
    WHERE  attrelid = pg_typeof(_rowtype)::text::regclass
    AND    attnum > 0
    AND    NOT attisdropped);
END
$func$  LANGUAGE plpgsql;

Surcharge avec cette variante pour le formulaire à 2 paramètres:

CREATE OR REPLACE FUNCTION crosstab_n(_qry text, _cat_qry text, _rowtype anyelement)
  RETURNS SETOF anyelement AS
$func$
BEGIN
   RETURN QUERY EXECUTE 
   (SELECT format('SELECT * FROM crosstab(%L, %L) t(%s)'
                , _qry, _cat_qry
                , string_agg(quote_ident(attname) || ' ' || atttypid::regtype
                           , ', ' ORDER BY attnum))
    FROM   pg_attribute
    WHERE  attrelid = pg_typeof(_rowtype)::text::regclass
    AND    attnum > 0
    AND    NOT attisdropped);
END
$func$  LANGUAGE plpgsql;

pg_typeof(_rowtype)::text::regclass: Un type de ligne est défini pour chaque type composite défini par l'utilisateur, de sorte que les attributs (colonnes) sont répertoriés dans le catalogue système pg_attribute . La voie rapide pour l'obtenir: transtypez le type enregistré (regtype) en text et transformez ce text en regclass.

Créez une fois des types composites:

Vous devez définir une fois chaque type de retour que vous allez utiliser:

CREATE TYPE tablefunc_crosstab_int_3 AS (
    row_name text, val1 int, val2 int, val3 int);

CREATE TYPE tablefunc_crosstab_int_4 AS (
    row_name text, val1 int, val2 int, val3 int, val4 int);

...

Pour les appels ad-hoc, vous pouvez également simplement créer une table temporaire avec le même effet (temporaire):

CREATE TEMP TABLE temp_xtype7 AS (
    row_name text, x1 int, x2 int, x3 int, x4 int, x5 int, x6 int, x7 int);

Ou utilisez le type d'une table, vue ou vue matérialisée existante, si disponible.

Appel

Utilisation des types de lignes ci-dessus:

Formulaire à 1 paramètre (aucune valeur manquante):

SELECT * FROM crosstab_n(
   'SELECT row_name, attrib, val FROM tbl ORDER BY 1,2'
 , NULL::tablefunc_crosstab_int_3);

Formulaire à 2 paramètres (certaines valeurs peuvent être manquantes):

SELECT * FROM crosstab_n(
   'SELECT row_name, attrib, val FROM tbl ORDER BY 1'
 , $$VALUES ('val1'), ('val2'), ('val3')$$
 , NULL::tablefunc_crosstab_int_3);

Cette fonction fonctionne pour tous les types de retour, tandis que le framework crosstabN() fourni par le module tablefunc a besoin d'une fonction distincte pour chacun.
Si vous avez nommé vos types dans l'ordre comme illustré ci-dessus, vous n'avez qu'à remplacer le nombre en gras. Pour trouver le nombre maximum de catégories dans la table de base:

SELECT max(count(*)) OVER () FROM tbl  -- returns 3
GROUP  BY row_name
LIMIT  1;

C'est à peu près aussi dynamique que cela si vous voulez colonnes individuelles. Des tableaux comme démontré par @Clocoaldo ou une simple représentation textuelle ou le résultat enveloppé dans un type de document comme json ou hstore peuvent fonctionner dynamiquement pour un certain nombre de catégories.

Avertissement:
C'est toujours potentiellement dangereux lorsque l'entrée utilisateur est convertie en code. Assurez-vous que cela ne peut pas être utilisé pour l'injection SQL. N'acceptez pas les entrées d'utilisateurs non approuvés (directement).

Appelez pour une question originale:

SELECT * FROM crosstab_n('SELECT bar, 1, feh FROM tbl_org ORDER BY 1,2'
                       , NULL::tablefunc_crosstab_int_3);
51
Erwin Brandstetter

Bien que ce soit une vieille question, je voudrais ajouter une autre solution rendue possible par les récentes améliorations de PostgreSQL. Cette solution atteint le même objectif de renvoyer un résultat structuré à partir d'un ensemble de données dynamiques sans utiliser la fonction de tableau croisé En d'autres termes, c'est une bonne exemple de réexamen d'hypothèses involontaires et implicites qui nous empêchent de découvrir de nouvelles solutions à d'anciens problèmes. ;)

Pour illustrer, vous avez demandé une méthode pour transposer des données avec la structure suivante:

id    feh    bar
1     10     A
2     20     A
3      3     B
4      4     B
5      5     C
6      6     D
7      7     D
8      8     D

dans ce format:

bar  val1   val2   val3
A     10     20 
B      3      4 
C      5        
D      6      7     8

La solution conventionnelle est une approche intelligente (et incroyablement bien informée) pour créer des requêtes de tableau croisé dynamique qui est expliquée en détail dans la réponse d'Erwin Brandstetter.

Cependant, si votre cas d'utilisation particulier est suffisamment flexible pour accepter un format de résultat légèrement différent, une autre solution est possible qui gère magnifiquement les pivots dynamiques. Cette technique, que j'ai apprise ici

utilise la nouvelle version de PostgreSQL jsonb_object_agg fonction pour construire des données pivotées à la volée sous la forme d'un objet JSON.

Je vais utiliser le "cas de test plus simple" de M. Brandstetter pour illustrer:

CREATE TEMP TABLE tbl (row_name text, attrib text, val int);
INSERT INTO tbl (row_name, attrib, val) VALUES
   ('A', 'val1', 10)
 , ('A', 'val2', 20)
 , ('B', 'val1', 3)
 , ('B', 'val2', 4)
 , ('C', 'val1', 5)
 , ('D', 'val3', 8)
 , ('D', 'val1', 6)
 , ('D', 'val2', 7);

En utilisant le jsonb_object_agg, nous pouvons créer le jeu de résultats pivoté requis avec cette beauté lapidaire:

SELECT
  row_name AS bar,
  json_object_agg(attrib, val) AS data
FROM tbl
GROUP BY row_name
ORDER BY row_name;

Quelles sorties:

 bar |                  data                  
-----+----------------------------------------
 A   | { "val1" : 10, "val2" : 20 }
 B   | { "val1" : 3, "val2" : 4 }
 C   | { "val1" : 5 }
 D   | { "val3" : 8, "val1" : 6, "val2" : 7 }

Comme vous pouvez le voir, cette fonction fonctionne en créant des paires clé/valeur dans l'objet JSON à partir des colonnes attrib et value dans les exemples de données, toutes regroupées par row_name.

Bien que cet ensemble de résultats semble évidemment différent, je pense qu'il satisfera en fait de nombreux cas d'utilisation (sinon la plupart) du monde réel, en particulier ceux où les données nécessitent un pivot généré dynamiquement, ou où les données résultantes sont consommées par une application parent (par exemple, doit être reformaté pour être transmis dans une réponse http).

Avantages de cette approche:

  • Syntaxe plus propre. Je pense que tout le monde conviendrait que la syntaxe de cette approche est beaucoup plus propre et plus facile à comprendre que même les exemples de tableaux croisés les plus élémentaires.

  • Complètement dynamique. Aucune information sur les données sous-jacentes ne doit être spécifiée au préalable. Ni les noms des colonnes ni leurs types de données ne doivent être connus à l'avance.

  • Gère un grand nombre de colonnes. Étant donné que les données pivotées sont enregistrées comme une seule colonne jsonb, vous ne rencontrerez pas la limite de colonne de PostgreSQL (≤1,600 colonnes, Je crois). Il y a encore une limite, mais je pense que c'est la même chose que pour les champs de texte: 1 Go par objet JSON créé (veuillez me corriger si je me trompe). C'est beaucoup de paires clé/valeur!

  • Gestion simplifiée des données. Je pense que la création de données JSON dans la base de données simplifiera (et accélérera probablement) le processus de conversion des données dans les applications parentes. (Vous remarquerez que les données entières de notre exemple de scénario de test ont été correctement stockées en tant que telles dans les objets JSON résultants. PostgreSQL gère cela en convertissant automatiquement ses types de données intrinsèques en JSON conformément à la spécification JSON.) Cela éliminera efficacement le besoin pour caster manuellement les données transmises aux applications parentes: elles peuvent toutes être déléguées à l'analyseur JSON natif de l'application.

Différences (et inconvénients possibles):

  • Il semble différent. Il ne fait aucun doute que les résultats de cette approche sont différents. L'objet JSON n'est pas aussi joli que l'ensemble de résultats du tableau croisé; cependant, les différences sont purement cosmétiques. Les mêmes informations sont produites - et dans un format qui est probablement plus convivial pour la consommation par les applications parentes.

  • Clés manquantes. Les valeurs manquantes dans l'approche croisée sont remplies de valeurs nulles, tandis que les objets JSON manquent simplement les clés applicables. Vous devrez décider par vous-même s'il s'agit d'un compromis acceptable pour votre cas d'utilisation. Il me semble que toute tentative de résoudre ce problème dans PostgreSQL compliquera considérablement le processus et impliquera probablement une certaine introspection sous la forme de requêtes supplémentaires.

  • L'ordre des clés n'est pas conservé. Je ne sais pas si cela peut être résolu dans PostgreSQL, mais ce problème est surtout cosmétique également, car toutes les applications parentes sont soit peu susceptible de s'appuyer sur l'ordre des clés, soit d'avoir la capacité de déterminer l'ordre des clés approprié par d'autres moyens. Le pire des cas ne nécessitera probablement qu'une requête supplémentaire de la base de données.

Conclusion

Je suis très curieux d'entendre les opinions des autres (surtout @ ErwinBrandstetter's) sur cette approche, surtout en ce qui concerne la performance. Quand j'ai découvert cette approche sur le blog d'Andrew Bender, c'était comme se faire frapper sur le côté de la tête. Quelle belle façon d'adopter une nouvelle approche à un problème difficile dans PostrgeSQL. Cela a parfaitement résolu mon cas d'utilisation, et je pense qu'il en sera de même pour beaucoup d'autres.

15
Damian C. Rossney

C'est pour terminer @ Damian bonne réponse. J'ai déjà suggéré l'approche JSON dans d'autres réponses avant le 9.6 à portée de main json_object_agg une fonction. Il faut juste plus de travail avec le jeu d'outils précédent.

Deux des inconvénients possibles cités ne le sont vraiment pas. L'ordre des clés aléatoires est corrigé de manière triviale si nécessaire. Les clés manquantes, le cas échéant, nécessitent une quantité de code presque triviale à traiter:

select
    row_name as bar,
    json_object_agg(attrib, val order by attrib) as data
from
    tbl
    right join
    (
        (select distinct row_name from tbl) a
        cross join
        (select distinct attrib from tbl) b
    ) c using (row_name, attrib)
group by row_name
order by row_name
;
 bar |                     data                     
-----+----------------------------------------------
 a   | { "val1" : 10, "val2" : 20, "val3" : null }
 b   | { "val1" : 3, "val2" : 4, "val3" : null }
 c   | { "val1" : 5, "val2" : null, "val3" : null }
 d   | { "val1" : 6, "val2" : 7, "val3" : 8 }

Pour un consommateur de requête finale qui comprend JSON, il n'y a aucun inconvénient. Le seul est qu'il ne peut pas être consommé comme source de table.

6
Clodoaldo Neto

Dans votre cas, je suppose qu'un tableau est bon. SQL Fiddle

select
    bar,
    feh || array_fill(null::int, array[c - array_length(feh, 1)]) feh
from
    (
        select bar, array_agg(feh) feh
        from foo
        group by bar
    ) s
    cross join (
        select count(*)::int c
        from foo
        group by bar
        order by c desc limit 1
    ) c(c)
;
 bar |      feh      
-----+---------------
 A   | {10,20,NULL}
 B   | {3,4,NULL}
 C   | {5,NULL,NULL}
 D   | {6,7,8}
5
Clodoaldo Neto

Je suis désolé de revenir dans le passé, mais la solution "Dynamic Crosstab" renvoie une table de résultats erronée. Ainsi, les valeurs valN sont erronément "alignées à gauche" et ne correspondent pas aux noms de colonne. Lorsque la table d'entrée a des "trous" dans les valeurs, par ex. "C" a val1 et val3 mais pas val2. Cela produit une erreur: la valeur val3 sera classée dans la colonne val2 (c'est-à-dire la prochaine colonne libre) dans le tableau final.

CREATE TEMP TABLE tbl (row_name text, attrib text, val int); 
INSERT INTO tbl (row_name, attrib, val) VALUES ('C', 'val1', 5) ('C', 'val3', 7);

SELECT * FROM crosstab('SELECT row_name, attrib, val FROM tbl 
ORDER BY 1,2') AS ct (row_name text, val1 int, val2 int, val3 int);

row_name|val1|val2|val3
 C      |   5|  7 |

Afin de renvoyer les cellules correctes avec des "trous" dans la colonne de droite, la requête de tableau croisé nécessite un 2e SELECT dans le tableau croisé, quelque chose comme ceci "crosstab('SELECT row_name, attrib, val FROM tbl ORDER BY 1,2', 'select distinct row_name from tbl order by 1')"

2
vsinceac