data / sqlalchemy
Jointures avec l'ORM
Explication
Ce que vous allez apprendre
- Écrire une jointure ORM à partir d'une
relationship()déjà déclarée - Écrire une jointure explicite quand aucune relation n'existe entre deux modèles
- Utiliser
outerjoin()pour ne pas perdre les lignes sans correspondance - Joindre une table à elle-même avec
aliased()(self-join) - Réutiliser un
JOINdéjà présent pour peupler une relation aveccontains_eager
Dans quel contexte ?
Un développeur doit produire un rapport listant chaque client avec son nombre total de commandes, y compris les clients qui n'ont jamais rien commandé. Un join() classique (INNER JOIN) ferait disparaître ces clients sans commande du rapport. Cette leçon explique pourquoi outerjoin() est nécessaire ici, et comment construire des jointures ORM correctement dans plusieurs scénarios.
D'abord, le problème que la jointure résout
Sans jointure, obtenir "le nom de l'auteur de chaque article" demanderait une requête séparée pour chaque article — lent et inefficace. Une jointure combine les lignes de plusieurs tables en une seule requête, en s'appuyant sur une correspondance entre colonnes, typiquement une clé étrangère.
Étape 1 : réutiliser une relation déjà connue
Si une relationship() existe déjà entre deux modèles (vue à la leçon 3), join(Modele.relation) la réutilise directement : SQLAlchemy sait déjà comment les deux tables se rejoignent, sans qu'on ait à réécrire la condition.
Il reste un cas : pas de relation déclarée
Parfois, aucune relation n'a été déclarée entre deux modèles, ou la jointure ne suit pas la clé étrangère naturelle. Dans ce cas, join(Cible, condition) permet d'écrire la condition de jointure explicitement, colonne par colonne.
Un piège fréquent : perdre des lignes sans s'en rendre compte
Un join() classique (INNER JOIN) exclut les lignes qui n'ont pas de correspondance de l'autre côté : un auteur sans aucun article disparaîtrait purement et simplement du résultat.
| Méthode ORM | Équivalent SQL | Perd les lignes sans correspondance ? |
|---|---|---|
.join(Modele.relation) | INNER JOIN | Oui |
.join(Cible, condition) | INNER JOIN explicite | Oui |
.outerjoin(...) | LEFT OUTER JOIN | Non |
aliased(Modele) + .join() | Self-join | Selon la condition |
La solution : garder ce qui n'a pas de correspondance
outerjoin() (LEFT JOIN) conserve ces lignes, avec des valeurs nulles côté manquant. Utiliser un INNER JOIN par réflexe, sans y penser, fait disparaître silencieusement des données légitimes d'un rapport ou d'un comptage.
Piège fréquent
select(Auteur.nom, func.count(Article.id)).join(Article, ...).group_by(Auteur.id) (avec un join simple) omet silencieusement tous les auteurs sans aucun article du rapport, alors qu'un rapport correct devrait normalement les afficher avec un compte de zéro. Il faut outerjoin() pour ce cas précis.
Un cas particulier à connaître : joindre une table à elle-même
Joindre une table à elle-même, comme un employé et son manager tous deux dans la table employes, nécessite un alias explicite via aliased() : sans lui, SQL ne peut pas distinguer les deux occurrences de la même table.
Bonne pratique
Si une requête joint déjà une relation nécessaire à l'affichage (par exemple .join(Article.auteur) avec un filtre sur Auteur.nom), ajoute .options(contains_eager(Article.auteur)) pour réutiliser ce JOIN et peupler article.auteur sans déclencher une requête supplémentaire.
Vers la suite
Une fois les données correctement jointes, la question suivante est de savoir QUAND SQLAlchemy va réellement charger les relations associées — le sujet de la prochaine leçon, sur l'eager et le lazy loading.
Commandes & code
Jointures avec l'ORM
from sqlalchemy import select, func
with SessionLocal() as session:
# join() implicite via une relationship() déjà déclarée
stmt = (
select(Article)
.join(Article.auteur)
.where(Auteur.nom == "Ada Lovelace")
)
articles = session.scalars(stmt).all()
# join() explicite avec la condition ON, sans relationship() préalable
stmt = (
select(Article.titre, Auteur.nom)
.join(Auteur, Auteur.id == Article.auteur_id)
)
for titre, nom in session.execute(stmt):
print(titre, nom)
# LEFT JOIN (outer join) : garder les auteurs même sans article
stmt = (
select(Auteur.nom, func.count(Article.id).label("nb_articles"))
.outerjoin(Article, Article.auteur_id == Auteur.id)
.group_by(Auteur.id, Auteur.nom)
)
for nom, nb in session.execute(stmt):
print(nom, nb)
# Jointure sur plusieurs tables
stmt = (
select(Commande.id, Client.nom, Produit.nom)
.join(Client, Client.id == Commande.client_id)
.join(LigneCommande, LigneCommande.commande_id == Commande.id)
.join(Produit, Produit.id == LigneCommande.produit_id)
.where(Commande.montant > 100)
)
# Self-join (ex: employés et managers) : nécessite un alias explicite
from sqlalchemy.orm import aliased
Manager = aliased(Employe)
stmt = (
select(Employe.nom, Manager.nom)
.join(Manager, Employe.manager_id == Manager.id)
)
# Agrégation après jointure : total dépensé par client
stmt = (
select(Client.nom, func.sum(Commande.montant).label("total"))
.join(Commande, Commande.client_id == Client.id)
.group_by(Client.id, Client.nom)
.having(func.sum(Commande.montant) > 1000)
.order_by(func.sum(Commande.montant).desc())
)
for nom, total in session.execute(stmt):
print(nom, total)
# contains_eager : charger une relation déjà jointe SANS requête supplémentaire
from sqlalchemy.orm import contains_eager
stmt = (
select(Article)
.join(Article.auteur)
.where(Auteur.nom.like("A%"))
.options(contains_eager(Article.auteur)) # réutilise le JOIN déjà fait
)
for article in session.scalars(stmt):
print(article.titre, article.auteur.nom) # pas de requête additionnelleRésumé
join(Modele.relation)s'appuie sur unerelationship()déjà déclarée ;join(Cible, condition)est explicite.outerjoin()correspond auLEFT OUTER JOINSQL, utile pour compter y compris les entités sans lien.aliased()est indispensable pour un self-join ou pour joindre deux fois la même table.contains_eager()réutilise un JOIN déjà présent dans la requête pour peupler une relation sans requête additionnelle.
Exercices pratiques
Mission : un rapport clients qui fait disparaître les clients sans commande
Objectif : Diagnostiquer pourquoi un rapport utilisant join() omet des clients, puis corriger avec outerjoin().
Contexte
Un rapport de vente exécute select(Client.nom, func.count(Commande.id)).join(Commande, Commande.client_id == Client.id).group_by(Client.id) pour compter les commandes par client. Le service commercial signale que plusieurs clients récemment créés, qui n'ont encore jamais commandé, n'apparaissent jamais dans ce rapport, alors qu'ils devraient s'afficher avec un compte de zéro.