Retour au cours

data / sqlalchemy

Jointures avec l'ORM

Leçon 61 exercice

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 JOIN déjà présent pour peupler une relation avec contains_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 SQLPerd les lignes sans correspondance ?
.join(Modele.relation)INNER JOINOui
.join(Cible, condition)INNER JOIN expliciteOui
.outerjoin(...)LEFT OUTER JOINNon
aliased(Modele) + .join()Self-joinSelon 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

python
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 additionnelle

Résumé

  • join(Modele.relation) s'appuie sur une relationship() déjà déclarée ; join(Cible, condition) est explicite.
  • outerjoin() correspond au LEFT OUTER JOIN SQL, 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

1 disponible
1

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.

Résoudre l’exercice →