Retour au cours

data / sqlalchemy

Sessions et unit of work

Leçon 41 exercice

Explication

Ce que vous allez apprendre

  • Comprendre le rôle de la Session comme unit of work
  • Distinguer flush() (envoie le SQL) de commit() (valide la transaction)
  • Utiliser le dirty tracking : un attribut modifié génère un UPDATE automatique au commit
  • Annuler une transaction en cours avec rollback()
  • Mettre en place le pattern FastAPI standard : une session par requête HTTP via Depends

Dans quel contexte ?

Un développeur écrit un endpoint FastAPI qui met à jour le nom d'un utilisateur : il récupère l'objet avec session.get(Utilisateur, id), modifie u.nom, puis appelle session.commit(). Aucune ligne de SQL explicite n'apparaît dans son code, et pourtant un UPDATE précis part vers la base. Cette leçon explique le mécanisme invisible - le dirty tracking de la session - qui rend ça possible.

D'abord, rien n'a encore touché la base

Jusqu'ici, on a défini des modèles et des relations, mais aucune donnée n'a encore été réellement écrite en base de données. C'est le rôle de la Session : elle garde la trace de tous les objets créés, modifiés ou supprimés dans le code Python.

Étape 1 : accumuler plutôt qu'envoyer tout de suite

L'idée centrale s'appelle "unit of work". Plutôt que d'envoyer une requête SQL à chaque ligne de code — ce qui serait lent et risqué — la session accumule les changements en mémoire, un ajout ici, une modification là, un peu comme préparer une liste de courses complète avant d'aller au magasin.

Étape 2 : tout envoyer d'un coup, au bon moment

Ces changements accumulés ne partent vers la base qu'au moment du commit(), qui les regroupe et les envoie en une seule fois. C'est ce qui rend l'écriture en base prévisible et groupée, plutôt que dispersée ligne par ligne.

Un comportement qui surprend souvent les débutants

Quand on modifie un simple attribut Python sur un objet déjà chargé (u.nom = "Alice Dupont"), aucune requête SQL n'est envoyée immédiatement. La session détecte ce changement en silence — c'est le "dirty tracking" — et ne génère l'UPDATE correspondant qu'au commit().

Une distinction à ne pas rater : flush contre commit

flush() envoie le SQL vers la base sans valider la transaction, utile par exemple pour récupérer un id généré avant de continuer. commit(), lui, valide définitivement. Confondre les deux, ou oublier un rollback() après une erreur, est une source fréquente de bugs difficiles à diagnostiquer.

MéthodeEffetQuand l'utiliser
session.add(obj)Marque l'objet "pending"Avant tout flush/commit
session.flush()Envoie le SQL, transaction PAS validéeRécupérer un id généré avant de continuer
session.commit()Flush + valide définitivementFin d'une unité de travail cohérente
session.rollback()Annule tout ce qui n'a pas été committéAprès une erreur métier ou une exception

Piège fréquent

Oublier session.rollback() après une exception laisse la session dans un état "sale" (transaction ouverte, potentiellement invalide) pour les opérations suivantes sur cette même session, ce qui peut provoquer des erreurs en cascade difficiles à comprendre.

Bonne pratique

Utilise systématiquement with SessionLocal() as session: pour garantir la fermeture de la session même en cas d'exception, et enveloppe toute opération métier risquée dans un try/except qui appelle session.rollback() avant de relever l'erreur.

Vers la suite

Cette notion de transaction sera creusée en détail dans une leçon dédiée un peu plus loin. Pour l'instant, la prochaine étape logique est d'apprendre à interroger ces données une fois qu'elles sont bien en base.

Commandes & code

Sessions et unit of work

python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

engine = create_engine("postgresql+psycopg2://user:pass@localhost/technologik")
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)

# Pattern recommandé : context manager, fermeture garantie même en cas d'exception
with SessionLocal() as session:
    utilisateur = Utilisateur(email="alice@exemple.com", nom="Alice")
    session.add(utilisateur)          # marqué "pending" -- pas encore en base
    session.commit()                  # flush + COMMIT : écrit réellement en base
    print(utilisateur.id)             # l'id généré par la base est maintenant disponible

# Unit of work : la session traque les objets modifiés et génère le SQL au commit
with SessionLocal() as session:
    u = session.get(Utilisateur, 1)   # SELECT par clé primaire
    u.nom = "Alice Dupont"            # simple attribut Python modifié
    # AUCUN SQL exécuté ici -- la session détecte le changement (dirty tracking)
    session.commit()                  # génère UN SEUL UPDATE automatiquement

# add_all pour plusieurs objets
with SessionLocal() as session:
    session.add_all([
        Utilisateur(email="a@x.com", nom="A"),
        Utilisateur(email="b@x.com", nom="B"),
    ])
    session.commit()

# delete
with SessionLocal() as session:
    u = session.get(Utilisateur, 5)
    if u is not None:
        session.delete(u)
        session.commit()

# flush vs commit : flush envoie le SQL sans valider la transaction (utile pour obtenir un id)
with SessionLocal() as session:
    u = Utilisateur(email="c@x.com", nom="C")
    session.add(u)
    session.flush()          # INSERT envoyé, id disponible, transaction PAS encore validée
    print(u.id)
    session.commit()         # validation finale

# rollback explicite en cas d'erreur métier
with SessionLocal() as session:
    try:
        u = Utilisateur(email="d@x.com", nom="D")
        session.add(u)
        if not u.email.endswith("@entreprise.com"):
            raise ValueError("Domaine email non autorisé")
        session.commit()
    except ValueError:
        session.rollback()   # annule tout ce qui n'a pas été committé

# Dependency injection typique dans FastAPI
from fastapi import Depends
from collections.abc import Generator

def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()   # toujours fermer, même si une exception a été levée

# @app.get("/utilisateurs/{id}")
# def lire_utilisateur(id: int, db: Session = Depends(get_db)):
#     return db.get(Utilisateur, id)

# Identity map : dans une même session, un même id renvoie TOUJOURS le même objet Python
with SessionLocal() as session:
    u1 = session.get(Utilisateur, 1)
    u2 = session.get(Utilisateur, 1)
    assert u1 is u2   # identique en mémoire, pas de second SELECT nécessaire

Résumé

  • La Session est l'unit of work : elle traque les objets attachés et génère le SQL au commit()/flush().
  • commit() valide la transaction ; rollback() l'annule ; flush() envoie le SQL sans valider.
  • Le pattern FastAPI standard : une session par requête HTTP, créée et fermée via Depends.
  • L'identity map garantit qu'un même objet (même classe + même clé primaire) n'est chargé qu'une fois par session.

Exercices pratiques

1 disponible
1

Mission : diagnostiquer un UPDATE fantôme après un rollback oublié

Objectif : Comprendre pourquoi une modification en mémoire disparaît, puis corriger la gestion de session pour garantir un unique UPDATE fiable.

Contexte

Un endpoint FastAPI fait u = session.get(Utilisateur, 1), puis u.nom = "Alice Dupont", mais oublie d'appeler session.commit() avant la fin de la fonction. Un autre endpoint, juste après, attrape une IntegrityError sur un import et continue à utiliser la même session sans rollback() explicite.

Résoudre l’exercice →