Retour au cours

data / sqlalchemy

Event listeners : mapper events et session events

Leçon 161 exercice

Explication

Ce que vous allez apprendre

  • Brancher une règle automatiquement sur un modèle avec les mapper events (before_insert, before_update)
  • Réagir au cycle de vie de la session entière avec les session events (before_flush, after_commit)
  • Comprendre pourquoi un effet de bord irréversible (email) doit attendre after_commit
  • Compter ou chronométrer chaque requête SQL exécutée avec before_cursor_execute

Dans quel contexte ?

Une plateforme de vente en ligne doit générer automatiquement un slug URL à partir du nom de chaque produit créé, quel que soit l'endroit du code qui crée ce produit (formulaire d'admin, import CSV, script de seed). Répéter cette logique manuellement à chaque endroit serait fragile : un seul oubli casserait la garantie. Un event listener branché une seule fois sur le modèle Produit résout ce problème définitivement.

D'abord, le problème d'une règle répétée partout

Certaines actions doivent se produire systématiquement autour d'une opération sur la base : générer un slug avant chaque insertion, refuser un prix négatif avant chaque mise à jour. On pourrait répéter cette logique manuellement à chaque endroit du code qui crée un produit, mais c'est fragile : un seul endroit oublié casse la garantie.

La solution : brancher la logique une seule fois

Les event listeners permettent de brancher cette logique une seule fois, au niveau du modèle ou de la session, pour qu'elle s'applique partout automatiquement, sans dépendre de la discipline de chaque développeur.

Type d'événementPortéeExemple
Mapper events (before_insert...)un modèle précisgénérer un slug avant insertion
Session events (before_flush...)tous les objets en attentevalidation transversale
after_commitaprès validation définitiveenvoyer un email, notifier un webhook

Étape 1 : réagir aux instances d'un modèle précis

Les "mapper events" (before_insert, before_update, after_delete) réagissent aux instances d'un modèle précis, quel que soit l'endroit du code qui les manipule.

Étape 2 : réagir au cycle de vie de la session entière

Les "session events" (before_flush, after_commit) réagissent au cycle de vie de la session elle-même, et permettent une validation transversale sur tous les objets en attente, pas seulement un modèle donné.

Un piège temporel à bien comprendre

Avant after_commit, un ROLLBACK reste toujours possible. Déclencher un effet externe irréversible, comme un email, trop tôt dans le cycle de vie risque de notifier une action qui, finalement, n'a jamais été validée en base.

Piège fréquent

Envoyer un email de confirmation dans un listener after_insert ou before_commit est risqué : si la transaction est annulée juste après (une autre erreur dans le même bloc), l'email aura déjà été envoyé pour une donnée qui n'existe finalement pas en base. Réservez les effets de bord irréversibles à after_commit, le seul moment garanti définitif.

La règle à retenir

after_commit est le seul point garanti sûr pour ce type d'effet de bord, jamais avant.

Pour aller plus loin

Écouter before_cursor_execute au niveau du moteur permet de compter ou chronométrer chaque requête SQL réellement exécutée, une technique précieuse pour détecter un problème N+1 directement dans les tests automatisés.

Vers la suite

Après avoir vu comment réagir automatiquement à des événements, la prochaine leçon montre comment créer son propre type de colonne sur mesure, pour des besoins qu'aucun type SQL natif ne couvre.

Commandes & code

Event listeners

python
from sqlalchemy import event
from sqlalchemy.orm import Session, Mapped, mapped_column
from datetime import datetime

class Produit(Base):
    __tablename__ = "produits"
    id: Mapped[int] = mapped_column(primary_key=True)
    nom: Mapped[str]
    prix: Mapped[float]
    slug: Mapped[str] = mapped_column(default="")

# --- Mapper events : réagissent aux changements sur les INSTANCES d'un modèle précis ---

# before_insert : dernière chance de modifier l'objet avant le INSERT physique
@event.listens_for(Produit, "before_insert")
def generer_slug(mapper, connection, target: Produit):
    target.slug = target.nom.lower().replace(" ", "-")

# before_update : valider/normaliser avant chaque UPDATE
@event.listens_for(Produit, "before_update")
def empecher_prix_negatif(mapper, connection, target: Produit):
    if target.prix < 0:
        raise ValueError(f"Prix négatif refusé pour {target.nom!r}")

# after_delete : effet de bord APRÈS écriture en base (audit, journalisation)
@event.listens_for(Produit, "after_delete")
def logger_suppression(mapper, connection, target: Produit):
    connection.execute(
        audit_table.insert().values(action="delete", produit_id=target.id, quand=datetime.utcnow())
    )

# --- Session events : réagissent au cycle de vie de la SESSION, pas d'un modèle précis ---

@event.listens_for(Session, "before_flush")
def valider_avant_flush(session: Session, flush_context, instances):
    # Parcourt tous les objets en attente d'écriture pour une validation transversale
    for obj in session.new:                      # objets sur le point d'être insérés
        if isinstance(obj, Produit) and not obj.nom.strip():
            raise ValueError("Un produit doit avoir un nom")

@event.listens_for(Session, "after_commit")
def notifier_apres_commit(session: Session):
    # Sûr d'envoyer une notification ici : les données sont réellement persistées sur disque
    print("Transaction validée, notification envoyée")

# --- Event au niveau connexion (DBAPI) : mesurer/loguer chaque requête SQL brute ---
compteur = {"requetes": 0}

@event.listens_for(engine, "before_cursor_execute")
def compter_requetes(conn, cursor, statement, parameters, context, executemany):
    compteur["requetes"] += 1
    # Utile en test pour détecter un N+1 : assert compteur["requetes"] <= 2 après une requête eager-load

@event.listens_for(engine, "after_cursor_execute")
def logger_requetes_lentes(conn, cursor, statement, parameters, context, executemany):
    # En pratique : combiner avant/après avec time.perf_counter() stocké dans context.info
    # pour ne loguer que les requêtes dépassant un seuil (ex: > 100ms)
    pass

# Retirer un listener proprement (utile en tests, pour isoler les effets de bord entre tests)
event.remove(Produit, "before_insert", generer_slug)

Résumé

  • Les mapper events (before_insert, before_update, after_delete, ...) réagissent aux instances d'un modèle précis, indépendamment de la session qui les manipule.
  • Les session events (before_flush, after_commit, ...) permettent une validation ou des effets de bord transversaux, sur tous les objets en attente.
  • after_commit est le seul moment sûr pour déclencher un effet externe irréversible (email, webhook) : avant, un ROLLBACK reste possible.
  • Les events au niveau engine (before_cursor_execute) donnent accès au SQL brut, utiles pour instrumenter ou détecter des N+1 en test.
  • event.remove(...) détache un listener, essentiel pour isoler des tests qui ne doivent pas hériter d'effets de bord globaux.

Exercices pratiques

1 disponible
1

Mission : un email de confirmation envoyé pour une commande jamais validée

Objectif : Diagnostiquer pourquoi un email a été envoyé pour une transaction finalement annulée, et déplacer l'effet de bord au bon moment du cycle de vie.

Contexte

Un listener @event.listens_for(Commande, "after_insert") envoie un email de confirmation dès qu'une Commande est insérée en base. Un client se plaint d'avoir reçu un email de confirmation pour une commande qui n'apparaît finalement jamais dans son historique : une erreur métier plus loin dans le même bloc a déclenché un rollback() après l'insertion.

Résoudre l’exercice →