web-dev-qa-db-fra.com

DISTINCT ON dans une fonction d'agrégation en postgres

Pour mon problème, nous avons un schéma selon lequel une photo a de nombreux tags et aussi de nombreux commentaires. Donc, si j'ai une requête où je veux tous les commentaires et balises, cela multipliera les lignes ensemble. Donc, si une photo a 2 balises et 13 commentaires, j'obtiens 26 lignes pour cette seule photo:

SELECT
        tag.name, 
        comment.comment_id
FROM
        photo
        LEFT OUTER JOIN comment ON comment.photo_id = photo.photo_id
        LEFT OUTER JOIN photo_tag ON photo_tag.photo_id = photo.photo_id
        LEFT OUTER JOIN tag ON photo_tag.tag_id = tag.tag_id

enter image description here

C'est bien pour la plupart des choses, mais cela signifie que si je GROUP BY Puis json_agg(tag.*), j'obtiens 13 copies de la première balise et 13 copies de la deuxième balise.

SELECT json_agg(tag.name) as tags
FROM
        photo
        LEFT OUTER JOIN comment ON comment.photo_id = photo.photo_id
        LEFT OUTER JOIN photo_tag ON photo_tag.photo_id = photo.photo_id
        LEFT OUTER JOIN tag ON photo_tag.tag_id = tag.tag_id
GROUP BY photo.photo_id

enter image description here

Au lieu de cela, je veux un tableau qui ne soit que "suburbain" et "ville", comme ceci:

 [
      {"tag_id":1,"name":"suburban"}, 
      {"tag_id":2,"name":"city"}
 ]

Je pourrais json_agg(DISTINCT tag.name), mais cela ne fera qu'un tableau de noms de balises, quand je veux que la ligne entière soit json. Je voudrais json_agg(DISTINCT ON(tag.name) tag.*), mais ce n'est pas du SQL valide apparemment.

Comment puis-je simuler DISTINCT ON À l'intérieur d'une fonction d'agrégation dans Postgres?

32
Migwell

Chaque fois que vous avez une table centrale et que vous souhaitez la joindre à gauche à plusieurs lignes du tableau A et également la joindre à gauche à de nombreuses lignes du tableau B, vous rencontrez ces problèmes de duplication des lignes. Il peut surtout supprimer les fonctions d'agrégat comme COUNT et SUM si vous n'y faites pas attention! Je pense donc que vous devez créer vos balises pour chaque photo et vos commentaires pour chaque photo séparément, puis les assembler:

WITH tags AS (
  SELECT  photo.photo_id, json_agg(row_to_json(tag.*)) AS tags
  FROM    photo
  LEFT OUTER JOIN photo_tag on photo_tag.photo_id = photo.photo_id
  LEFT OUTER JOIN tag ON photo_tag.tag_id = tag.tag_id
  GROUP BY photo.photo_id
),
comments AS (
  SELECT  photo.photo_id, json_agg(row_to_json(comment.*)) AS comments
  FROM    photo
  LEFT OUTER JOIN comment ON comment.photo_id = photo.photo_id
  GROUP BY photo.photo_id
)
SELECT  COALESCE(tags.photo_id, comments.photo_id) AS photo_id,
        tags.tags,
        comments.comments
FROM    tags
FULL OUTER JOIN comments
ON      tags.photo_id = comments.photo_id

EDIT: Si vous voulez vraiment tout réunir sans CTE, cela ressemble à des résultats corrects:

SELECT  photo.photo_id,
        to_json(array_agg(DISTINCT tag.*)) AS tags,
        to_json(array_agg(DISTINCT comment.*)) AS comments
FROM    photo
LEFT OUTER JOIN comment ON comment.photo_id = photo.photo_id
LEFT OUTER JOIN photo_tag on photo_tag.photo_id = photo.photo_id
LEFT OUTER JOIN tag ON photo_tag.tag_id = tag.tag_id
GROUP BY photo.photo_id
18
Paul A Jungwirth

L'opération DISTINCT la moins chère et la plus simple est .. de ne pas multiplier les lignes dans une "jointure proxy" en premier lieu. Agréger d'abord, puis rejoindre. Voir:

Idéal pour renvoyer quelques lignes sélectionnées

En supposant vous ne voulez en fait pas récupérer la table entière, mais juste une ou quelques photos sélectionnées à la fois , avec des détails agrégés, le moyen le plus élégant et probablement le plus rapide est avec LATERAL sous-requêtes :

SELECT *
FROM   photo p
CROSS  JOIN LATERAL (
   SELECT json_agg(c) AS comments
   FROM   comment c
   WHERE  photo_id = p.photo_id
   ) c1
CROSS  JOIN LATERAL (
   SELECT json_agg(t) AS tags
   FROM   photo_tag pt
   JOIN   tag       t USING (tag_id)
   WHERE  pt.photo_id = p.photo_id
   ) t
WHERE  p.photo_id = 2;  -- arbitrary selection

Cela renvoie des lignes entières de comment et tag, agrégées séparément dans des tableaux JSON. Les lignes ne sont pas des multiplications comme dans votre tentative, mais elles sont aussi "distinctes" qu'elles le sont dans vos tables de base.

Pour plier en plus les doublons dans les données de base, voir ci-dessous.

Remarques:

  • LATERAL et json_agg() nécessitent Postgres 9.3 ou version ultérieure.

  • json_agg(c) est l'abréviation de json_agg(c.*).

  • Nous n'avons pas besoin de LEFT JOIN Car une fonction d'agrégation comme json_agg() renvoie toujours une ligne.

Typiquement , vous ne voudriez qu'un sous-ensemble de colonnes - au moins excluant le redondant photo_id:

SELECT *
FROM   photo p
CROSS  JOIN LATERAL (
   SELECT json_agg(json_build_object('comment_id', comment_id
                                   , 'comment', comment)) AS comments
   FROM   comment
   WHERE  photo_id = p.photo_id
   ) c
CROSS  JOIN LATERAL (
   SELECT json_agg(t) AS tags
   FROM   photo_tag pt
   JOIN   tag       t USING (tag_id)
   WHERE  pt.photo_id = p.photo_id
   ) t
WHERE  p.photo_id = 2;

json_build_object() a été introduit avec Postgres 9.4 . Utilisé pour être encombrant dans les anciennes versions car un constructeur ROW ne conserve pas les noms de colonne. Mais il existe des solutions de contournement génériques:

Permet également de choisir librement les noms de clés JSON, vous n'avez pas à vous en tenir aux noms de colonnes.

Idéal pour retourner la table entière

Pour renvoyer toutes les lignes, c'est plus efficace:

SELECT p.*
     , COALESCE(c1.comments, '[]') AS comments
     , COALESCE(t.tags, '[]') AS tags
FROM   photo p
LEFT   JOIN (
   SELECT photo_id
        , json_agg(json_build_object('comment_id', comment_id
                                   , 'comment', comment)) AS comments
   FROM   comment c
   GROUP  BY 1
   ) c1 USING (photo_id)
LEFT  JOIN LATERAL (
   SELECT photo_id , json_agg(t) AS tags
   FROM   photo_tag pt
   JOIN   tag       t USING (tag_id)
   GROUP  BY 1
   ) t USING (photo_id);

Une fois que nous avons récupéré suffisamment de lignes, cela revient moins cher que les sous-requêtes LATERAL. Fonctionne pour Postgres 9.3 + .

Notez la clause USING dans la condition de jointure. De cette façon, nous pouvons facilement utiliser SELECT * Dans la requête externe sans obtenir de colonnes en double pour photo_id. Je n'ai pas utilisé SELECT * Ici car votre réponse supprimée indique que vous voulez des tableaux JSON vides au lieu de [~ # ~] null [~ # ~] pour aucun tag/aucun commentaire.

Supprimez également les doublons existants dans les tables de base

Vous ne pouvez pas simplement json_agg(DISTINCT json_build_object(...)) car il n'y a pas d'opérateur d'égalité pour le type de données json. Voir:

Il existe de meilleures façons:

SELECT *
FROM   photo p
CROSS  JOIN LATERAL (
   SELECT json_agg(to_json(c1.comment)) AS comments1
        , json_agg(json_build_object('comment', c1.comment)) AS comments2
        , json_agg(to_json(c1)) AS comments3
   FROM  (
      SELECT DISTINCT c.comment  -- folding dupes here
      FROM   comment c
      WHERE  c.photo_id = p.photo_id
   -- ORDER  BY comment --  any particular order?
      ) c1
   ) c2
CROSS  JOIN LATERAL (
   SELECT jsonb_agg(DISTINCT t) AS tags  -- demonstrating jsonb_agg
   FROM   photo_tag pt
   JOIN   tag       t USING (tag_id)
   WHERE  pt.photo_id = p.photo_id
   ) t
WHERE  p.photo_id = 2;

Démonstration de 4 techniques différentes dans comments1, comments2, comments3 (Redondant) et tags.

db <> violon ici
Ancien SQL Fiddle backpatched to Postgres 9.3
Ancien SQL Fiddle pour Postgres 9.6

15
Erwin Brandstetter

Comme indiqué dans les commentaires, json_agg ne sérialise pas une ligne en tant qu'objet, mais crée un tableau JSON des valeurs que vous lui transmettez. Tu auras besoin row_to_json pour transformer votre ligne en objet JSON, puis json_agg pour effectuer l'agrégation dans un tableau:

SELECT json_agg(DISTINCT row_to_json(comment)) as tags
FROM
    photo
    LEFT OUTER JOIN comment ON comment.photo_id = photo.photo_id
    LEFT OUTER JOIN photo_tag ON photo_tag.photo_id = photo.photo_id
    LEFT OUTER JOIN tag ON photo_tag.tag_id = tag.tag_id
GROUP BY photo.photo_id
1
PinnyM