Audit — Garde make:crud many-to-many avec pivot.fields[]¶
Ticket : FIELD-AUDIT-M2M-GUARD-001 Date : 2026-05-20 Friction d'origine : F-003 (FIELD-TEST-APP-001)
1. Résumé¶
Le garde make:crud qui refuse la génération si un pivot possède des
pivot.fields[] non-nullable se déclenche sur les deux côtés de la
relation many-to-many, alors qu'il ne devrait s'appliquer qu'au côté from
(l'entité source qui possède la relation).
Ce comportement est un bug — le raise ValueError est placé avant
le filtre d'entité courante dans relations_loader.py.
Décision recommandée : correction dans FIELD-FIX-M2M-GUARD-001.
2. Contexte¶
Durant FIELD-TEST-APP-001 :
- Entités :
ArticleetTag - Relation :
many_to_manyArticle → Tag, pivotarticle_tag - Champs pivot :
position(nullable: false),note(nullable: true)
Observation : make:crud Article ET make:crud Tag ont refusé la génération
avec le même message d'erreur, alors que Tag n'est pas propriétaire du pivot.
3. Méthode d'audit¶
- Lecture de
forge_cli/entities/crud/relations_loader.py - Lecture de
forge_cli/entities/make_crud.py - Exécution de
tests/test_make_crud_pivot_fields_guard.py(29 tests — OK) - Exécution de
tests/test_make_crud_many_to_many.py(passe) - Exécution de
tests/test_make_crud_many_to_many_canonical.py(passe) - Exécution de
tests/test_make_pivot_crud.py+test_pivot_advanced_e2e.py - Lecture de
docs/entities/pivot-advanced.mdetdocs/entities/pivots-many-to-many.md
4. Comportement observé¶
| Cas | Résultat |
|---|---|
make:crud Article (position nullable: false) |
BLOQUÉ — attendu |
make:crud Tag (position nullable: false) |
BLOQUÉ — inattendu |
make:crud Article (tous champs nullable: true) |
OK |
make:crud Tag (tous champs nullable: true) |
OK |
make:pivot-crud Article tags |
OK dans tous les cas |
5. Implémentation actuelle¶
Fichier : forge_cli/entities/crud/relations_loader.py
Fonction : _load_crud_many_to_many_relations
for relation in validated_relations:
if isinstance(relation, ValidatedCanonicalManyToManyRelation):
incompatible = [pf.name for pf in relation.pivot_fields if not pf.nullable]
if incompatible:
field_list = ", ".join(incompatible)
raise ValueError( # ← RAISE ICI (ligne ~117)
f"Relation many_to_many incompatible avec make:crud : "
f"{relation.from_entity} → {relation.to_entity} "
f"(pivot {relation.pivot_table}).\n"
f"Le pivot {relation.pivot_table} contient des champs obligatoires "
f"non gérés par le CRUD simple : {field_list}.\n"
f"make:crud synchronise uniquement les identifiants.\n"
f"Rendez ces champs nullable ou utilisez un module/CRUD pivot dédié."
)
m2m_source = relation.from_entity
# ...
if m2m_source.lower() not in current_names: # ← FILTRE ICI (ligne ~135)
continue
Le raise ValueError est exécuté avant le filtre current_names.
Conséquence : pour toute entité impliquée dans une relation many_to_many
dont le pivot contient un champ non-nullable, _load_crud_many_to_many_relations
lève l'erreur — qu'elle soit côté from ou côté to.
6. Cas Article ↔ Tag¶
Lors de make:crud Tag :
current_entity = "Tag"(definition["entity"])current_names = {"tag", "tags", "tag"}- Itération sur les relations — trouve
Article → Tag isinstance(relation, ValidatedCanonicalManyToManyRelation)→Trueincompatible = ["position"](non-nullable)raise ValueError("Article → Tag ...")— avant tout filtre
Le contrôle n'atteint jamais la ligne :
if m2m_source.lower() not in current_names: # "article" not in {"tag", "tags"}
continue # ne sera jamais exécuté
7. Options étudiées¶
| Option | Description | Avantage | Risque |
|---|---|---|---|
| A | Garder le blocage des deux côtés | Évite toute génération CRUD sur des entités liées à un pivot avancé | Trop strict — Tag n'est pas responsable du pivot |
| B | Bloquer seulement le côté from |
Sémantiquement correct — seul Article "possède" le pivot | Nul — Tag ne gère pas le pivot, son CRUD reste cohérent |
| C | Ne bloquer aucun côté | make:crud reste simple partout |
Risque : CRUD Article genère un <select> d'IDs sans les attributs pivot — silencieux |
| D | Warning non bloquant | Moins restrictif | Risque de masquer une perte fonctionnelle côté from |
| E | Garder le blocage mais améliorer le message | Stable, plus clair | Ne résout pas le problème côté to |
8. Analyse¶
Garde sur le côté from¶
Le garde côté from est intentionnel et correct. make:crud Article
avec un pivot position (non-nullable) ne peut pas synchroniser ce champ —
il ne génère que des paires d'IDs. Bloquer la génération évite un CRUD
silencieusement incomplet.
Garde sur le côté to¶
Le garde côté to (Tag dans cet exemple) est un bug d'implémentation.
make:crud Tagne génère aucun formulaire lié au pivot article_tag.- Tag ne possède pas la relation — il n'a pas à gérer
position. - Le CRUD Tag généré ne comporterait aucune référence au pivot.
- Le blocage n'a donc aucune justification fonctionnelle.
Message d'erreur¶
Le message est partiellement clair :
- Il identifie correctement la relation (
Article → Tag) - Il nomme le champ problématique (
position) - Il mentionne
make:crudet le "CRUD simple" - Manque : le nom
make:pivot-crudn'est pas cité explicitement - Manque : aucune indication sur quel côté est concerné
Documentation¶
pivot-advanced.md décrit le garde comme s'appliquant quand
"make:crud est bloqué par le garde-fou" — le tableau implique que c'est
l'entité possédant le pivot qui est bloquée, pas le côté inverse.
La documentation ne dit pas que le blocage affecte les deux côtés.
9. Décision recommandée¶
Option B — Bloquer seulement le côté from.
Le correctif est minimal et ciblé :
déplacer le bloc if incompatible: raise ValueError(...) à l'intérieur
du filtre if m2m_source.lower() in current_names: dans
_load_crud_many_to_many_relations.
Structure cible :
for relation in validated_relations:
if isinstance(relation, ValidatedCanonicalManyToManyRelation):
m2m_source = relation.from_entity
m2m_target = relation.to_entity
# ...
if m2m_source.lower() not in current_names:
continue
# Garde seulement pour le côté source
incompatible = [pf.name for pf in relation.pivot_fields if not pf.nullable]
if incompatible:
raise ValueError(...)
Amélioration complémentaire du message : citer make:pivot-crud explicitement.
10. Ticket suivant proposé¶
FIELD-FIX-M2M-GUARD-001 — Corriger le garde make:crud pour ne bloquer que
le côté from d'une relation many-to-many avec pivot.fields[] non-nullable.
Périmètre :
- Modifier
forge_cli/entities/crud/relations_loader.py - Déplacer le garde après le filtre
current_names - Améliorer le message d'erreur (citer
make:pivot-crud) - Ajouter un test
make:crud Tagqui vérifie qu'il passe malgré un pivotfrom=Articlenon-nullable - Mettre à jour
pivot-advanced.mdpour documenter que seul le côtéfromest concerné
11. Aucun runtime modifié¶
Ce ticket est un audit. Aucun fichier de code n'a été modifié.
- Runtime : NON modifié
- make:crud : NON modifié
- Schémas : NON modifiés
- PyPI : NON publié
- Tag : NON créé
12. Correction — FIELD-FIX-M2M-GUARD-001¶
Le bug confirmé dans cet audit a été corrigé dans le ticket FIELD-FIX-M2M-GUARD-001.
Le garde make:crud lié aux pivot.fields[] est désormais appliqué uniquement
après filtrage du côté source de la relation (if m2m_source.lower() not in current_names: continue).
État final :
make:crud Articlereste bloqué siArticle.tagscontient des champs pivot incompatibles avec le CRUD simple (nullable: falseourequired: true) ;make:crud Tagn'est plus bloqué par la relation inverse — le CRUD Tag se génère normalement ;make:pivot-crud Article tagsreste la commande dédiée pour le sous-CRUD pivot ;make:crudreste neutre vis-à-vis du Pivot advanced généré côtéto.
Fichier modifié : forge_cli/entities/crud/relations_loader.py