Relations entre entités¶
Forge utilise un fichier global mvc/entities/relations.json pour décrire les relations entre entités. Ce fichier complète les JSON d'entités : les champs restent dans chaque entité, tandis que les liens entre entités sont centralisés et projetés vers un SQL explicite.
Cette page documente le système de relations V1 tel qu'il existe dans Forge 1.5.0.
Principe d'architecture¶
Les entités restent dans mvc/entities/, chacune avec son JSON canonique et ses fichiers générés. Les relations globales restent dans mvc/entities/relations.json, puis Forge génère mvc/entities/relations.sql.
Forge ne fournit pas d'ORM. Le SQL généré reste visible, lisible et modifiable dans l'application. Les relations décrivent des contraintes et des intentions de modèle, mais elles ne créent pas de navigation objet automatique.
core/ doit rester générique. Il ne doit pas contenir de logique métier relationnelle, ni de relations propres à une application précise. Les choix métier appartiennent aux applications générées, aux starters ou aux modules générateurs.
Format V1¶
Le format V1 de relations.json contient une version et une liste de relations.
{
"format_version": 1,
"relations": [
{
"name": "contact_ville",
"type": "many_to_one",
"from_entity": "Contact",
"to_entity": "Ville",
"from_field": "ville_id",
"to_field": "id",
"foreign_key_name": "fk_contact_ville",
"on_delete": "SET NULL",
"on_update": "CASCADE"
}
]
}
| Clé | Rôle |
|---|---|
format_version |
Version du format relationnel. En V1, la valeur attendue est 1. |
relations |
Liste des relations globales. |
name |
Nom logique de la relation. |
type |
Type de relation. En V1, many_to_one et many_to_many sont supportés côté SQL. |
from_entity |
Entité source, celle qui porte la clé étrangère. |
to_entity |
Entité cible, celle qui porte la clé primaire référencée. |
from_field |
Champ source dans le JSON d'entité, côté clé étrangère. |
to_field |
Champ cible dans le JSON d'entité, côté clé primaire. |
foreign_key_name |
Nom de la contrainte SQL. |
on_delete |
Action SQL ON DELETE. |
on_update |
Action SQL ON UPDATE. |
Exemple many_to_one¶
Exemple neutre : un Contact appartient éventuellement à une Ville.
Dans l'entité Contact, le champ ville_id est un champ normal, visible dans le JSON de l'entité :
{
"name": "ville_id",
"column": "VilleId",
"python_type": "int",
"sql_type": "INT",
"nullable": true,
"primary_key": false,
"auto_increment": false,
"constraints": {}
}
La relation globale est ensuite décrite dans mvc/entities/relations.json :
{
"format_version": 1,
"relations": [
{
"name": "contact_ville",
"type": "many_to_one",
"from_entity": "Contact",
"to_entity": "Ville",
"from_field": "ville_id",
"to_field": "id",
"foreign_key_name": "fk_contact_ville",
"on_delete": "SET NULL",
"on_update": "CASCADE"
}
]
}
SQL généré¶
Forge génère un fichier relations.sql contenant du SQL relationnel explicite :
contraintes many_to_one et tables pivot many_to_many.
ALTER TABLE contact
ADD CONSTRAINT fk_contact_ville
FOREIGN KEY (VilleId)
REFERENCES ville (Id)
ON DELETE SET NULL
ON UPDATE CASCADE;
Le fichier relations.sql contient les contraintes globales, typiquement des
ALTER TABLE ... ADD CONSTRAINT, et les tables pivot déclaratives
many_to_many. Les CREATE TABLE des entités normales restent dans les
fichiers SQL des entités.
Règles de validation¶
Forge valide les relations avant de générer relations.sql.
- Les entités
from_entityetto_entitydoivent exister. - Les champs
from_fieldetto_fielddoivent exister. to_fielddoit être une clé primaire.- Les types Python des champs source et cible doivent être compatibles.
- Les types SQL des champs source et cible doivent être compatibles pour MariaDB.
SET NULLexige que le champ source soit nullable.- Les noms SQL, comme
foreign_key_name, sont validés. - Les noms de relations et de contraintes doivent rester uniques.
Ces règles évitent de générer une contrainte SQL incohérente ou dangereuse.
Relations many_to_many déclaratives¶
Depuis Forge 1.5.0, une relation many_to_many peut être déclarée dans relations.json.
Format¶
{
"format_version": 1,
"relations": [
{
"type": "many_to_many",
"source": "article",
"target": "tag",
"pivot_table": "article_tag",
"source_key": "article_id",
"target_key": "tag_id",
"pivot_fields": [
{
"name": "position",
"sql_type": "INT",
"nullable": false
},
{
"name": "note",
"sql_type": "VARCHAR(255)",
"nullable": true
}
]
}
]
}
| Clé | Rôle |
|---|---|
type |
Doit valoir "many_to_many". |
source |
Nom de la table source (identifiant SQL, snake_case). |
target |
Nom de la table cible (identifiant SQL, snake_case). |
pivot_table |
Nom de la table pivot (identifiant SQL, snake_case). |
source_key |
Colonne FK vers la table source dans le pivot. |
target_key |
Colonne FK vers la table cible dans le pivot. |
pivot_fields |
Liste optionnelle de colonnes supplémentaires sur le pivot. |
Si pivot_fields est absent, Forge génère le même pivot simple que
REL-M2M-002.
Règles de validation¶
type,source,target,pivot_table,source_key,target_keysont tous obligatoires.- Chaque valeur de clé relationnelle doit être une chaîne et un identifiant SQL valide.
pivot_fieldsest optionnel.- Si
pivot_fieldsest présent, il doit être une liste. - Chaque champ pivot exige
nameetsql_type. nullableest optionnel et vautfalsepar défaut.- Un champ pivot ne peut pas dupliquer
source_keyoutarget_key. - Les clés inconnues sont refusées.
SQL généré¶
Depuis REL-M2M-002, forge sync:relations génère la table pivot SQL dans
mvc/entities/relations.sql.
Pour l'exemple précédent, le SQL généré est :
CREATE TABLE IF NOT EXISTS article_tag (
article_id INT NOT NULL,
tag_id INT NOT NULL,
position INT NOT NULL,
note VARCHAR(255) NULL,
PRIMARY KEY (article_id, tag_id),
INDEX idx_article_tag_article_id (article_id),
INDEX idx_article_tag_tag_id (tag_id),
CONSTRAINT fk_article_tag_article_id
FOREIGN KEY (article_id)
REFERENCES article(id)
ON DELETE CASCADE,
CONSTRAINT fk_article_tag_tag_id
FOREIGN KEY (tag_id)
REFERENCES tag(id)
ON DELETE CASCADE
);
Les noms d'index et de contraintes sont déterministes :
idx_{pivot_table}_{source_key};idx_{pivot_table}_{target_key};fk_{pivot_table}_{source_key};fk_{pivot_table}_{target_key}.
Les relations many_to_many et many_to_one peuvent coexister dans le même relations.json.
Pivot enrichi¶
Depuis REL-PIVOT-001, une relation many_to_many peut déclarer des colonnes
supplémentaires avec pivot_fields. Forge les ajoute uniquement dans le SQL de
la table pivot.
Exemple complet :
{
"type": "many_to_many",
"source": "article",
"target": "tag",
"pivot_table": "article_tag",
"source_key": "article_id",
"target_key": "tag_id",
"pivot_fields": [
{
"name": "position",
"sql_type": "INT",
"nullable": false
},
{
"name": "note",
"sql_type": "VARCHAR(255)",
"nullable": true
}
]
}
SQL généré :
CREATE TABLE IF NOT EXISTS article_tag (
article_id INT NOT NULL,
tag_id INT NOT NULL,
position INT NOT NULL,
note VARCHAR(255) NULL,
PRIMARY KEY (article_id, tag_id),
INDEX idx_article_tag_article_id (article_id),
INDEX idx_article_tag_tag_id (tag_id),
CONSTRAINT fk_article_tag_article_id
FOREIGN KEY (article_id)
REFERENCES article(id)
ON DELETE CASCADE,
CONSTRAINT fk_article_tag_tag_id
FOREIGN KEY (tag_id)
REFERENCES tag(id)
ON DELETE CASCADE
);
Les champs pivot ne sont pas inclus dans la clé primaire composite et Forge ne crée pas d'index automatique sur eux.
Formulaires CRUD côté source¶
Depuis REL-M2M-003, forge make:crud exploite aussi les relations many_to_many
où l'entité générée est la source.
Pour l'exemple article -> tag, le formulaire Article généré contient un
champ conceptuellement équivalent à :
Le nom du champ suit la convention {target}_ids. Le contrôleur généré charge
les choix de la table cible avec du SQL explicite :
Le champ de libellé suit la même logique que les relations many_to_one :
name, nom, title, titre, label, libelle, puis premier champ texte,
puis clé primaire si aucun champ texte n'existe.
À la création, Forge insère les lignes pivot sélectionnées après la création de l'entité source :
À l'édition, Forge applique une synchronisation simple et lisible :
DELETE FROM article_tag
WHERE article_id = ?
INSERT INTO article_tag (article_id, tag_id)
VALUES (?, ?)
Seul le côté source est traité dans ce ticket. Le CRUD Tag ne reçoit pas
automatiquement un champ inverse vers les articles.
Affichage CRUD côté source¶
Depuis REL-M2M-004, forge make:crud affiche aussi les libellés liés dans les
pages index.html et show.html générées pour l'entité source.
Pour article -> tag, la liste peut afficher une colonne Tag :
| Titre | Tag | Actions |
|---|---|---|
| Article 1 | Python, Web | Voir / Modifier / Supprimer |
Le contrôleur de liste charge les libellés en une requête groupée sur les ids affichés :
SELECT
pivot.article_id AS source_id,
tag.id AS target_id,
tag.name AS target_label
FROM article_tag pivot
JOIN tag ON tag.id = pivot.tag_id
WHERE pivot.article_id IN (...)
ORDER BY tag.name
La fiche show charge seulement les libellés de l'objet affiché :
SELECT tag.id AS target_id, tag.name AS target_label
FROM article_tag pivot
JOIN tag ON tag.id = pivot.tag_id
WHERE pivot.article_id = ?
ORDER BY tag.name
L'affichage reste volontairement simple : libellés séparés par des virgules, état vide lisible, aucun lien automatique vers les fiches cible. Les formulaires create/edit et la synchronisation pivot restent ceux de REL-M2M-003.
Limites actuelles¶
Forge V1 ne supporte pas encore directement :
one_to_manyexploité dans le CRUD généré ;- relation ordonnée ;
- relation principale ;
- génération automatique de
JOINarbitraires au-delà des relations déjà supportées ; - gestion inverse côté cible ;
- liens automatiques vers les fiches cible ;
- autocomplete, tags UI ou interaction HTMX spécifique ;
- saisie, édition ou affichage des champs
pivot_fieldsdans le CRUD ; - attach/detach applicatif dédié aux pivots enrichis.
Depuis Forge 1.2.0, forge make:crud génère automatiquement un <select> pour les champs FK déclarés dans relations.json — voir la section Exploitation dans le CRUD généré ci-dessous.
Pivot explicite¶
Un many-to-many peut déjà être modélisé manuellement avec une entité pivot normale et deux relations many_to_one.
Exemple neutre :
ArticleTagArticleTag
ArticleTag est une entité associative classique :
{
"entity": "ArticleTag",
"fields": [
{ "name": "id", "sql_type": "INT", "primary_key": true, "auto_increment": true },
{ "name": "article_id", "sql_type": "INT" },
{ "name": "tag_id", "sql_type": "INT" }
]
}
Les deux relations sont ensuite déclarées dans relations.json :
{
"format_version": 1,
"relations": [
{
"name": "article_tag_article",
"type": "many_to_one",
"from_entity": "ArticleTag",
"to_entity": "Article",
"from_field": "article_id",
"to_field": "id",
"foreign_key_name": "fk_article_tag_article",
"on_delete": "CASCADE",
"on_update": "CASCADE"
},
{
"name": "article_tag_tag",
"type": "many_to_one",
"from_entity": "ArticleTag",
"to_entity": "Tag",
"from_field": "tag_id",
"to_field": "id",
"foreign_key_name": "fk_article_tag_tag",
"on_delete": "CASCADE",
"on_update": "CASCADE"
}
]
}
Cette approche garde le modèle explicite : le pivot est une vraie entité, avec son propre SQL et son propre CRUD si nécessaire.
Exploitation dans le CRUD généré¶
Forge 1.2.0 commence à exploiter les relations V1 many_to_one dans le CRUD généré.
Depuis REL-M2M-003, le CRUD généré exploite aussi les relations many_to_many
côté source dans les formulaires create/edit.
Depuis REL-M2M-004, les mêmes relations sont affichées côté source dans les
listes et les fiches détail générées.
Quand l'entité générée est le from_entity d'une relation, le champ FK correspondant peut être rendu comme un choix applicatif :
- le formulaire Python utilise
RelationField, qui hérite deChoiceField; - la vue
form.htmlutilise un<select>; - le contrôleur fournit les choix au formulaire ;
- le modèle généré charge les choix depuis la table cible avec du SQL visible.
Comme relations.json V1 ne contient pas encore de label_field, Forge choisit le libellé affiché avec un fallback simple :
- premier champ texte non-PK de l'entité cible ;
- sinon clé primaire cible.
Le support many_to_many reste limité au côté source, aux formulaires simples,
à la synchronisation simple des pivots et à l'affichage texte des libellés.
pivot_fields enrichit seulement le SQL généré. Il ne crée pas de navigation
objet, pas de liens automatiques vers les cibles et pas d'ORM.
Ce qui reste à venir¶
Les prochains incréments relationnels prévus :
- relations ordonnées ;
label_fieldexplicite dansrelations.jsonV2 ;- navigation objet optionnelle sans ORM complet.
Forge conserve sa doctrine : pas d'ORM, SQL visible, incréments génériques dans les générateurs.
Bonnes pratiques¶
- Nommez explicitement les relations, par exemple
contact_villeouarticle_categorie. - Nommez clairement les contraintes SQL, par exemple
fk_contact_ville. - Préférez des champs FK explicites comme
ville_id,categorie_idouauteur_id. - Ne cachez pas une vraie entité métier dans un pivot trop complexe.
- Utilisez une entité associative quand le pivot porte beaucoup de données.
- Gardez les relations génériques dans
relations.jsonet les règles métier dans l'application.