data / sqlalchemy
Relations : one-to-many et many-to-many
Explication
Ce que vous allez apprendre
- Distinguer la clé étrangère (
ForeignKey, en base) de larelationship()(en Python) - Modéliser une relation one-to-many entre deux modèles
- Modéliser une relation many-to-many avec une table d'association (
secondary=) - Transformer une table d'association en modèle intermédiaire quand elle porte une donnée propre
- Utiliser
back_populatespour synchroniser les deux côtés d'une relation en mémoire
Dans quel contexte ?
Un développeur modélise un système de blog où un Auteur peut publier plusieurs Article, et où des Etudiant peuvent s'inscrire à plusieurs Cours (et réciproquement). Le premier cas se résout avec une simple clé étrangère, mais le second demande une structure différente puisque aucune des deux tables ne peut porter seule la clé de l'autre. Cette leçon distingue précisément ces deux situations.
D'abord, un lien qui existe à deux niveaux
Quand deux tables sont liées, comme un auteur qui écrit des articles, il y a en réalité deux mécanismes distincts qui travaillent ensemble. La clé étrangère (ForeignKey) est une contrainte réelle stockée en base de données. La relationship() est un lien purement Python, qui permet de naviguer entre objets en mémoire (auteur.articles) sans écrire de jointure SQL à la main.
Étape 1 : le cas le plus simple, one-to-many
Un auteur peut avoir plusieurs articles, mais un article n'a qu'un seul auteur. Il faut alors placer la clé étrangère du côté "plusieurs" : la table articles contient auteur_id, jamais l'inverse. C'est une règle universelle en modélisation relationnelle, pas une convention propre à SQLAlchemy.
Il reste un problème : et si les deux côtés ont plusieurs éléments ?
Si un étudiant peut suivre plusieurs cours ET qu'un cours accueille plusieurs étudiants, aucune des deux tables ne peut contenir une clé étrangère unique vers l'autre : une seule colonne ne suffit plus à représenter la relation.
Étape 2 : la table d'association
La solution est une table intermédiaire qui stocke chaque paire (étudiant, cours). SQLAlchemy peut la gérer de façon transparente avec secondary=, à condition que cette table n'ait pas besoin de colonnes propres.
| Type de relation | Où va la clé étrangère | Outil SQLAlchemy |
|---|---|---|
| One-to-many | Dans la table "plusieurs" | relationship() + ForeignKey |
| Many-to-many (sans donnée propre) | Table d'association pure | relationship(secondary=...) |
| Many-to-many (avec donnée propre) | Modèle intermédiaire explicite | Deux relationship() vers le modèle intermédiaire |
Une complication de plus : et si la relation porte une information ?
Dès que cette relation doit stocker une donnée à elle, comme une note ou une date d'inscription, une simple table d'association ne suffit plus : il faut la transformer en véritable modèle intermédiaire explicite, avec ses propres relations vers les deux tables.
Piège fréquent
Sans back_populates, modifier un objet d'un côté de la relation ne met pas automatiquement à jour l'autre côté en mémoire, ce qui peut créer des incohérences silencieuses tant que la session n'a pas été rechargée.
Piège fréquent
Ajouter article à auteur.articles.append(article) sans avoir déclaré back_populates="auteur" sur la relation articles laisse article.auteur à None en mémoire jusqu'au prochain rechargement depuis la base, ce qui peut provoquer des bugs difficiles à reproduire.
Bonne pratique
Déclare systématiquement back_populates des deux côtés d'une relation bidirectionnelle, même quand un seul sens semble utilisé au départ : cela évite des incohérences silencieuses le jour où le code évolue.
Vers la suite
Maintenant que des objets liés existent en mémoire, il faut comprendre comment SQLAlchemy décide quand et comment les envoyer réellement en base — le sujet de la prochaine leçon, sur la session.
Commandes & code
Relations
from sqlalchemy import ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
# --- One-to-many : un auteur a plusieurs articles ---
class Auteur(Base):
__tablename__ = "auteurs"
id: Mapped[int] = mapped_column(primary_key=True)
nom: Mapped[str] = mapped_column(String(100))
# "many" côté objet Python : liste d'Article, pas stockée en colonne
articles: Mapped[list["Article"]] = relationship(back_populates="auteur")
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(primary_key=True)
titre: Mapped[str] = mapped_column(String(200))
# clé étrangère réelle stockée en base
auteur_id: Mapped[int] = mapped_column(ForeignKey("auteurs.id"))
# relation "one" côté objet Python
auteur: Mapped["Auteur"] = relationship(back_populates="articles")
# Utilisation : back_populates synchronise les deux côtés en mémoire
auteur = Auteur(nom="Ada Lovelace")
article = Article(titre="Sur le calcul", auteur=auteur)
print(auteur.articles) # [Article(titre='Sur le calcul')] -- synchronisé automatiquement
# --- Many-to-many : des étudiants suivent plusieurs cours ---
# Table d'association pure (sans colonnes propres) : Table(), pas un modèle complet
etudiants_cours = Table(
"etudiants_cours",
Base.metadata,
Column("etudiant_id", ForeignKey("etudiants.id"), primary_key=True),
Column("cours_id", ForeignKey("cours.id"), primary_key=True),
)
class Etudiant(Base):
__tablename__ = "etudiants"
id: Mapped[int] = mapped_column(primary_key=True)
nom: Mapped[str] = mapped_column(String(100))
cours: Mapped[list["Cours"]] = relationship(
secondary=etudiants_cours, back_populates="etudiants"
)
class Cours(Base):
__tablename__ = "cours"
id: Mapped[int] = mapped_column(primary_key=True)
titre: Mapped[str] = mapped_column(String(200))
etudiants: Mapped[list["Etudiant"]] = relationship(
secondary=etudiants_cours, back_populates="cours"
)
# Many-to-many AVEC colonnes additionnelles : utiliser un modèle intermédiaire explicite
class Inscription(Base):
__tablename__ = "inscriptions"
etudiant_id: Mapped[int] = mapped_column(ForeignKey("etudiants.id"), primary_key=True)
cours_id: Mapped[int] = mapped_column(ForeignKey("cours.id"), primary_key=True)
note: Mapped[float | None] # colonne propre à la relation
date_inscription: Mapped[date] = mapped_column(default=date.today)
etudiant: Mapped["Etudiant"] = relationship(back_populates="inscriptions")
cours: Mapped["Cours"] = relationship(back_populates="inscriptions")
# One-to-one : uselist=False sur une relation one-to-many restreinte par une contrainte unique
class Profil(Base):
__tablename__ = "profils"
id: Mapped[int] = mapped_column(primary_key=True)
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), unique=True)
bio: Mapped[str | None] = mapped_column(Text)
utilisateur: Mapped["Utilisateur"] = relationship(back_populates="profil")
# Sur Utilisateur :
# profil: Mapped["Profil | None"] = relationship(back_populates="utilisateur", uselist=False)
# Cascade : que faire des enfants quand le parent est supprimé ?
class Auteur2(Base):
__tablename__ = "auteurs2"
id: Mapped[int] = mapped_column(primary_key=True)
articles: Mapped[list["Article"]] = relationship(
cascade="all, delete-orphan" # supprime les articles orphelins avec l'auteur
)Résumé
relationship()est un lien Python en mémoire ;ForeignKeyest la contrainte SQL réelle.back_populatessynchronise les deux côtés d'une relation à chaque affectation.- Many-to-many simple :
secondary=table_association. Avec colonnes propres : modèle intermédiaire explicite avec deuxrelationship. cascade="all, delete-orphan"supprime automatiquement les enfants détachés de leur parent.
Exercices pratiques
Mission : réparer une relation auteur-article incohérente en mémoire
Objectif : Diagnostiquer pourquoi article.auteur reste None après un ajout côté auteur, et corriger la relation avec back_populates.
Contexte
Un développeur écrit auteur.articles.append(article) pour lier un nouvel article à son auteur. Juste après, en mémoire (avant tout rechargement depuis la base), article.auteur vaut toujours None. Le modèle Article déclare bien auteur: Mapped["Auteur"] = relationship(), mais sans paramètre supplémentaire.