Relations entre tables¶
Objectif : lire des données réparties sur deux tables liées, combinées par une jointure SQL.
Ce que vous allez apprendre : relier deux tables par une clé étrangère (articles.category_id vers categories.id) et les lire ensemble avec un JOIN SQL visible. Forge ne masque pas ces relations derrière un ORM : le SQL reste sous vos yeux.
Un mini-projet autonome
Le niveau avancé est un tutoriel continu : vous construisez à la main un seul petit projet, le « Catalogue d'articles », qui grandit palier après palier.
C'est un projet indépendant des niveaux précédents.
Vous écrivez le code vous-même, à la main, sans génération automatique.
Démarrer le niveau¶
Créez un projet neuf et placez-vous dedans :
Une base MariaDB est nécessaire dès ce palier.
Voir Préparer MariaDB si ce n'est pas déjà fait.
Documentations
Pour bien comprendre ce palier :
| Document | Ce qu'il apporte |
|---|---|
| Migrations SQL | créer les tables liées et appliquer la migration |
Migrations
Avant la première migration, initialisez la base de données (une seule fois pour ce mini-projet) :
forge db:init prépare la base et la table forge_migrations, qui enregistre les migrations déjà appliquées.
Sur un backend sans serveur comme SQLite, il crée directement le fichier.
Sur un backend serveur, il affiche le SQL de provisioning, base et comptes, à exécuter dans une session d'administration, ou l'exécute avec --run (ADR-067).
Sans cette étape, forge migration:apply n'a ni base ni table de suivi et échoue.
Générez ensuite le fichier de migration ; Forge l'horodate pour vous :
Forge crée mvc/migrations/<timestamp>_create_catalogue.sql ; ouvrez-le et placez-y le SQL suivant.
La table parente (categories) est créée avant l'enfant (articles) : la contrainte l'exige.
CREATE TABLE IF NOT EXISTS categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
article_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_articles_category
FOREIGN KEY (category_id) REFERENCES categories (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO categories (name, article_count)
SELECT seed.name, seed.article_count FROM (
SELECT 'Tutoriels' AS name, 1 AS article_count
UNION ALL SELECT 'Références', 0
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM categories);
INSERT INTO articles (title, category_id)
SELECT 'Bien démarrer avec Forge', c.id FROM categories c
WHERE c.name = 'Tutoriels'
AND NOT EXISTS (SELECT 1 FROM articles);
La contrainte FOREIGN KEY (category_id) REFERENCES categories (id) garantit qu'un article pointe toujours vers une catégorie qui existe.
Appliquez la migration avec forge migration:apply avant de tester.
Contrôleurs
Créez le fichier mvc/controllers/article_controller.py :
# mvc/controllers/article_controller.py
from core.database.db import fetch_all
from core.http.request import Request
from core.http.response import Response
from core.mvc.controller.base_controller import BaseController
SELECT_ARTICLES_WITH_CATEGORY = (
"SELECT a.id, a.title, c.name AS category "
"FROM articles a "
"JOIN categories c ON c.id = a.category_id "
"ORDER BY a.id"
)
class ArticleController(BaseController):
@staticmethod
def index(request: Request) -> Response:
articles = fetch_all(SELECT_ARTICLES_WITH_CATEGORY)
return BaseController.render(
"article/index.html",
request=request,
context={"articles": articles},
)
| Élément | Rôle |
|---|---|
JOIN … ON c.id = a.category_id |
Relie chaque article à sa catégorie en une seule requête. |
c.name AS category |
Ramène le nom de la catégorie dans chaque ligne, sans seconde requête. |
Le SQL reste lisible et explicite : c'est tout l'esprit de Forge, sans ORM.
Vues
Créez la vue mvc/views/article/index.html :
<!-- mvc/views/article/index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Catalogue d'articles</title>
</head>
<body>
<h1>Articles</h1>
{% if articles %}
<ul>
{% for a in articles %}
<li>#{{ a.id }} : {{ a.title }} <em>({{ a.category }})</em></li>
{% endfor %}
</ul>
{% else %}
<p>Aucun article.</p>
{% endif %}
</body>
</html>
Routes
Ajoutez l'import du contrôleur et la route /article dans mvc/routes/__init__.py :
# mvc/routes/__init__.py
from core.http.router import Router
from mvc.controllers.home_controller import HomeController
from mvc.controllers.article_controller import ArticleController
router = Router()
with router.group("", public=True) as public:
public.add("GET", "/", HomeController.index, name="home-index")
public.add("GET", "/article", ArticleController.index, name="article-index")
Tests
| URL | Résultat |
|---|---|
https://localhost:8000/article |
l'article de départ avec sa catégorie entre parenthèses |
À retenir
- Une clé étrangère relie deux tables et garantit la cohérence.
- Un
JOINlit les données reliées en une seule requête, SQL visible. - La table parente se crée avant l'enfant.
Au palier suivant, nous créons un article par une écriture transactionnelle.