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 de Forge.
Le format de fichier est versionné via la clé format_version dans relations.json (actuellement 1).
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 version 1¶
Le format version 1 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. La valeur attendue est 1. |
relations |
Liste des relations globales. |
name |
Nom logique de la relation. |
type |
Type de relation. La version 1 du format supporte many_to_one et many_to_many 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.
Quand la colonne FK n'est pas déclarée comme champ de l'entité source (flux recommandé), relations.sql la crée lui-même, au type exact de la clé primaire visée, avant de poser la contrainte, puis un index :
ALTER TABLE classe
ADD COLUMN annee_scolaire_id BIGINT UNSIGNED NULL;
ALTER TABLE classe
ADD CONSTRAINT fk_classe_annee_scolaire_id
FOREIGN KEY (annee_scolaire_id)
REFERENCES annee_scolaire (Id)
ON DELETE RESTRICT
ON UPDATE RESTRICT;
CREATE INDEX idx_classe_annee_scolaire_id ON classe (annee_scolaire_id);
Reprendre le type exact de la PK visée (ici BIGINT UNSIGNED) est indispensable : une colonne FK signée (BIGINT) serait refusée par MariaDB, dont la clé primaire auto-incrémentée est BIGINT UNSIGNED.
Si la colonne FK est au contraire déclarée comme champ de l'entité source, relations.sql ne pose que la contrainte (l'entité porte déjà la colonne et son type) :
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 donc les colonnes FK portées par les relations, les contraintes globales 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. - Le nom de relation (
name) est un accesseur côté source : il doit être unique par entité source (from), pas globalement.Classe.annee_scolaireetInscriptionEleve.annee_scolairecoexistent. - La clé étrangère (
foreign_key) est une colonne de la table source : elle doit être unique par table source.classe.annee_scolaire_idetinscription_eleve.annee_scolaire_idsont deux colonnes distinctes. - Les tables pivot
many_to_many, elles, restent globalement uniques (ce sont de vraies tables).
Ces règles évitent de générer une contrainte SQL incohérente ou dangereuse, tout en autorisant plusieurs entités (dont les pivots) à référencer la même cible avec des noms fidèles au modèle.
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.
Relations ordonnées (order_column)¶
Depuis REL-ORDERED-001, une relation many_to_many peut déclarer un champ pivot comme colonne de tri via order_column.
Quand ce champ est présent, les requêtes list et show générées par make:crud trient les libellés liés selon ce champ plutôt que par le libellé de la cible.
Règles :
order_columnest optionnel.- Sa valeur doit être un identifiant SQL valide.
- Il doit être le nom d'un champ déclaré dans
pivot_fieldsde la même relation.
Exemple :
{
"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 }
],
"order_column": "position"
}
SQL généré (liste) :
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 pivot.position
Sans order_column, l'ordre reste par défaut sur le libellé de la cible (ORDER BY tag.name).
order_column ne modifie pas le SQL CREATE TABLE généré par sync:relations.
Limites actuelles¶
Le système de relations ne supporte pas encore directement :
one_to_manyexploité dans le CRUD généré ;- 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 relations many_to_one de relations.json, que la colonne FK soit un champ d'entité ou portée par la relation, 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 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.
La colonne FK n'a pas besoin d'être déclarée comme champ de l'entité source : quand la relation la porte (voir SQL généré), make:crud l'ajoute automatiquement au formulaire (le <select>) et au modèle (colonne présente dans INSERT et UPDATE), de sorte que la valeur choisie est bien persistée.
La liste et la fiche détail affichent le libellé de l'entité liée (pas l'identifiant brut) : SELECT_ALL et SELECT_BY_ID joignent la table cible pour exposer une colonne <fk>_label.
Pour appliquer le schéma des relations via une migration, forge migration:make <nom> --from-entity <Entite> --with-relations (ou --from-entities --with-relations) inclut le SQL des relations après les CREATE TABLE.
Comme relations.json (version 1 du format) 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 dans une version future du formatrelations.json;- 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.