Les données de démonstration et de test dans Forge (forge-mvc-fixtures)¶
Ce document explique ce que fait l'opt-in forge-mvc-fixtures, ce qu'il expose, et comment on s'en sert.
forge-mvc-fixtures charge, purge et génère des données de démonstration et de test rejouables et cadrées par environnement, à SQL visible.
Le cœur de Forge ignore tout des fixtures : ce paquet fournit les commandes et la classe de base des factories ; l'application fournit ses fichiers .sql et ses factories.
1. Rôle du module
Une démonstration ou un projet pédagogique a besoin d'un jeu de données de départ : référentiels, comptes d'exemple, données d'atelier.
L'opt-in couvre ce besoin par des données rejouables (charger, purger, recharger) et cadrées par environnement (dev, test, jamais prod par défaut).
Il fournit :
- le chargement :
fixtures:loadexécute les fichiersmvc/fixtures/*.sql; - la purge :
fixtures:purgevide les tables ciblées pour repartir d'un état propre ; - la génération :
fixtures:make-factoryéchafaude une factory depuis le contrat d'entité,fixtures:generatel'exécute et écrit le.sql.
Le SQL reste visible (charte principe 5) : les fixtures sont des fichiers .sql relus, et les commandes affichent le SQL avant de l'exécuter (charte §7).
Frontière (ADR-074, principe 11) : le référentiel permanent reste une migration de seed appliquée par forge migration:apply ; les données de démo/test rejouables relèvent de cet opt-in.
2. Installation
Prérequis : activez le venv du projet
Quelle que soit la source, installez dans le venv du projet :
Lancé hors d'un venv, pip vise le Python système (Debian 12+, Ubuntu 23.04+),
protégé par PEP 668. Il refuse alors d'installer, pour ne pas écraser les paquets
gérés par apt, et affiche externally-managed-environment.
Le venv de projet créé par forge new n'a pas ce verrou.
Installer le paquet¶
B. Depuis Git (avant-garde)¶
Cœur puis opt-in depuis git, dans le venv du projet (l'opt-in trouve le cœur git déjà en place, sans version publiée sur PyPI) :
3. Mise en service
Installer le paquet ne suffit pas à le rendre opérationnel.
Voici les gestes propres à forge-mvc-fixtures, dans l'ordre.
Ils déclinent la procédure canonique, Rendre un opt-in opérationnel : les cinq
points.
1. L'épingler¶
Dans requirements.txt, à la même version ou au même commit que forge-mvc.
Sans cette ligne, l'opt-in n'existe que sur votre machine.
2. L'inscrire¶
L'opt-in est inscrit dans optins/registry.py (ADR-061), ce qui le rend visible du
projet.
--apply est obligatoire : sans lui, la commande simule et n'écrit rien.
3. Poser ce dont il a besoin¶
Rien à faire : cet opt-in n'apporte aucune table.
4. Le brancher là où il agit¶
Rien à brancher : il ajoute des commandes forge, sans surface de runtime.
Une application ne l'importe pas dans le chemin d'une requête.
5. Le prouver¶
Puis un premier usage réel.
Un opt-in installé, inscrit et provisionné qu'aucun code n'appelle n'est pas
opérationnel : il est seulement présent.
4. Désinstallation
opt-in:disable est l'inverse d'enable : il dé-inscrit du registre, sans toucher au paquet.
forge opt-in:remove fixtures affiche la commande pip uninstall sans l'exécuter.
5. Commandes
forge-mvc-fixtures ajoute quatre commandes.
| Commande | Rôle | Exemple |
|---|---|---|
fixtures:load |
Charge mvc/fixtures/*.sql dans la base de l'environnement actif. |
forge fixtures:load --run |
fixtures:purge |
Vide les tables ciblées par les fixtures. | forge fixtures:purge --run |
fixtures:make-factory |
Échafaude une factory depuis le contrat d'entité. | forge fixtures:make-factory ville |
fixtures:generate |
Exécute la factory et écrit mvc/fixtures/<table>.sql. |
forge fixtures:generate ville --rows 50 --seed 42 |
load et purge affichent leur SQL par défaut ; --run exécute ; --run --force autorise APP_ENV=prod.
generate et make-factory écrivent un fichier en mode write-if-new (--force pour remplacer).
load ordonne les fichiers par dépendances de clés étrangères (ADR-077) ; --no-fk-checks désactive les contraintes le temps du chargement pour les jeux non triables.
6. Vue d'ensemble rapide
| Élément | Valeur |
|---|---|
| Paquet | forge-mvc-fixtures |
| Module | forge_mvc_fixtures |
| Catégorie | Exploitation et outillage (ADR-055) |
| Couche | opt-in CLI-only (ADR-052), sans API de runtime |
| Dépend de | forge-mvc, un backend BDD installé (ADR-054), et faker (génération) |
| Commandes | fixtures:load, fixtures:purge, fixtures:generate, fixtures:make-factory |
| API publique | Factory (classe de base, reference), Fixture (hooks Python), FactoryError, FixtureReference |
| Table SQL | aucune (l'opt-in peuple des tables déjà provisionnées) |
| Environnement | vise APP_ENV (défaut dev) ; production protégée (--force) |
| Rendu SQL | via dialect.render_literal (ADR-075), correct pour le backend installé |
| Installation | pip install --pre forge-mvc-fixtures |
7. Schémas UML
Deux vues complémentaires : la classe de génération et le flux des commandes.
5.1 Diagramme de classe¶
classDiagram
direction LR
class Factory {
<<classe de base>>
+str table
+str locale
+faker
+definition() dict
+rows(count) list~dict~
+build(count) list~dict~
}
class VilleFactory {
+rows(count) list~dict~
}
class FactoryError {
<<exception>>
}
VilleFactory --|> Factory : sous-classe (mvc/fixtures/factories/)
Factory ..> FactoryError : lève si mal définie
À retenir :
- une factory produit des dicts (colonne vers valeur), pas du SQL ni des écritures en base ;
rows(count)est la surface de code libre (boucles, conditions, tableaux) ;definition()couvre le cas simple ;self.fakerest disponible mais optionnel.
5.2 Diagramme de séquence¶
sequenceDiagram
actor Dev as Développeur
participant Make as fixtures:make-factory
participant Gen as fixtures:generate
participant Factory as VilleFactory
participant Load as fixtures:load
participant Base as Base (env actif)
Dev->>Make: forge fixtures:make-factory ville
Make-->>Dev: mvc/fixtures/factories/ville_factory.py
Dev->>Gen: forge fixtures:generate ville --rows 50
Gen->>Factory: build(50)
Factory-->>Gen: lignes (dicts)
Gen-->>Dev: affiche + écrit mvc/fixtures/ville.sql
Dev->>Load: forge fixtures:load --run
Load->>Base: exécute les INSERT
À retenir :
- la génération produit un
.sqlrelu et versionné ; le chargement restefixtures:load(un seul mécanisme, principe 11) ; - chaque valeur est rendue par
dialect.render_literal(correcte pour le backend installé, ADR-075).
8. API publique
| Élément | Signature | Rôle |
|---|---|---|
Factory |
classe de base | table, locale, self.faker ; à sous-classer par entité |
Factory.definition |
definition() -> dict |
une ligne (colonne vers valeur), cas simple |
Factory.rows |
rows(count) -> list[dict] |
les lignes ; surface de code libre (boucles, conditions) |
Factory.build |
build(count) -> list[dict] |
produit et valide les lignes (table définie, colonnes cohérentes) |
Factory.reference |
reference(table, key_column, value) -> FixtureReference |
relie une colonne à l'Id d'une autre table par une clé naturelle (ADR-077) |
FixtureReference |
valeur | sentinelle rendue en sous-requête par fixtures:generate |
FactoryError |
exception | factory mal définie |
La classe de base est importée par le code de factory de l'utilisateur (from forge_mvc_fixtures import Factory), exécuté par fixtures:generate ; ce n'est pas une API de runtime.
9. Contextes d'utilisation
| Besoin | Élément |
|---|---|
| Charger un jeu écrit à la main | mvc/fixtures/*.sql + fixtures:load --run |
| Repartir d'un état propre | fixtures:purge --run |
| Échafauder une factory | fixtures:make-factory <entity> |
Générer un .sql volumineux |
fixtures:generate <entity> --rows N --seed S |
| Coder sa génération (boucles, conditions) | surcharger rows(count) dans la factory |
| Cibler un autre environnement | APP_ENV=test forge fixtures:load --run |
| Forcer en production (rare) | --run --force |
10. Exemples d'utilisation
8.1 Une factory¶
from forge_mvc_fixtures import Factory
class VilleFactory(Factory):
table = "villes"
def rows(self, count: int) -> list[dict]:
villes = []
for i in range(count):
villes.append({
"Nom": self.faker.city(),
"CodePostal": self.faker.postcode(),
"Prefecture": i == 0, # condition
})
return villes
Les clés du dict sont les colonnes réelles de la table, pas les noms de champs du contrat : Nom, CodePostal (PascalCase), une clé étrangère gardant son nom snake (user_id).
C'est ce que fixtures:make-factory échafaude désormais (ADR-077), via le mapping canonique de forge-mvc-entities.
8.2 Générer puis charger¶
forge fixtures:make-factory ville # échafaude la factory
forge fixtures:generate ville --rows 50 --seed 42 # affiche puis écrit mvc/fixtures/villes.sql
forge fixtures:load --run # charge dans la base de l'environnement actif
Aide-mémoire
Écrire ou générer un .sql, puis charger :
- à la main : un fichier dans
mvc/fixtures/, puisfixtures:load; - généré :
make-factorypuisgenerate, puisfixtures:load.
11. Fixtures reliées : références et ordre de chargement (ADR-077)
Un jeu de démo réaliste relie des tables : un eleve pointe un compte users, une classe pointe une annee_scolaire.
Trois mécanismes rendent ce cas natif, sans bricolage dans l'application.
9.1 Colonnes réelles¶
fixtures:make-factory échafaude le dict de la factory avec les colonnes réelles de l'entité, pas les noms de champs du contrat : Nom, UserId (PascalCase), une clé étrangère gardant son nom snake (user_id).
Le mapping vient de forge-mvc-entities (source unique, column_for_field), donc le SQL généré tourne tel quel sur le backend installé (cohérent ADR-075).
9.2 Références inter-fixtures¶
Une factory relie une ligne à une autre table par une clé naturelle, sans connaître l'Id auto-incrémenté :
def rows(self, count: int) -> list[dict]:
return [{
"Nom": self.faker.last_name(),
"UserId": self.reference("users", "Email", "prof.durand@ecole.fr"),
} for _ in range(count)]
fixtures:generate rend self.reference(...) en sous-requête SQL, résolue à la charge contre les vrais Id :
INSERT INTO eleve (Nom, UserId)
VALUES ('Durand', (SELECT Id FROM users WHERE Email = 'prof.durand@ecole.fr' LIMIT 1));
make-factory reconnaît les clés étrangères (type foreign_key, ou colonne déclarée dans relations.json) et échafaude un self.reference(...) commenté, à compléter, au lieu d'un entier aléatoire.
9.3 Ordre de chargement par dépendances¶
fixtures:load ordonne les unités par tri topologique d'un graphe de dépendances : chaque .sql est chargé après les tables dont il dépend, qu'elles viennent d'une clé étrangère de relations.json (users avant eleve) ou d'une sous-requête reference() (SELECT Id FROM users …, même pour une table de socle comme users, hors relations.json).
Les fixtures callable (chapitre 10) entrent dans le même graphe : une unité passe après toute unité qui fournit une table dont elle dépend, .sql comme .py.
Repli sur l'ordre du nom de fichier si le graphe est absent ou en cas de cycle (le préfixe 01_, 02_ reste un ordre déclaratif de secours).
Pour un jeu non triable (cycle de dépendances), --no-fk-checks encadre le chargement par le levier du dialecte : SET FOREIGN_KEY_CHECKS en MariaDB, PRAGMA defer_foreign_keys en SQLite, session_replication_role en PostgreSQL, sans effet en SQL Server.
Sur PostgreSQL, ce levier exige un rôle superutilisateur, que le compte applicatif d'un projet Forge n'est pas (ADR-033).
L'option y est donc refusée par le serveur, et la commande s'arrête en nommant le droit manquant plutôt qu'en rendant le message brut du moteur.
Les deux issues sont alors d'ordonner les fixtures par leurs dépendances, ce que fixtures:load fait seul dès que le jeu est triable, ou de charger sous un compte qui possède ce droit.
Le levier SQLite reporte la vérification au COMMIT, il ne la supprime pas.
Un enfant dont le parent n'existe toujours pas à la fin fait donc échouer le chargement entier, là où MariaDB l'aurait laissé passer.
C'est le cycle de dépendances que l'option sert à charger, pas un jeu incohérent.
9.4 Colonnes timestamps automatiques¶
Une entité options.timestamps: true déclare CreatedAt et UpdatedAt en NOT NULL (posées par la couche applicative, pas par un DEFAULT).
Une fixture qui les omettrait serait refusée au chargement (Field 'CreatedAt' doesn't have a default value).
fixtures:generate lit le contrat de l'entité et ajoute automatiquement CreatedAt/UpdatedAt aux INSERT quand la factory ne les fournit pas, avec un horodatage déterministe constant (fixtures reproductibles, pas de NOW()).
Une colonne déjà posée par la factory est respectée, jamais écrasée ; une entité sans timestamps n'est pas touchée.
12. Fixtures callable (hooks Python, ADR-078)
Deux étapes d'un seed réaliste ne sont pas des données statiques et ne peuvent pas s'écrire en .sql : l'import d'un référentiel depuis une source (un JSON canonique), et des valeurs calculées (un agrégat).
Pour ces cas, une fixture callable exécute du code Python dans le même pipeline que les .sql.
On sous-classe Fixture dans 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, *, tx=None) -> None:
import_referentiel("data/referentiel.json") # écrit via core.database.db
load(self, *, tx=None)écrit en base comme le reste du projet (from core.database import db, ou une fonction applicative qui le fait) : le SQL reste paramétré et visible dans le code appelé (principe 7).- Propagez
txà vosdb.execute, comme le fait déjàpurge(). Le chargement se déroule dans une seule transaction : sanstx, vos écritures repartiraient sur d'autres connexions du pool, échapperaient à l'annulation en cas d'échec, et--no-fk-checksne les couvrirait pas. Une fixture qui déclareload(self)sanstxest refusée, avec un message qui indique la correction. tablesetdepends_onplacent la fixture dans l'ordre de chargement (tri topologique unifié avec les.sql) ; un préfixe numérique (50_referentiel.py) ordonne les callable entre elles.purge(self)(surchargeable) démonte la fixture ; par défaut, vide lestablesdéclarées.
fixtures:load découvre les mvc/fixtures/*.py (hors factories/), affiche leur source par défaut, puis les exécute avec --run.
fixtures:purge démonte dans l'ordre inverse exact du chargement (le même graphe topologique renversé, .sql et callable), dans une seule transaction encadrée par la désactivation des contraintes FK du dialecte (SET FOREIGN_KEY_CHECKS, PRAGMA foreign_keys...). SET FOREIGN_KEY_CHECKS étant une variable de session (par connexion), tout le démontage partage une même connexion, et Fixture.purge(*, tx=None) propage cette transaction (robuste même pour un callable peuplant plusieurs tables liées). Si bien que fixtures:purge --run puis fixtures:load --run reconstruit un état propre sans erreur de clé étrangère, de façon rejouable.
Une fixture qui écrit dans des tables non déclarées et ne surcharge pas purge() n'est pas purgée automatiquement (limite : déclarer tables, ou écrire purge()).
Frontière (principe 11) : la fixture callable n'est pas une deuxième façon d'insérer du statique (cela reste des .sql), mais le recours pour ce que le SQL statique ne peut pas exprimer.
13. Frontière avec la migration de seed
Une seule façon officielle par besoin (principe 11) :
| Besoin | Voie |
|---|---|
| Données de référence permanentes (partout, prod comprise) | Migration de seed écrite à la main, forge migration:apply |
| Données de démo/test rejouables, cadrées par environnement | Opt-in fixtures (fixtures:load / fixtures:purge / fixtures:generate) |
Les fixtures peuplent des tables déjà provisionnées : le schéma vient des migrations, les données de démo viennent des fixtures.
Production protégée
En APP_ENV=prod, fixtures:load --run et fixtures:purge --run sont refusés sans --force.
Gardez les fixtures pour dev et test ; en production, le référentiel permanent passe par une migration de seed.
14. Jeux de fixtures nommés
mvc/fixtures/ était plat : tous les fichiers se chargeaient ensemble (FIXTURES-SCENARIOS-001).
Un projet qui voulait un jeu de démonstration riche et un jeu de test minimal devait commenter des fichiers ou les déplacer à la main entre deux exécutions.
mvc/fixtures/
01_roles.sql <- jeu commun, toujours chargé
demo/10_articles.sql <- forge fixtures:load --scenario demo
test/10_articles.sql <- forge fixtures:load --scenario test
Le jeu commun est chargé d'abord, puis celui du scénario : un scénario complète une base partagée au lieu de la réécrire. Sans --scenario, seul le jeu commun est chargé, ce qui est le comportement d'avant ce ticket.
Un scénario inconnu est une erreur, jamais un chargement vide
C'est le point qui compte.
--scenario dmo, faute de frappe pour demo, chargerait zéro fichier et annoncerait un succès : l'exploitant croirait ses données en place, et chercherait ailleurs pourquoi son application est vide.
Le message liste les scénarios présents. Un dossier de scénario vide est refusé pour la même raison.
Trois noms suggérés, aucun imposé
demo, test et minimal couvrent les besoins courants et la documentation les emploie.
Ce ne sont que des noms de dossiers : Forge n'en connaît aucun et n'en réserve aucun. Imposer une liste fermée obligerait à un ticket pour chaque projet ayant un quatrième besoin.
Le nom devient un dossier sur le disque : il est validé, et une valeur comme ../etc est refusée.
15. L'ordre des clés étrangères, durci
Le tri topologique existait, et se rabattait en silence sur l'ordre alphabétique dans trois cas (FIXTURES-FK-ORDER-ROBUST-001) : relations.json absent, cycle dans le graphe, ou table sans entité déclarée.
Le repli était raisonnable, le silence ne l'était pas
Le chargement échouait alors sur une violation de clé étrangère, et rien ne reliait cette erreur à l'ordre qui l'avait causée.
L'exploitant cherchait dans ses données un défaut qui était dans son graphe.
fixtures:load affiche désormais ce qu'il n'a pas pu déduire, avant de charger, et le cycle est nommé : « cycle entre Article, Auteur » se corrige, « ordre non déduit » ne se corrige pas.
Un fichier peut écrire dans plusieurs tables
L'ordre ne regardait que le premier INSERT INTO de chaque fichier.
Un fichier insérant dans articles puis commentaires était classé comme s'il ne touchait qu'articles, et pouvait passer avant celui dont commentaires dépend. Toutes les tables écrites sont maintenant lues, et le fichier est classé après la plus tardive de leurs dépendances.
Une table qui se référence elle même est signalée
Une hiérarchie parent_id demande que l'ordre soit respecté ligne à ligne dans le fichier.
Aucun classement de fichiers ne peut le garantir, et le dire vaut mieux que de laisser découvrir.
relations.json absent et relations.json illisible donnent deux messages différents : le premier est une situation normale dans un projet sans relation, le second est un défaut à corriger.
16. Partir de l'état courant plutôt que d'une page blanche
Écrire des fixtures à la main coûte cher et vieillit mal : une colonne ajoutée au contrat, et tous les INSERT sont à reprendre (FIXTURES-SNAPSHOT-001).
La base contient pourtant déjà un jeu de données cohérent, celui avec lequel on travaille.
forge fixtures:snapshot articles --limit 20 --order-by id
forge fixtures:snapshot articles --out mvc/fixtures/demo/10_articles.sql
La sortie vient d'une base réelle
Sur un environnement de recette alimenté depuis la production, ces données sont celles de personnes, et le fichier produit finit dans un dépôt Git, où il ne s'efface plus.
L'exécution en APP_ENV=prod est refusée sans --force, comme fixtures:load --run (ADR-074). L'en-tête du fichier le rappelle : un fichier de fixtures est relu des mois plus tard, souvent par quelqu'un d'autre, et rien dans un INSERT ne dit d'où il vient.
Forge ne devine pas quelles colonnes masquer
Il ne sait pas lesquelles portent une donnée personnelle, et prétendre le deviner donnerait une fausse assurance.
C'est précisément pourquoi la sortie est affichée par défaut : vous relisez avant d'écrire. Un fichier existant n'est jamais écrasé (charte §9).
Une fixture est une amorce, pas une sauvegarde
Le plafond vaut 50 lignes par défaut et 1000 au maximum.
Une ligne de plus que le plafond est lue, pour savoir qu'il en restait et le dire dans le fichier, plutôt que de rendre un instantané tronqué qui ressemble à un instantané complet.
Les valeurs sont rendues par Dialect.render_literal (ADR-075), réservé aux artefacts relus par un humain avant d'être joués. Le nom de table et le tri sont validés plutôt qu'échappés, aucun backend n'acceptant un nom de table en paramètre lié.
API Python des trois tickets¶
| Module | Symboles | Rôle |
|---|---|---|
scenarios.py |
select_scenario_files, available_scenarios, ScenarioSelection, ScenarioError, fixtures_root |
jeux nommés |
ordering.py |
plan_fixture_order, FixtureOrderPlan, tables_written_by, fk_dependencies, topological_order |
ordre et diagnostic |
snapshot.py |
snapshot_table, render_snapshot, render_insert, TableSnapshot, SnapshotError |
instantané |
Voir aussi¶
- Référence par module (cli/, factory) : détail des fonctions et de la classe
Factory. - Welcome-Fixtures : parcours d'apprentissage.