Nous developpons votre site vitrine SPA À PARTIR DE 300€. Oui, c'est dingue. Web SPA À PARTIR DE 300€ — Oui, c'est dingue. Parlons-en →

Comment classer n'importe quel modèle avec des étiquettes et des catégories sans dupliquer de tables

Comment classer n'importe quel modèle avec des étiquettes et des catégories sans dupliquer de tables

L'étiquetage fait partie de ces fonctionnalités qui paraissent triviales jusqu'au jour où il faut les ajouter au quatrième modèle. Vous vous retrouvez alors avec des tables quasi identiques, le même code répété à plusieurs endroits et une demande produit impossible à satisfaire sans migrer la moitié de la base. Voici comment le monter une seule fois pour n'importe quel modèle, avec les décisions qui évitent que le nettoyage et les permissions ne se compliquent ensuite. Les exemples sont en Laravel, l'une de nos spécialités, et nous comparons ouvertement avec spatie/laravel-tags, que presque tout le monde utilise, mais le modèle de données est transposable à n'importe quel stack.

Une table d'étiquettes par modèle, c'est le chemin qui ne passe pas à l'échelle

Cela commence toujours pareil. Vous devez étiqueter des documents, alors vous créez document_tags et document_tag_document. Ça marche.

Un mois plus tard il faut étiqueter des clients. Puis des dossiers, puis des clauses. Chacun avec sa table d'étiquettes et sa table pivot, avec la même logique de créer si ça n'existe pas, le même générateur d'identifiants, le même sélecteur dans l'interface dupliqué quatre fois.

Et arrive le jour où quelqu'un demande de filtrer par une étiquette qui existe à deux endroits, et vous découvrez que "urgent" dans les documents et "urgent" dans les dossiers sont deux lignes de deux tables différentes qui ne peuvent pas se croiser.

Deux tables, et la taxonomie est une colonne de texte

La structure qui résout cela, ce sont exactement deux tables, et aucune ne grossit quand vous ajoutez un nouveau modèle.

terms stocke les termes : leur nom, leur identifiant stable, la taxonomie à laquelle ils appartiennent, leur parent s'il y a une hiérarchie et leur ordre.

termables est la pivot polymorphe : quel terme est rattaché à quel modèle, quel que soit son type.

Et la décision qui simplifie le plus le quotidien : la taxonomie n'a pas de table. C'est une chaîne de texte dans une colonne, et sa définition vit dans la configuration de l'application :

'taxonomies' => [
    'case_tags'  => ['models' => [CaseModel::class], 'max_terms_per_model' => 8],
    'clause_tags'=> ['models' => [Clause::class]],
    'thread_categories' => ['models' => [Thread::class], 'hierarchical' => true],
],

Ajouter une nouvelle classification, c'est une entrée dans un tableau. Zéro migration, zéro déploiement de schéma, et la liste des taxonomies du projet se lit d'un coup d'oeil dans un fichier.

NULL ne convient pas comme valeur de "sans propriétaire" dans une contrainte d'unicité

En SQL, NULL n'est pas une valeur : c'est l'absence de valeur, et deux absences ne sont pas égales entre elles. Cette règle, qui sonne académique, est celle qui fait qu'une contrainte d'unicité laisse passer des doublons exactement là où cela vous arrange le moins.

Si votre application a plusieurs clients ou plusieurs organisations, chacun a besoin de son propre catalogue d'étiquettes. L'étiquette "VIP" de l'organisation 5 et la "VIP" de l'organisation 6 sont deux lignes différentes, et elles doivent pouvoir coexister avec le même identifiant.

Cela se résout avec un second axe polymorphe dans la table des termes, qui dit à qui appartient le catalogue, et avec une contrainte d'unicité sur les quatre colonnes réunies :

UNIQUE (scope_type, scope_id, taxonomy, handle)

Et voici le piège. Les étiquettes globales, celles qui n'appartiennent à personne, semblent appeler NULL dans ces deux colonnes. Ne le faites pas. Dans MySQL, NULL n'est pas égal à NULL au regard d'une contrainte d'unicité, donc la contrainte cesse de protéger précisément dans le cas global : vous pouvez insérer "urgent" en global cinquante fois et la base de données ne dira rien.

La solution est d'utiliser des valeurs sentinelles : chaîne vide et zéro.

protected $attributes = [
    'scope_type' => '',
    'scope_id'   => 0,
];

Laid à lire et correct dans son comportement. C'est une de ces choses qui ne se découvrent qu'en production, quand apparaissent les doublons que la contrainte aurait dû empêcher.

Le pivot polymorphe ne peut avoir de cascade que d'un seul côté

Une relation polymorphe renonce à quelque chose en échange de sa souplesse, et mieux vaut savoir à quoi : une clé étrangère doit pointer vers une table précise, et une colonne qui pointe aujourd'hui vers des documents et demain vers des dossiers ne peut pas en avoir.

Dans la table pivot, la colonne qui pointe vers le terme peut bien avoir une clé étrangère avec suppression en cascade : vous supprimez un terme et ses associations partent toutes seules, c'est la base de données qui le fait.

La colonne qui pointe vers le modèle étiqueté ne peut pas en avoir, parce qu'elle est polymorphe : elle pointe vers documents, vers clients ou vers cases selon la ligne, et une clé étrangère ne peut pointer que vers une seule table.

Ce côté-là doit donc être nettoyé depuis le code, dans l'événement de suppression du modèle :

static::deleting(function ($model) {
    if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) {
        return; // suppression douce : on conserve pour pouvoir restaurer
    }

    $model->terms()->detach();
});

Et il y a un cas que ce point d'accroche ne couvre pas et qu'il vaut mieux garder en tête : si le modèle est supprimé par une cascade de la base de données elle-même, parce que sa table parente avait une clé étrangère en cascade, l'événement Eloquent ne se déclenche pas et les lignes du pivot restent à pointer vers une fiche qui n'existe plus.

Ce n'est pas un défaut de la conception polymorphe, c'est son prix. Et la façon de le payer, c'est de s'en souvenir : chaque table qui se relie de façon polymorphe à quelque chose supprimé en cascade a besoin de son propre nettoyage.

Andie recommande

Si votre modèle utilise la suppression douce, ne retirez pas les étiquettes à la suppression. Laissez les associations et ne les retirez qu'à la suppression définitive. Ainsi, quand quelqu'un restaure une fiche, il en récupère aussi la classification. C'est un détail de deux lignes qui évite la conversation gênante du "j'ai récupéré le dossier mais il a perdu toutes ses étiquettes".

Le nom en texte brut et les traductions à part

Stocker le nom deux fois, dans une colonne et en plus traduit, ressemble beaucoup à de la duplication, précisément ce qu'on cherche à éviter en concevant un schéma. La justification n'est pas dans le modèle, elle est dans ce que la base de données sait faire de chaque format.

Un terme multilingue peut se stocker entièrement dans une colonne JSON, avec une clé par langue. C'est propre conceptuellement et cela a un coût concret : trier, indexer et filtrer par nom demande désormais d'extraire du JSON à chaque requête.

L'alternative est d'avoir les deux : une colonne de texte brut avec le nom canonique, celle qu'utilisent l'index, l'ORDER BY et la génération de l'identifiant, et une colonne JSON à part avec les traductions, qu'un accesseur renvoie quand celle de la langue active existe.

Et pour chercher dans toutes les langues à la fois, une troisième colonne dénormalisée qui concatène le nom, toutes ses traductions et les descriptions, maintenue automatiquement à l'enregistrement. Avec cela, une seule recherche trouve le terme quelle que soit sa langue, sans conditionnels par langue dans la requête.

L'arbre se construit en mémoire avec une seule requête

Si la taxonomie est hiérarchique, le piège classique est de parcourir les enfants de chaque noeud, ce qui fait une requête par niveau et par branche.

Ce n'est pas nécessaire. On ramène tous les termes de la taxonomie en une requête, on les groupe par identifiant de parent en mémoire et on les raccroche :

$byParent = $terms->groupBy('parent_id');

$terms->each(fn ($term) => $term->setRelation(
    'children',
    $byParent->get($term->id, collect())
));

Une requête, l'arbre monté, et à partir de là le fil d'Ariane et les descendants se résolvent sans retourner en base de données. Pour une taxonomie de quelques centaines de termes, ce qui est la norme, c'est toujours la bonne réponse.

Désactiver n'est pas supprimer, et les confondre casse l'historique

Quand quelqu'un demande à pouvoir « retirer » une étiquette, il ne veut presque jamais dire la supprimer. Il veut qu'elle disparaisse de la liste où on la choisit, sans s'évaporer des centaines d'enregistrements qui la portent déjà, et ce sont deux opérations distinctes même si le bouton porte le même nom.

Quand une étiquette cesse d'être utilisée, ce que vous voulez normalement n'est pas la supprimer : vous voulez qu'elle n'apparaisse plus dans le sélecteur pour les nouvelles classifications, mais que les fiches qui la portent déjà continuent de l'afficher.

C'est un drapeau d'activation, pas une suppression douce, et ce sont deux choses différentes. La suppression douce dit "ceci n'existe plus" ; le drapeau dit "ceci existe et n'est plus proposé". Mettre les deux sémantiques dans le même mécanisme fait que plus personne ne sait ce que signifie une ligne avec une date de suppression.

Ce qui existe sur le marché et ce qui les distingue

L'étiquetage fait partie des problèmes les mieux résolus de l'écosystème Laravel, si bien que la question n'est pas de savoir s'il existe quelque chose, mais en quoi cela diffère de votre besoin. Les différences ne sont pas dans les fonctionnalités mises en avant, elles sont dans le schéma.

spatie/laravel-tags est, de loin, le plus utilisé : plus de douze millions d'installations. Il partage l'architecture de fond, deux tables et une pivot polymorphe, et il gère les types d'étiquette et les traductions. La différence d'approche qui mérite d'être évaluée, c'est que son internationalisation passe entièrement par JSON, appuyée sur une dépendance de traduction obligatoire, ce qui donne une source de vérité unique par champ en échange de devoir extraire du JSON pour trier ou indexer. Et l'isolation par client n'est pas dans son schéma, donc si vous en avez besoin cela se règle à l'extérieur, avec une portée globale ou une base de données par client.

rtconner/laravel-tagging et cviebrock/eloquent-taggable couvrent le cas classique des étiquettes libres, avec normalisation des identifiants et compteurs d'usage, et ils le font bien. Leur périmètre n'inclut d'origine ni les taxonomies multiples ni la hiérarchie.

Ce qui a tranché dans notre cas, c'est le second axe polymorphe, celui du propriétaire du catalogue, parce qu'il était dans le schéma et dans la contrainte d'unicité au lieu d'être traité à la main dans chaque requête. Quand vous avez des clients qui ne doivent pas voir les étiquettes des autres, cette garantie, vous préférez l'avoir dans la base de données.

À quoi cela ressemble en production

Sur Crowd Legal il y a neuf taxonomies déclarées et six modèles classables : dossiers, clients, utilisateurs, fils de discussion, clauses et modèles de document.

Et il y en a un septième qui illustre bien pourquoi la conception polymorphe paie. Le modèle de fichiers vient d'un autre paquet et ne peut pas être modifié, donc la relation lui est ajoutée au démarrage depuis un fournisseur de services. Il n'a fallu aucune table nouvelle ni toucher au code d'autrui : la pivot sait déjà pointer vers n'importe quoi.

Et si vous ne travaillez pas avec Laravel

Les critères relèvent de la modélisation des données et valent partout :

  • Une pivot polymorphe plutôt qu'une table par modèle. Si ajouter une entité classable implique une migration, la conception va mal vieillir.
  • Pas de NULL dans les colonnes d'une contrainte d'unicité. Utilisez une valeur sentinelle, même si c'est laid.
  • Nettoyez à la main le côté polymorphe à la suppression, parce que la base de données ne peut pas le faire à votre place.
  • Stockez la valeur canonique en texte brut et les traductions à part, si vous avez besoin de trier ou d'indexer dessus.
  • Et construisez les arbres en mémoire avec une requête, pas en parcourant les niveaux.

Où se trouve le code

Le paquet est sur Packagist, le code sur GitHub et la documentation du paquet reprend les taxonomies, les portées et les scopes de requête. Cela fait deux tables et environ mille sept cents lignes.

Si vous avez un produit où la classification s'est répandue en colonnes de texte et en tableaux JSON dans différents modèles, l'unifier coûte moins cher qu'il n'y paraît et peut se faire modèle par modèle. Nous le travaillons en développement Laravel et en logiciel juridique, et si vous nous montrez comment vous classez aujourd'hui, nous vous dirons par où commencer.

Écrit par
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Développeur full-stack avec plus de 15 ans d'expérience en Laravel, React, Node.js et architectures cloud. J'aide les entreprises en Andorre à construire leur présence digitale.

Partner de diseño · ionospace.
Besoin d'aide? ×
Andie by AndorraDev
Assistant IA + équipe humaine
Assistant IA d'AndorraDev
Andie
Bonjour! Je suis Andie, l'assistant IA d'AndorraDev. Comment puis-je vous aider? Si vous souhaitez parler avec Edu, il suffit de le demander.
17:30