Retour au cours

data / sqlalchemy

Association proxy

Leçon 191 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi une relation many-to-many enrichie perd le confort de navigation directe
  • Restaurer une navigation simple (etudiant.cours) par-dessus une table intermédiaire avec association_proxy
  • Contrôler la création automatique de l'objet intermédiaire avec un creator= personnalisé
  • Connaître la limite du proxy : il simplifie les objets Python, pas les requêtes select()

Dans quel contexte ?

Une application de gestion scolaire modélise les inscriptions d'étudiants à des cours avec une note associée à chaque inscription. Cette information supplémentaire force à passer par un modèle intermédiaire Inscription explicite (leçon 3), ce qui oblige à écrire [i.cours for i in etudiant.inscriptions] partout où l'on veut simplement la liste des cours d'un étudiant. association_proxy restaure une écriture aussi simple que etudiant.cours, sans jamais exposer la table intermédiaire au code appelant.

D'abord, le prix payé pour une relation enrichie

La leçon 3 a montré que dès qu'une relation many-to-many porte des informations propres, comme une note ou une date, il faut passer par un modèle intermédiaire explicite plutôt qu'un simple secondary=. Le confort de la navigation directe (etudiant.cours) est alors perdu.

Le problème concret que ça pose

Sans rien de plus, il faut écrire [i.cours for i in etudiant.inscriptions] à chaque fois qu'on veut la liste des cours, ce qui expose un détail d'implémentation, la table Inscription, que le code métier ne devrait pas avoir à connaître.

La solution : association_proxy

association_proxy restaure la navigation directe par-dessus la table intermédiaire : etudiant.cours redevient une simple liste d'objets Cours, comme dans un many-to-many classique, sans que le code appelant ait besoin de connaître l'existence d'Inscription.

Sans association_proxyAvec association_proxy
[i.cours for i in etudiant.inscriptions]etudiant.cours
expose la table intermédiairecache la table intermédiaire

Une conséquence à connaître côté écriture

Ajouter un élément via le proxy (etudiant.cours.append(nouveau_cours)) crée automatiquement l'objet intermédiaire correspondant. Si cet objet a des colonnes obligatoires, comme une note par défaut, un creator= personnalisé permet de contrôler précisément comment il est construit.

Le saviez-vous ?

association_proxy est purement un confort d'écriture côté objets Python : il ne modifie pas les requêtes SQL possibles. Pour filtrer un select() sur la note d'une inscription (par exemple "tous les étudiants avec une note > 15"), il faut toujours passer par un JOIN explicite sur la table intermédiaire.

Une limite à garder en tête

Le proxy simplifie la lecture et l'écriture au niveau des objets Python, mais ne remplace pas les JOIN explicites nécessaires pour filtrer une requête select() sur la table intermédiaire : c'est un outil d'ergonomie du code, pas un raccourci pour les requêtes elles-mêmes.

Vers la suite

La dernière leçon du parcours revient sur un thème déjà croisé, la performance des écritures, en poussant les techniques d'insertion en masse jusqu'à leurs limites.

Commandes & code

Association proxy

python
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.associationproxy import association_proxy

# Table d'association AVEC colonnes supplémentaires (many-to-many enrichi)
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]

    etudiant: Mapped["Etudiant"] = relationship(back_populates="inscriptions")
    cours: Mapped["Cours"] = relationship(back_populates="inscriptions")

class Cours(Base):
    __tablename__ = "cours"
    id: Mapped[int] = mapped_column(primary_key=True)
    nom: Mapped[str]
    inscriptions: Mapped[list[Inscription]] = relationship(back_populates="cours")

class Etudiant(Base):
    __tablename__ = "etudiants"
    id: Mapped[int] = mapped_column(primary_key=True)
    nom: Mapped[str]
    inscriptions: Mapped[list[Inscription]] = relationship(back_populates="etudiant")

    # Sans proxy : [i.cours for i in etudiant.inscriptions] -- expose la table intermédiaire
    # Avec proxy : etudiant.cours -- navigation directe, comme un simple many-to-many
    cours: Mapped[list[Cours]] = association_proxy("inscriptions", "cours")

noms = [c.nom for c in etudiant.cours]     # aucune référence explicite à Inscription

# Ajouter via le proxy crée automatiquement l'objet intermédiaire (Inscription)
etudiant.cours.append(nouveau_cours)   # équivaut à Inscription(etudiant=etudiant, cours=nouveau_cours)
session.commit()

# Créateur personnalisé : contrôler la construction de l'objet intermédiaire
class Etudiant(Base):
    # ... colonnes ci-dessus ...
    cours = association_proxy(
        "inscriptions", "cours",
        creator=lambda cours_cible: Inscription(cours=cours_cible, note=None),
    )

# Proxy sur un attribut SCALAIRE à travers une relation one-to-many (pas seulement many-to-many)
class Tag(Base):
    __tablename__ = "tags"
    id: Mapped[int] = mapped_column(primary_key=True)
    libelle: Mapped[str]

class ArticleTag(Base):
    __tablename__ = "articles_tags"
    article_id: Mapped[int] = mapped_column(ForeignKey("articles.id"), primary_key=True)
    tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
    tag: Mapped[Tag] = relationship()

class Article(Base):
    __tablename__ = "articles"
    id: Mapped[int] = mapped_column(primary_key=True)
    titre: Mapped[str]
    articles_tags: Mapped[list[ArticleTag]] = relationship()
    libelles_tags: Mapped[list[str]] = association_proxy("articles_tags", "tag.libelle")
    # article.libelles_tags -> ["python", "sql"] directement, sans boucle manuelle

# Filtrer une requête à travers les tables intermédiaires (le proxy ne dispense pas des JOIN explicites)
from sqlalchemy import select
stmt = (
    select(Etudiant)
    .join(Etudiant.inscriptions)
    .join(Inscription.cours)
    .where(Cours.nom == "SQL avancé")
)

Résumé

  • association_proxy masque une table intermédiaire pour exposer une navigation directe (etudiant.cours au lieu de [i.cours for i in etudiant.inscriptions]).
  • Ajouter un élément via le proxy crée automatiquement l'objet intermédiaire ; creator= personnalise cette construction (utile si l'objet intermédiaire porte des colonnes obligatoires).
  • Un proxy peut cibler un attribut scalaire au bout d'une chaîne de relations ("tag.libelle"), pas seulement un objet complet.
  • Le proxy simplifie la LECTURE/écriture mais ne remplace pas les JOIN explicites nécessaires pour filtrer une requête select().

Exercices pratiques

1 disponible
1

Mission : une inscription qui plante en production mais pas en local

Objectif : Diagnostiquer un IntegrityError provoqué par un association_proxy sans creator, puis corriger la construction de l'objet intermédiaire et cerner les limites du proxy pour les requêtes filtrées.

Contexte

L'application de gestion scolaire utilise cours: Mapped[list[Cours]] = association_proxy("inscriptions", "cours") sur Etudiant, sans creator= personnalisé. La colonne note d'Inscription est déclarée NOT NULL en base PostgreSQL de production. En local, les tests tournent sur SQLite avec une base recréée sans jamais avoir migré cette contrainte, donc tout semble fonctionner. En production, etudiant.cours.append(nouveau_cours) fait planter le déploiement avec un IntegrityError sur note. Un autre développeur, en parallèle, essaie d'écrire select(Etudiant).where(Etudiant.cours == cours_cible) pour retrouver les étudiants inscrits à un cours donné, sans succès non plus.

Résoudre l’exercice →