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

Où calculer la valeur d'un champ quand le modèle est déjà rempli d'observers

Où calculer la valeur d'un champ quand le modèle est déjà rempli d'observers

Les champs calculés font partie des décisions les plus anodines d'un projet et de celles qui vieillissent le plus mal. Slug, référence, titre composé, texte de recherche : chacun arrive de son côté et tous finissent au même endroit. Vous verrez ici pourquoi l'observer n'est pas cet endroit, pourquoi un accessor ne règle pas non plus le problème quand vous devez interroger le champ, et où les placer pour qu'ils cessent de se multiplier. Les exemples sont en Laravel, l'une de nos spécialités, et nous comparons avec ce que le framework propose déjà, mais le problème se pose de la même façon avec n'importe quel ORM doté d'un cycle de vie.

L'observer démarre propre et finit par être l'endroit où tout tient

Le schéma est toujours le même. Vous devez générer un slug à la création d'un enregistrement et vous le mettez dans l'observer, qui est là pour ça. Ensuite il faut un code de référence. Ensuite un champ de texte concaténé pour la recherche. Ensuite un titre commercial composé à partir des deux précédents.

Six mois plus tard, l'observer fait quatre-vingts lignes, mêle logique métier et orchestration, et sa méthode creating fait cinq choses qui n'ont rien à voir entre elles.

Et il y a un symptôme qui trahit que la situation a échappé à tout contrôle : vous ouvrez deux observers de modèles différents et vous y trouvez la même fonction privée de génération de slugs, copiée octet pour octet, à la seule différence du texte de repli quand le résultat est vide. Ce jour-là, ce n'est plus un problème de style, c'est de la duplication réelle.

Un champ calculé que vous voulez interroger doit vivre dans une colonne

Avant de déplacer quoi que ce soit, il vaut mieux séparer deux cas que l'on confond souvent, parce qu'ils appellent des solutions différentes.

Si la valeur est seulement affichée, un accessor est la bonne réponse. Il se calcule à la lecture, n'occupe pas d'espace et ne peut pas se désynchroniser.

Mais si la valeur est interrogée, l'accessor ne convient pas. Un champ de recherche concaténé n'a d'intérêt que si vous pouvez écrire :

WHERE search_text LIKE '%martinez%'

Et cela exige une vraie colonne, avec son index. Idem pour un code unique qui a besoin d'une contrainte d'unicité, ou pour un slug par lequel se résout une route.

C'est là que la conversation devient intéressante, parce que le calcul doit avoir lieu à l'écriture et être persisté, et c'est précisément le terrain de l'observer.

Sortir le calcul dans une classe avec une convention de nommage

La solution qui fonctionne chez nous consiste à sortir chaque calcul dans sa propre classe, en ne laissant dans l'observer que l'orchestration.

La classe reçoit le modèle et expose une méthode par champ, avec un nom dérivé du champ : search_text se résout avec getSearchText, advert_title avec getAdvertTitle. Il n'y a aucun registre indiquant quelle méthode correspond à quelle colonne, la convention de nommage est le registre.

class UserKeeper
{
    public function __construct(protected User $user) {}

    public function getSearchText(): string
    {
        $parts = array_filter([
            $this->user->name,
            $this->user->last_name,
            $this->user->email,
            $this->user->phone,
            $this->user->address,
            $this->user->postal_code,
        ]);

        return substr(implode(' ', $parts), 0, 1000);
    }
}

Et l'observer s'en tient à ce qui lui revient vraiment, c'est-à-dire décider quand :

public function creating(User $user): void
{
    $user->slug = Str::uuid();
    $user->process(['search_text']);

    Password::sendResetLink($user->only('email'));
}

La classe s'associe au modèle via un attribut, et cet attribut est répétable : un gros modèle peut donc répartir ses calculs sur plusieurs classes au lieu de tout accumuler dans une seule.

Andie recommande

Nommez les champs en minuscules et avec des tirets bas, qui est la convention de colonnes de Laravel et celle qu'utilise la résolution par nom. Si vous suivez cette convention, celle que suit déjà n'importe quel projet Laravel normal, la méthode se trouve toute seule et il n'y a rien d'autre à configurer.

L'ordre du tableau est l'ordre d'exécution

Les champs calculés sont rarement indépendants les uns des autres : le titre commercial a besoin du slug, et le texte de recherche a besoin des deux. Tout système qui les traite comme un ensemble non ordonné oblige à résoudre ces dépendances à la main, et c'est exactement le travail qu'on peut s'épargner.

Quand vous demandez plusieurs champs d'un coup, ils sont calculés dans l'ordre où vous les écrivez, et chacun dispose déjà de ce qu'ont calculé les précédents :

$property->process(['code', 'reference', 'advert_title']);

Ici le code est généré en premier, la référence s'en sert pour se composer, et le titre commercial utilise la référence. Inverser le tableau casse le calcul sans lever la moindre erreur, donc cet ordre est une information et il vaut mieux le traiter comme telle.

C'est exactement le type de dépendance qui, dans un observer long, se retrouve enfouie au milieu de vingt autres lignes, et qui tient ici sur une seule ligne lisible.

Calculer et enregistrer sont deux choses distinctes, et les séparer vous donne le remplissage rétroactif

Décision de conception qui ressemble à un oubli et qui est tout l'inverse : calculer n'enregistre pas. Les valeurs sont affectées en mémoire et c'est vous qui décidez ensuite quoi en faire.

À l'intérieur d'un observer, c'est le bon comportement, parce que l'enregistrement vient de toute façon après et que persister au milieu provoquerait une écriture supplémentaire ou une récursion.

Mais le vrai bénéfice arrive à l'extérieur. Le jour où vous ajoutez un champ calculé à un modèle qui compte déjà cent mille enregistrements, le remplissage rétroactif tient en trois lignes et réutilise exactement le même calcul que la création :

foreach (Client::cursor() as $client) {
    $client->process(['search_text']);
    $client->save();
}

Si le calcul vivait dans l'observer, il faudrait pour remplir toucher et réenregistrer chaque ligne juste pour déclencher l'événement, ou dupliquer la logique dans la commande. Les deux options sont moins bonnes.

Ce que Laravel propose pour la même chose et ce qui change

Autant être honnête : cela n'apporte aucune capacité que Laravel n'ait déjà. Cela apporte un endroit où mettre le code et une convention. Cela dit, les alternatives natives ont des compromis concrets qui valent la peine d'être connus.

Les accessors avec la classe Attribute sont l'outil correct pour des valeurs dérivées qui ne font que s'afficher. Ils calculent à la lecture et ne persistent pas, donc on ne peut ni les interroger, ni les indexer, ni leur appliquer une contrainte d'unicité. Pour un champ de recherche ou un slug de route, ce n'est pas une option.

Les casts personnalisés transforment à l'écriture et persistent bel et bien, ce qui règle la moitié du problème. Le compromis tient à leur forme : ils sont un à un entre champ et classe, et le cast reçoit la valeur et le modèle mais il est pensé pour transformer, pas pour composer à partir d'autres colonnes ni pour lancer des requêtes. Générer un code en vérifiant qu'il n'existe pas déjà dans la table y devient inconfortable.

L'attribut #[ObservedBy] est l'équivalent natif pour associer un comportement à un modèle, et c'est la bonne pièce pour décider quand. Ce qu'il ne donne pas, c'est l'invocation champ par champ : un observer se déclenche sur des événements, vous ne pouvez donc pas lui demander « recalcule seulement ce champ » depuis une commande.

Et la logique directement dans booted() ou dans l'observer est le point de départ, celui qui fonctionne parfaitement jusqu'au jour où il y a quatre champs et deux modèles qui partagent un calcul.

La différence pratique qui nous convient, c'est que l'observer se contente de décider quand et que le calcul se contente de décider combien ça vaut, chacun dans son fichier, et que le second est invocable d'où vous voulez.

À quoi cela ressemble en production

Sur Abodara, il y a neuf modèles avec des champs calculés, neuf classes de calcul et vingt et une méthodes réparties entre elles, invoquées depuis neuf observers et une commande de console.

Le cas le plus dense est celui des fichiers, où le type dépend du MIME déjà calculé, d'où des appels séparés :

$file->process(['mime_type']);
if (empty($file->type)) $file->process(['type']);

$file->process(['slug', 'position', 'digest', 'size', 'access', 'default', 'published']);

Neuf champs calculés dans un observer qui tient toujours sur un écran.

Et si vous ne travaillez pas avec Laravel

Le critère se transpose tel quel et ne dépend pas du framework :

  • Séparez le quand du comment. Le déclencheur et la formule sont deux responsabilités distinctes qui changent pour des raisons différentes.
  • Si la valeur est interrogée, persistez-la dans une colonne. Calculer à la lecture n'est correct que pour ce qui s'affiche.
  • Que la fonction de calcul soit invocable seule, sans dépendre du cycle de vie de l'ORM. C'est ce qui vous permet de remplir les anciens enregistrements et de la tester sans base de données.
  • Et rendez explicites les dépendances entre champs. Si l'un se calcule à partir d'un autre, que cela se voie sur une ligne et non enfoui dans l'ordre d'apparition d'une longue méthode.

Où se trouve le code

Le paquet est sur Packagist, le code sur GitHub et la documentation du paquet présente l'API complète. Cela représente environ deux cents lignes, sans tables, sans configuration et avec un générateur make:keeper pour ne pas écrire le squelette à la main.

Si vous avez un projet où les observers sont devenus le fourre-tout, cela s'applique de façon incrémentale : un modèle, un champ, et vous continuez. Nous le travaillons en développement Laravel et en applications web, et si vous nous montrez le modèle qui fait le plus mal, 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