Aller au contenu

Carnets : modèle de données et migration mono → multi-carnets

Cette page explique pourquoi Kirexo organise les articles d'un utilisateur en carnets, comment ce modèle est passé d'un seul carnet par compte à plusieurs, et quels choix de données sous-tendent l'archivage et le devenir d'un article dont le carnet disparaît. Pour la référence du dashboard qui affiche le nom du carnet, voir Espaces utilisateur et admin ; pour la création d'un compte et de son premier carnet, voir Créer un compte utilisateur en ligne de commande.

D'un carnet unique à plusieurs

À l'origine, chaque compte portait un simple champ texte : le nom de son carnet d'écriture, stocké directement sur l'entité User. C'était suffisant tant qu'un utilisateur n'avait qu'un seul espace où déposer ses articles — mais ce n'était qu'une étiquette, sans existence propre : impossible d'en avoir plusieurs, d'en archiver un, ou de regrouper des articles autrement que par auteur.

Kirexo est passé à un modèle multi-carnets : le carnet devient une entité à part entière, Notebook (src/Entity/Notebook.php), et un utilisateur peut en posséder plusieurs. Un carnet regroupe des articles pour la frise chronologique de l'espace utilisateur, porte un nom et un sous-titre (tagline) optionnel, et appartient à un unique propriétaire.

La relation est bidirectionnelle :

  • Notebook.ownerManyToOne vers User, non nul et immuable : le propriétaire est fixé au constructeur et n'a pas de setter. Un carnet ne change jamais de main.
  • User.notebooks — la collection inverse (OneToMany, mappedBy: 'owner'), ordonnée par createdAt croissant grâce à #[ORM\OrderBy]. Cet ordre déterministe est ce qui permet à user.notebooks|first de désigner toujours le carnet le plus ancien (le dashboard s'appuie dessus).

Le nom par défaut d'un carnet (« Le carnet de \<prénom> ») est désormais une préoccupation du carnet, pas de l'utilisateur : la logique vit dans Notebook::defaultName(). Tout nouveau compte reçoit un carnet à sa création — que ce soit via la commande app:user:create ou via les fixtures — de sorte qu'un utilisateur possède toujours au moins un carnet.

L'archivage tient dans un seul champ

Un carnet peut être actif ou archivé. Plutôt qu'un enum d'état ou un booléen, Kirexo porte cette distinction par un unique champ nullable, archivedAt :

  • null → le carnet est actif ;
  • une date → le carnet est archivé, et l'on sait quand.

Ce choix donne l'information d'état et sa date en une seule colonne, sans redondance possible entre « est archivé » et « date d'archivage ». Le helper métier Notebook::isArchived() dérive le booléen (null !== $this->archivedAt) pour éviter de disséminer un test archivedAt is not null dans les templates. Le repository expose les deux vues correspondantes — findActiveByOwner() (les archivedAt IS NULL, du plus ancien au plus récent) et findArchivedByOwner() (les archivedAt IS NOT NULL, du plus récemment archivé au plus ancien) — qui alimenteront la frise et la vue « archives ».

C'est le même parti pris que pour la date d'entrée en corbeille d'un article (trashedAt) : un état de cycle de vie porté par un horodatage nullable plutôt que par un drapeau séparé.

Un article orphelin part en corbeille, pas en cascade

Un Article est rattaché à un carnet par une relation notebook (ManyToOne, inverse de Notebook::$articles). Cette relation est nullable, mais normalement toujours renseignée : un article reçoit son carnet dès sa création — le controller de création (src/Controller/AppArticleNewController.php) affecte le premier carnet de l'auteur, et la migration comme les fixtures rattachent tout article à un carnet.

Pourquoi alors autoriser null ? Pour répondre à une seule question : que devient un article si son carnet est supprimé ? Deux réponses étaient possibles.

  • ON DELETE CASCADE — supprimer le carnet supprimerait aussi tous ses articles. Trop brutal : perdre un carnet ne doit pas effacer le travail qu'il contenait.
  • ON DELETE SET NULL — supprimer le carnet détache ses articles, qui deviennent orphelins (notebook_id remis à null) au lieu d'être détruits.

Kirexo retient ON DELETE SET NULL (JoinColumn(nullable: true, onDelete: 'SET NULL') sur Article::$notebook). Un article détaché de son carnet n'est pas perdu : il subsiste, typiquement rangé en corbeille, où l'auteur garde la main dessus. La nullabilité de la colonne n'est donc pas une invitation à créer des articles sans carnet — c'est le filet qui évite une suppression en cascade. Aucune contrainte NOT NULL n'est posée sur notebook_id, précisément pour laisser cet état orphelin exister.

Ce contraste avec la corbeille des articles est instructif : là où l'historique de révisions d'un article suit son article en ON DELETE CASCADE (une révision n'a pas de sens sans son article, voir Contenu de travail, révisions et corbeille), un article a du sens sans son carnet. La direction de la contrainte suit la dépendance métier réelle.

Des identifiants UUIDv7 générés au constructeur

Comme toutes les entités de Kirexo, Notebook porte une clé primaire UUIDv7 générée au constructeur ($this->id = $id ?? Uuid::v7()), jamais un entier auto-incrémenté ni un #[ORM\GeneratedValue]. L'identifiant existe donc avant tout passage en base : on peut construire un carnet, l'associer à son propriétaire et le manipuler en mémoire sans dépendre d'un flush.

Le choix d'UUIDv7 plutôt qu'UUIDv4 n'est pas anodin : l'UUIDv7 est ordonnable dans le temps (son préfixe encode l'instant de génération), ce qui donne des insertions ordonnées côté index et évite la fragmentation d'un identifiant purement aléatoire, tout en conservant le bénéfice anti-énumération d'un identifiant non devinable. Ce même invariant vaut jusque dans la migration de données : les carnets créés lors du backfill reçoivent un Uuid::v7() généré en PHP, jamais un gen_random_uuid() PostgreSQL (qui produirait de l'UUIDv4).

La migration mono → multi préserve les données

Le passage de l'ancien champ texte à l'entité Notebook s'accompagne d'une migration de données, et non d'un simple changement de schéma. Supprimer d'emblée l'ancienne colonne aurait perdu le nom que chaque utilisateur avait choisi, et laissé ses articles sans carnet. La migration procède donc en trois temps, dans une seule transaction (PostgreSQL est transactionnel sur le DDL) :

  1. lecture de l'état existant — le contenu de l'ancienne colonne est lu avant toute modification de schéma, tant que la table le porte encore ;
  2. création du nouveau schéma puis backfill — la table notebook et la colonne article.notebook_id (nullable) sont créées, puis, pour chaque utilisateur, un carnet est inséré (nom = ancienne valeur, id UUIDv7 généré en PHP) et tous ses articles y sont rattachés ;
  3. finalisation — la clé étrangère ON DELETE SET NULL et son index sont posés, puis l'ancienne colonne est retirée.

À l'issue, chaque compte possède exactement un carnet reprenant son ancien nom, et aucun article n'est laissé sans carnet. Sur une base fraîche (en intégration continue, migrations jouées avant les fixtures), la boucle de backfill itère sur zéro utilisateur : c'est un no-op sûr, le schéma est simplement créé dans son état cible. Par convention projet, cette migration n'a pas de méthode down() : la réversibilité passe par la restauration d'un dump, pas par un retour arrière automatique.

Voir aussi