data / sqlalchemy
Sessions et unit of work
Explication
Ce que vous allez apprendre
- Comprendre le rôle de la
Sessioncomme unit of work - Distinguer
flush()(envoie le SQL) decommit()(valide la transaction) - Utiliser le dirty tracking : un attribut modifié génère un
UPDATEautomatique 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éthode | Effet | Quand l'utiliser |
|---|---|---|
session.add(obj) | Marque l'objet "pending" | Avant tout flush/commit |
session.flush() | Envoie le SQL, transaction PAS validée | Récupérer un id généré avant de continuer |
session.commit() | Flush + valide définitivement | Fin 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
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écessaireRésumé
- La
Sessionest l'unit of work : elle traque les objets attachés et génère le SQL aucommit()/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
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.