Retour au cours

data / sqlalchemy

Relations : one-to-many et many-to-many

Leçon 31 exercice

Explication

Ce que vous allez apprendre

  • Distinguer la clé étrangère (ForeignKey, en base) de la relationship() (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_populates pour 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 relationOù va la clé étrangèreOutil SQLAlchemy
One-to-manyDans la table "plusieurs"relationship() + ForeignKey
Many-to-many (sans donnée propre)Table d'association purerelationship(secondary=...)
Many-to-many (avec donnée propre)Modèle intermédiaire expliciteDeux 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

python
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 ; ForeignKey est la contrainte SQL réelle.
  • back_populates synchronise 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 deux relationship.
  • cascade="all, delete-orphan" supprime automatiquement les enfants détachés de leur parent.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →