Skip to content

Système de tagging (M:N) ​

Attacher des tags à des posts en utilisant une table de jointure many-to-many, avec remplacement atomique des tags et récupération des tags sans problème N+1.

Vue d'ensemble ​

Un système de tagging a trois tables : posts, tags, et post_tags (la table de jointure). Les posts et les tags ont une relation M:N — un post a plusieurs tags, un tag appartient à plusieurs posts.

Schéma de base de données ​

sql
CREATE TABLE posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    created_at TEXT    NOT NULL
);

CREATE TABLE tags (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL UNIQUE,
    created_at TEXT    NOT NULL
);

CREATE TABLE post_tags (
    post_id INTEGER NOT NULL,
    tag_id  INTEGER NOT NULL,
    PRIMARY KEY (post_id, tag_id),
    FOREIGN KEY (post_id) REFERENCES posts(id),
    FOREIGN KEY (tag_id)  REFERENCES tags(id)
);

La clé primaire composite sur (post_id, tag_id) impose l'unicité au niveau DB.

Définir les tags atomiquement ​

L'endpoint PUT /posts/{id}/tags remplace tous les tags d'un post en une opération. Supprimer d'abord, puis insérer :

php
public function setPostTags(int $postId, array $tagNames): array
{
    $this->executor->execute('DELETE FROM post_tags WHERE post_id = ?', [$postId]);

    foreach ($tagNames as $name) {
        $tag = $this->findTagByName($name);
        if ($tag === null) {
            continue; // Ignorer silencieusement les noms de tags inconnus
        }

        $this->executor->execute(
            'INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)',
            [$postId, $tag->id],
        );
    }

    return $this->findTagsByPostId($postId);
}
  • Supprimer-puis-insérer rend l'opération idempotente : l'appeler deux fois avec le même payload donne le même résultat.
  • INSERT OR IGNORE prévient une erreur DB si le même nom de tag apparaît deux fois dans le body de la requête.
  • Les noms de tags inconnus sont silencieusement ignorés — le client doit créer les tags avant de les assigner.
  • Pour effacer tous les tags, envoyer {"tags": []}.

Éviter les requêtes N+1 ​

Lors du chargement d'une liste de posts (ex. pour une recherche basée sur les tags), récupérer tous les tags en une seule requête IN plutôt qu'une requête par post :

php
private function findTagsByPostIds(array $postIds): array
{
    if ($postIds === []) {
        return [];
    }

    $placeholders = implode(',', array_fill(0, count($postIds), '?'));
    $rows = $this->executor->fetchAll(
        "SELECT t.*, pt.post_id FROM tags t
         INNER JOIN post_tags pt ON pt.tag_id = t.id
         WHERE pt.post_id IN ({$placeholders})
         ORDER BY t.name ASC",
        $postIds,
    );

    $map = [];
    foreach ($rows as $row) {
        $postId         = (int) $row['post_id'];
        $map[$postId][] = $this->hydrateTag($row);
    }

    return $map;
}

Cela retourne array<int, Tag[]> indexé par ID de post. Deux requêtes au total quel que soit le nombre de posts.

Unicité des tags ​

Les tags ont une contrainte UNIQUE sur name. La création dupliquée retourne 409 :

php
public function createTag(string $name, string $now): ?Tag
{
    try {
        $this->executor->execute(
            'INSERT INTO tags (name, created_at) VALUES (?, ?)',
            [$name, $now],
        );
    } catch (\RuntimeException) {
        return null; // null → le handler retourne 409
    }

    return $this->findTagByName($name);
}

Recherche basée sur les tags ​

Filtrer les posts par tag en utilisant un JOIN :

sql
SELECT p.* FROM posts p
INNER JOIN post_tags pt ON pt.post_id = p.id
INNER JOIN tags t ON t.id = pt.tag_id
WHERE t.name = ?
ORDER BY p.id DESC

Puis charger les tags en lot pour l'ensemble de résultats avec la requête IN ci-dessus.

Résumé des routes ​

MéthodeCheminDescription
POST/postsCréer un post
GET/posts/{id}Obtenir un post avec ses tags
POST/tagsCréer un tag (dupliqué → 409)
GET/tagsLister tous les tags (alphabétiquement)
PUT/posts/{id}/tagsRemplacer tous les tags d'un post
GET/tags/{name}/postsLister les posts avec ce tag

Notes de conception ​

  • Les tags sont des entités gérées par l'application, pas du texte libre. Les clients créent les tags d'abord, puis les assignent.
  • Les noms de tags inconnus dans PUT /posts/{id}/tags sont silencieusement ignorés. Cela évite un aller-retour pour pré-valider les noms.
  • Les noms de tags sont triés alphabétiquement dans les réponses pour une sortie déterministe.
  • GET /tags/{name}/posts retourne 404 si le tag n'existe pas, distinguant "tag inconnu" de "tag existe mais n'a pas de posts" (qui retourne 200 avec un tableau vide).

Publié sous licence MIT.