data / sqlalchemy
Association proxy
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 avecassociation_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_proxy | Avec association_proxy |
|---|---|
[i.cours for i in etudiant.inscriptions] | etudiant.cours |
| expose la table intermédiaire | cache 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
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_proxymasque une table intermédiaire pour exposer une navigation directe (etudiant.coursau 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
JOINexplicites nécessaires pour filtrer une requêteselect().
Exercices pratiques
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.