ADR-078 : Fixtures callable (hooks Python dans le pipeline fixtures:load)¶
Statut¶
Acceptée.
Décision d'architecture ; relève du mainteneur.
Date¶
2026-07-12
Contexte¶
L'opt-in forge-mvc-fixtures charge, purge et génère des jeux de données (ADR-074, ADR-076, ADR-077).
Le chargement (fixtures:load) n'exécute que des fichiers mvc/fixtures/*.sql : des données statiques, relues.
Le banc d'essai RéférenCiel veut supprimer son script de seed maison et tout passer par l'opt-in.
Deux étapes d'un seed réaliste ne sont pas des données statiques et ne peuvent pas s'exprimer en .sql :
- l'import d'un référentiel depuis un JSON canonique : une fonction applicative parcourt le canonique et persiste. Le figer en
.sqldupliquerait des dizaines d'objets et perdrait la source ; - des valeurs calculées : un agrégat construit à partir d'autres tables.
Ces deux cas exigent d'exécuter du code Python dans le même pipeline que les .sql.
L'ADR-077 avait explicitement différé cette piste (« fixtures callable »), citant trois points à examiner : dépendance, ordre, sécurité. Cet ADR les tranche.
Décision¶
Une classe Fixture, une seule façon¶
forge-mvc-fixtures expose une classe de base Fixture (à côté de Factory), sous-classée dans un fichier mvc/fixtures/<nom>.py :
from forge_mvc_fixtures import Fixture
from mvc.services.referentiel_importer import import_referentiel
class ReferentielFixture(Fixture):
tables = ("matiere", "niveau") # pour l'ordre et la purge
depends_on = ("annee_scolaire",) # exécutée après ces tables
def load(self) -> None:
import_referentiel("data/referentiel.json")
load(self)(requis) écrit en base comme le reste du projet : la fixture importecore.database.db(ou appelle une fonction applicative qui le fait). Le SQL vit dans le code applicatif, paramétré et visible (principe 7).tables: tuple[str, ...](optionnel) : les tables peuplées, pour l'ordre de chargement et la purge.depends_on: tuple[str, ...](optionnel) : noms d'entités ou de tables à charger avant.purge(self, *, tx=None)(optionnel) : démontage ; par défaut vide lestablesdéclarées, surchargeable pour un teardown sur-mesure. Reçoit la transaction defixtures:purgeet la propage à sesdb.execute(F52-bis).
Le préfixe numérique du nom de fichier (50_referentiel.py, 90_bilan.py) ordonne les fixtures callable entre elles, comme secours déclaratif.
On écarte la fonction load() nue : la classe porte les métadonnées d'ordre et de purge, et reste symétrique à Factory (principe 11, une seule façon).
Découverte¶
fixtures:load découvre les fixtures callable dans mvc/fixtures/*.py (au premier niveau).
Le sous-dossier mvc/fixtures/factories/ (factories de génération, ADR-076) et les fichiers __*.py sont exclus.
Chaque module expose une sous-classe de Fixture.
Avant de charger un mvc/fixtures/*.py, la racine du projet (où vivent config.py et mvc/) est insérée dans sys.path : une fixture callable importe donc le code applicatif (from mvc.services… import …) comme n'importe quelle commande du projet (retour terrain F49).
Ordre de chargement unifié¶
Le pipeline ordonne un ensemble d'unités (les fichiers .sql et les fixtures callable) par un unique graphe fournit / dépend (retour terrain F50) :
- chaque unité fournit des tables : les
INSERT INTOd'un.sql, lestablesd'un callable ; - chaque unité dépend de tables : les clés étrangères de ses tables fournies (graphe de
relations.json), les tables citées par une sous-requêtereference()d'un.sql(SELECT Id FROM <table>, F43, même horsrelations.jsoncomme la table de socleusers), plus lesdepends_ond'un callable (résolus en tables) ; - une unité qui dépend d'une table passe après toute unité qui la fournit, quel qu'en soit le type. Un callable fournissant
niveau_classe(ouusers) est donc ordonné avant un.sqldont une clé étrangère ou unereference()en dépend.
Le tri topologique de ce graphe est déterministe : à contrainte égale, les .sql passent avant les callable, puis on départage par nom de fichier (préfixe numérique 50_, 90_ compris).
Repli en cas de cycle : les unités restantes dans ce même ordre déterministe.
Affichage puis exécution (charte §7)¶
Comme pour les .sql, fixtures:load affiche par défaut et n'exécute rien :
- une unité
.sqlaffiche son SQL ; - une unité callable affiche le source de son fichier
.py(versionné, relu).
--run exécute : .sql via db.execute, callable en instanciant la classe et en appelant .load().
La protection production reste identique (--run seul refusé en APP_ENV=prod, --force pour confirmer).
Purge¶
fixtures:purge intègre les fixtures callable au démontage, en ordre inverse du chargement (les callable, qui dépendent des tables de base, sont purgées avant les .sql).
Le démontage est encadré par la désactivation des contraintes de clés étrangères du dialecte (foreign_key_checks_ddl, ADR-054), robuste même pour un callable peuplant plusieurs tables liées.
SET FOREIGN_KEY_CHECKS étant une variable de session (par connexion), tout le démontage se déroule dans une seule transaction (core.database.transaction) : la désactivation, tous les DELETE et la réactivation partagent la même connexion (F52-bis). Un db.execute sans tx repioche une connexion du pool où les FK restent actives.
Chaque Fixture porte une méthode purge(self, *, tx=None) :
- par défaut, elle vide les
tablesdéclarées (DELETE FROM <table>en ordre inverse), en propageanttxà sesdb.execute; - une sous-classe peut la surcharger pour un démontage sur-mesure (l'inverse exact de son
load()), en gardant la signaturepurge(self, *, tx=None)et en propageanttx.
Une fixture callable qui écrit dans des tables non déclarées et ne surcharge pas purge() n'est pas purgée automatiquement : limite documentée (déclarer tables, ou écrire purge()).
Sécurité¶
Exécuter un .py de mvc/fixtures/ revient à exécuter du code du projet lui-même, écrit par le développeur, versionné et relu.
Le geste est explicite (--run), cadré par environnement (dev/test par défaut, production protégée). Le risque est celui de lancer l'application, pas davantage : aucun code distant, aucune écriture invisible.
Frontière réaffirmée (principe 11)¶
La fixture callable n'est pas une deuxième façon d'insérer des données statiques : celles-ci restent des .sql (écrits à la main ou générés par fixtures:generate).
Le callable est réservé à ce que le SQL statique ne peut pas exprimer : import depuis une source, valeurs calculées.
Conséquences¶
- Surface d'API élargie (additive, rétro-compatible) :
forge-mvc-fixtures: classeFixture(nouvelle, publique ;load(),tables,depends_on,purge()) ;fixtures:loaddécouvre, ordonne, affiche et exécute lesmvc/fixtures/*.py;fixtures:purgedémonte les fixtures callable (purge(*, tx=None), défaut surtables) en ordre inverse, dans une transaction unique encadrée par la désactivation FK (F52-bis).
- Un seed 100 % opt-in devient possible :
.sqlpour le statique et le relationnel, callable pour l'import et les agrégats, dans un ordre unique. - Le pipeline exécute du code applicatif : posture de sécurité alignée sur « lancer le projet », documentée.
- Le SQL reste paramétré et visible (dans le code applicatif appelé) ; les fixtures
.pysont versionnées et affichées avant exécution.
Alternatives écartées¶
- Fonction
load()nue (sans classe).
Écartée : ne porte pas les métadonnées d'ordre (depends_on) ni de purge (tables) ; deux formes coexistantes casseraient le principe 11. - Injecter
dben argument deload(db).
Écartée : la fixture accède à la base « comme le reste du projet » (from core.database import db) ; l'injection ajouterait une convention propre aux fixtures sans gain. - Autoriser des fixtures en JSON/YAML déclaratif exécutées par un moteur.
Hors charte : masquerait le SQL (principe 5) et introduirait un moteur d'interprétation ; le.pyreste du code Python relu. - Statu quo (seed maison hors opt-in).
Rejeté : le besoin (import canonique, agrégats) est général et récurrent ; il a sa place dans le pipeline unique de chargement.