Pivot advanced — tables pivot avec attributs¶
1. Objectif¶
Un pivot advanced est une table pivot many_to_many qui porte des attributs
métier propres à la relation — position, note, role, joined_at, etc.
Ces attributs ne sont ni des colonnes d'entité source ni des colonnes d'entité
cible : ils appartiennent à l'association elle-même. Le CRUD standard de
Forge (make:crud) ne peut pas les gérer — il synchronise uniquement des paires
d'IDs. Un sous-CRUD relationnel dédié est nécessaire.
Forge fournit :
PivotAdvancedService— service de persistence pour les associations pivot ;make:pivot-crud— générateur opt-in d'un contrôleur et de templates dédiés.
2. Quand utiliser Pivot advanced ?¶
Utilisez Pivot advanced quand une relation many_to_many porte des données
métier non nullables ou significatives.
| Cas | Recommandation |
|---|---|
pivot.fields[] vide |
make:crud suffit — <select multiple> d'IDs |
pivot.fields[] avec champs tous nullables et non requis |
make:crud suffit ; attributs ignorés |
pivot.fields[] avec au moins un champ required: true ou nullable: false |
make:pivot-crud obligatoire — make:crud est bloqué par le garde-fou |
pivot.fields[] avec attributs métier significatifs |
make:pivot-crud recommandé |
3. Exemple complet Article ↔ Tag¶
Un article peut être associé à plusieurs tags. Chaque association porte :
position— ordre d'affichage dans l'articlenote— note éditoriale sur l'association
Article ↔ article_tag ↔ Tag
Colonnes de article_tag :
id (clé primaire technique)
article_id (clé étrangère → Article)
tag_id (clé étrangère → Tag)
position (attribut de l'association)
note (attribut de l'association)
position et note sont des informations de la relation, pas de Article
ni de Tag. Un même tag peut avoir position=1 pour un article et position=3
pour un autre.
4. Déclarer la relation dans relations.json¶
{
"schema_version": "1.0",
"relations": [
{
"type": "many_to_many",
"from": "Article",
"to": "Tag",
"name": "tags",
"inverse_name": "articles",
"pivot": {
"table": "article_tag",
"from_key": "article_id",
"to_key": "tag_id",
"id": true,
"unique_pair": true,
"fields": [
{
"name": "position",
"type": "integer",
"nullable": false
},
{
"name": "note",
"type": "string",
"max_length": 120,
"nullable": true
}
]
}
}
]
}
Points clés :
schema_version: "1.0"est obligatoire.pivot.fields[]non vide déclenche le besoin d'un sous-CRUD dédié.make:crudrefuse de générer le CRUD si un champ estrequired: trueounullable: false— message d'erreur explicite avec suggestion d'utilisermake:pivot-crud.make:pivot-crudest la commande dédiée pour ces cas.
Après modification de relations.json, relancer :
5. Générer le sous-CRUD pivot¶
Article— entité source (valeur de"from"dansrelations.json)tags— nom de la relation (valeur de"name"dansrelations.json)
Comportements :
| Comportement | Description |
|---|---|
--dry-run |
Liste les fichiers qui seraient générés sans les écrire |
| Génération | Crée les fichiers si absents (write-if-new) |
| Fichiers existants | Préservés — jamais écrasés |
| Routes | Documentées dans la sortie, non branchées automatiquement |
make:crud |
Non modifié — reste neutre |
La commande affiche les routes à ajouter manuellement dans mvc/routes.py.
6. Fichiers générés¶
mvc/controllers/pivot/article_tags_pivot_controller.py
mvc/templates/pivot/article_tags/index.html
mvc/templates/pivot/article_tags/form.html
Contrôleur¶
article_tags_pivot_controller.py contient une classe avec six actions :
| Action | Route suggérée |
|---|---|
index |
GET /articles/{id}/tags |
add_form |
GET /articles/{id}/tags/add |
add |
POST /articles/{id}/tags/add |
edit_form |
GET /articles/{id}/tags/{tag_id}/edit |
edit |
POST /articles/{id}/tags/{tag_id}/edit |
remove |
POST /articles/{id}/tags/{tag_id}/remove |
Le contrôleur importe PivotAdvancedService, PivotConstraintError et
pivot_error_to_form_error. Les actions add et edit intègrent un bloc
try/except qui convertit les erreurs de contrainte en PivotFormError
transmis au template.
Templates¶
index.html — tableau listant les associations avec les colonnes pivot.
form.html — formulaire d'ajout ou de modification. Affiche le message
d'erreur si error est présent dans le contexte :
{% if error %}
<div class="bg-red-50 border border-red-300 text-red-700 px-4 py-3 rounded mb-4">
{{ error.message }}
</div>
{% endif %}
Les templates générés sont une base minimale — adaptez-les selon les besoins visuels de votre projet.
7. Utiliser PivotAdvancedService¶
Import¶
from forge_mvc_pivot import (
PivotAdvancedService,
PivotFieldConstraint,
PivotConstraintError,
PivotFormError,
pivot_error_to_form_error,
)
Le pivot avancé est un opt-in extrait du core (ADR-021). Installez le paquet
avant usage : pip install --pre forge-mvc-pivot.
Instanciation (sans contraintes — API simple)¶
service = PivotAdvancedService(
table="article_tag",
source_key="article_id",
target_key="tag_id",
pivot_fields=["position", "note"],
)
En production, le service délègue automatiquement aux helpers core.database.db.
Les paramètres fetch_one, fetch_all, execute, insert_fn sont optionnels
et destinés aux tests.
Instanciation avec contraintes¶
service = PivotAdvancedService(
table="article_tag",
source_key="article_id",
target_key="tag_id",
pivot_constraints=[
PivotFieldConstraint("position", required=True, nullable=False),
PivotFieldConstraint("note", required=False, nullable=True),
],
unique_pair=True,
id_field="id",
)
Méthodes principales¶
# Lister toutes les associations d'un article
rows = service.list_for_source(article_id)
# → liste de PivotRow
# Lire une association spécifique
row = service.get(article_id, tag_id)
# → PivotRow ou None
# Créer une association
pivot_id = service.attach(article_id, tag_id, {"position": 1, "note": "Principal"})
# → lastrowid
# Modifier les attributs d'une association
service.update(article_id, tag_id, {"note": "Mis à jour"})
# → rowcount
# Supprimer une association
service.detach(article_id, tag_id)
# → rowcount
Méthodes par id technique (requiert id_field)¶
row = service.get_by_id(pivot_id)
service.update_by_id(pivot_id, {"note": "Nouveau"})
service.delete_by_id(pivot_id)
Ces méthodes nécessitent id_field="id" lors de l'instanciation. Sans ce
paramètre, elles lèvent PivotConstraintError(code="missing_id_field").
PivotRow¶
Chaque résultat est un PivotRow :
row.source_id # article_id
row.target_id # tag_id
row.pivot_data # dict : {"id": 1, "position": 2, "note": "..."}
8. Contraintes : required, nullable, unique_pair, id technique¶
Les contraintes sont déclarées via PivotFieldConstraint dans le constructeur.
Elles s'appliquent lors de attach et update.
required¶
required=True— le champ doit être présent danspivot_datalors d'unattach.requiredn'est pas vérifié lors d'unupdate— mise à jour partielle autorisée.
nullable¶
nullable=False— la valeurNoneest refusée dansattachetupdate.- Si le champ est absent de
pivot_datalors d'unattach, aucune vérification n'est faite (sauf sirequired=True).
unique_pair¶
- Avant chaque
attach, le service vérifie que la paire(source_id, target_id)n'existe pas déjà. - Si elle existe,
PivotConstraintError(code="duplicate_pair")est levée. - Sans
unique_pair=True, la contrainte UNIQUE de la base de données s'applique directement.
id technique¶
Active les méthodes get_by_id, update_by_id, delete_by_id.
9. Erreurs UX et messages formulaire¶
Codes d'erreur stables¶
| Code | Cas |
|---|---|
required_field_missing |
Champ required=True absent lors de attach |
nullable_field_rejected |
Champ nullable=False reçoit None |
duplicate_pair |
Association déjà existante avec unique_pair=True |
missing_id_field |
Appel *_by_id sans id_field configuré |
unknown_pivot_field |
Champ inconnu dans pivot_data |
invalid_pivot_data |
Toute autre erreur inattendue |
Conversion en erreur formulaire¶
try:
service.attach(article_id, tag_id, pivot_data)
except Exception as exc:
error = pivot_error_to_form_error(exc)
return render("form.html", error=error)
pivot_error_to_form_error retourne un PivotFormError :
error.code # code stable, ex: "required_field_missing"
error.message # message humain, ex: "Champ pivot requis absent : 'position'."
error.field # champ concerné si applicable, ex: "position" ou None
La fonction ne divulgue jamais de détail SQL — les erreurs inconnues produisent un message générique.
Dans le template¶
10. Ce que Pivot advanced ne fait pas¶
- Il ne modifie pas
make:crud— le CRUD d'entité classique reste inchangé. - Il ne branche pas les routes automatiquement dans
mvc/routes.py. - Il ne génère pas de JavaScript avancé (drag & drop, ordre dynamique).
- Il n'intègre pas de logique RBAC.
- Il ne remplace pas la conception métier de votre application.
- Les templates générés sont minimaux — ils sont conçus pour être adaptés.
- Une relation sans
pivot.fields[]non vide n'a pas besoin de Pivot advanced.
11. Commandes utiles¶
# Valider relations.json
python forge.py entity:validate
# Générer le SQL pivot
python forge.py build:model
# Générer le sous-CRUD (avec confirmation de ce qui sera créé)
python forge.py make:pivot-crud Article tags --dry-run
# Générer le sous-CRUD
python forge.py make:pivot-crud Article tags
# Inspecter les routes existantes
python forge.py routes:list
12. Limites actuelles¶
- Pivot advanced est opt-in — rien n'est généré automatiquement.
make:crudreste neutre — il ne lit paspivot.fields[]et ne génère aucun écran pivot advanced.- Les routes du sous-CRUD pivot ne sont pas branchées automatiquement dans
mvc/routes.py— elles sont documentées dans la sortie demake:pivot-crud. - Les templates générés (
index.html,form.html) sont minimaux — une UX finale peut nécessiter des adaptations CSS et structurelles. - La synchronisation globale destructive (remplacer toutes les associations d'une
source) n'est pas encore couverte par
PivotAdvancedService— utilisezdetachpuisattachpour chaque association. - L'accès par
id_fieldest optionnel et doit être configuré explicitement.
Pour la documentation des tables pivot simples (sans attributs métier), voir Tables pivot many-to-many.