🏠 Accueil

Exploration SQL

(lecture : 7 minutes)

Article technique aujourd’hui. Lorsque j’ai dĂ©veloppĂ© la page des profils de Flus, j’ai fait face Ă  un bug qui m’a bien occupĂ©. Pas le genre de bug trĂšs grave, mais suffisamment pĂ©nible pour que je veuille le corriger.

Contexte : un profil liste les six liens publiĂ©s le plus rĂ©cemment par une personne. Pour qu’un lien soit publiĂ©, il doit faire partie d’une collection publique et ne pas ĂȘtre masquĂ©. Un lien peut faire partie de plusieurs collections et une collection peut contenir plusieurs liens ; on a donc une relation « many-to-many ». La date de publication est associĂ©e Ă  une collection ; il peut donc y avoir plusieurs dates de publication pour un mĂȘme lien. Mon problĂšme : les liens publiĂ©s dans plusieurs collections pouvaient apparaĂźtre plusieurs fois.

Je vais vous illustrer tout ça par des requĂȘtes SQL. Pour simplifier le problĂšme, on va considĂ©rer que toutes les collections sont publiques et les liens tous visibles.

CREATE TABLE links (
    id SERIAL PRIMARY KEY,
    url TEXT NOT NULL
);

CREATE TABLE collections (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE links_to_collections (
    link_id INTEGER NOT NULL REFERENCES links,
    collection_id INTEGER NOT NULL REFERENCES collections,
    published_at TIMESTAMP NOT NULL
);

INSERT INTO links (url)
VALUES ('flus.fr'),
       ('marienfressinaud.fr'),
       ('framasoft.org');

INSERT INTO collections (name)
VALUES ('Mes favoris'),
       ('Mes partages');

INSERT INTO links_to_collections (link_id, collection_id, published_at)
VALUES (1, 1, '2022-01-17'),
       (2, 2, '2022-01-18'),
       (3, 2, '2022-01-19'),
       (1, 2, '2022-01-20');

Que fait-on dans ces quelques lignes de SQL ? On commence par créer les trois tables qui vont contenir les données (links, collections et la table faisant la jonction links_to_collections). On insÚre ensuite trois liens et deux collections. Enfin, on fait le lien entre les liens et les collections :

  1. flus.fr dans « Mes favoris » le 17 janvier ;
  2. marienfressinaud.fr dans « Mes partages » le 18 janvier ;
  3. framasoft.org dans « Mes partages » le 19 janvier ;
  4. enfin, à nouveau flus.fr dans « Mes partages » le 20 janvier.

Commençons par récupérer les trois liens les plus récents avec leur date de publication :

SELECT l.id, l.url, lc.published_at
FROM links l

INNER JOIN links_to_collections lc
ON lc.link_id = l.id

ORDER BY lc.published_at DESC, l.id
LIMIT 3;

Cette requĂȘte nous retourne le rĂ©sultat suivant :

id url published_at
1 flus.fr 2022-01-20
3 framasoft.org 2022-01-19
2 marienfressinaud.fr 2022-01-18

On a donc les lignes que l’on souhaite. Seulement, qu’arrive-t-il si on change la limite de trois à six ?

id url published_at
1 flus.fr 2022-01-20
3 framasoft.org 2022-01-19
2 marienfressinaud.fr 2022-01-18
1 flus.fr 2022-01-17

Mince, flus.fr apparaĂźt dĂ©sormais deux fois car il se trouve dans deux collections, avec deux dates de publication diffĂ©rentes. On ne le veut qu’une seule fois Ă  sa date la plus rĂ©cente ! Essayons d’ajouter une clause DISTINCT :

SELECT DISTINCT l.id, l.url, lc.published_at
FROM links l

INNER JOIN links_to_collections lc
ON lc.link_id = l.id

ORDER BY lc.published_at DESC, l.id
LIMIT 6;
id url published_at
1 flus.fr 2022-01-20
3 framasoft.org 2022-01-19
2 marienfressinaud.fr 2022-01-18
1 flus.fr 2022-01-17

MĂȘme rĂ©sultat : normal, DISTINCT s’applique Ă  toutes les colonnes de la sĂ©lection, published_at inclus. Heureusement, PostgreSQL propose une autre syntaxe :

SELECT DISTINCT ON (l.id) l.id, l.url, lc.published_at
FROM links l

INNER JOIN links_to_collections lc
ON lc.link_id = l.id

ORDER BY lc.published_at DESC, l.id
LIMIT 6;

AĂŻe, cette fois on obtient une erreur : « ERROR: SELECT DISTINCT ON expressions must match initial ORDER BY expressions ». Elle indique que la colonne du DISTINCT ON (l.id) doit se trouver Ă  gauche dans la liste de l’ORDER BY. Mais si l’id se trouve Ă  gauche, nos liens ne seront plus triĂ©s dans le bon ordre !

À ce niveau-lĂ , j’arrive au bout de ma crĂ©ativitĂ© en termes de SQL et il me faut faire appel Ă  StackOverflow. Heureusement, je ne suis pas le premier Ă  m’ĂȘtre posĂ© la question. La piste Ă©tait bonne1, mais il nous faut maintenant dĂ©placer tout ça dans une sous-requĂȘte qui sera triĂ©e Ă  nouveau :

SELECT l2.* FROM (
    SELECT DISTINCT ON (l.id) l.id, l.url, lc.published_at
    FROM links l

    INNER JOIN links_to_collections lc
    ON lc.link_id = l.id

    ORDER BY l.id, lc.published_at DESC
) l2

ORDER BY l2.published_at DESC, l2.id
LIMIT 6;

Et


id url published_at
1 flus.fr 2022-01-20
3 framasoft.org 2022-01-19
2 marienfressinaud.fr 2022-01-18

Tadam ! C’est ce qu’il nous fallait !

Maintenant ajoutez Ă  ça toute la complexitĂ© d’un vrai systĂšme, avec le calcul du nombre de commentaires par lien, l’état de lecture pour l’utilisateur courant, d’autres trucs Ă  calculer dynamiquement, etc. et vous obtenez une requĂȘte des plus agrĂ©ables (non). N’hĂ©sitez pas Ă  me faire savoir si vous avez une solution plus Ă©lĂ©gante ! 😄


Mise à jour du 12 février.

Heureusement, j’ai des copains plus malins que moi et qui apprĂ©cient particuliĂšrement le SQL. Merci Ă  François pour cette solution bien meilleure Ă  base de MAX — pour rĂ©cupĂ©rer le published_at le plus rĂ©cent — et de GROUP BY, tout simplement.

SELECT l.id, l.url, MAX(lc.published_at) AS published_at
FROM links l

INNER JOIN links_to_collections lc
ON lc.link_id = l.id

GROUP BY l.id
ORDER BY published_at DESC, l.id
LIMIT 6;

  1. Notez que la rĂ©ponse StackOverflow propose une seconde approche qui n’est pas propre Ă  PostgreSQL. Je la trouve personnellement un poil moins facile Ă  relire, donc je suis restĂ© avec la solution prĂ©sentĂ©e dans cet article. Ce ne sera pas forcĂ©ment votre cas !