Retour au cours

data / sqlalchemy

Validation et hybrid properties

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi la validation des données doit exister à plusieurs niveaux, avec des rôles différents
  • Intercepter toute assignation à un attribut avec @validates, quel que soit son chemin d'entrée
  • Connaître la limite de @validates face à un UPDATE SQL exécuté hors de l'ORM
  • Définir une même formule de calcul en Python ET en SQL avec hybrid_property
  • Utiliser une hybrid_property directement dans un WHERE sans charger toutes les lignes

Dans quel contexte ?

Une plateforme e-commerce doit garantir qu'un email d'utilisateur est toujours au bon format et qu'un prix TTC (calculé à partir du prix HT et d'un taux de TVA) reste cohérent, que l'utilisateur passe par le formulaire d'inscription, un script d'import en masse, ou une correction manuelle en base. Cette leçon montre comment poser des garde-fous à la fois côté Python (validation) et côté SQL (formule réutilisable).

D'abord, une question à se poser

Une application a généralement plusieurs "portes d'entrée" pour les données : une API HTTP, un import de fichier, un script interne. Où doit-on garantir qu'une donnée est valide, comme un email au bon format ou un âge positif ?

La réponse : à plusieurs endroits, avec des rôles différents

Il n'existe pas UN seul bon endroit. La stratégie la plus robuste combine une validation en amont, proche de l'utilisateur, et une protection plus profonde, proche des données.

MécanismeIntercepteLimite
Validation API (Pydantic...)les données entrant par l'APIne protège pas les scripts internes
@validates (ORM)toute assignation Python à l'attributn'intercepte pas un UPDATE SQL direct
Contrainte SQL (CHECK)tout accès, y compris hors ORMmessage d'erreur moins lisible

Étape 1 : un dernier rempart au niveau du modèle

Le décorateur @validates intercepte toute assignation à un attribut, quel que soit le chemin par lequel elle arrive dans le code Python — formulaire, script, import. C'est une garantie de dernier recours au niveau de l'ORM : même si la validation en amont a été oubliée ou contournée, cette protection reste active.

Une limite à connaître pour ce rempart

@validates ne remplace pas une validation plus riche faite en amont, car elle n'intercepte pas un UPDATE SQL exécuté directement, en dehors de l'ORM.

Piège fréquent

@validates ne protège que le chemin qui passe par l'ORM Python. Un UPDATE utilisateurs SET email = '...' exécuté en SQL brut (script de maintenance, requête manuelle) contourne totalement cette validation. Pour une garantie absolue, ajoutez aussi une contrainte CHECK au niveau de la base.

Il reste un autre problème : calculer une valeur, deux fois

Une propriété Python classique (@property) ne fonctionne qu'une fois l'objet chargé en mémoire : impossible de l'utiliser dans un WHERE SQL sans charger toutes les lignes d'abord.

Étape 2 : une seule formule, deux usages

hybrid_property résout ce problème en définissant la même formule de calcul, par exemple un prix TTC, sous deux formes : une version Python pour une instance déjà chargée, une version SQL utilisable directement dans une requête. Cela évite de maintenir deux implémentations séparées.

Vers la suite

Une fois les données validées et bien modélisées, la prochaine leçon aborde une question différente mais liée : comment tester tout ce code sans dépendre d'une vraie base de données à chaque exécution.

Commandes & code

Validation et hybrid properties

python
from sqlalchemy.orm import validates, Mapped, mapped_column
from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method
from sqlalchemy import select, and_

# @validates : valide/transforme une valeur AVANT qu'elle soit assignée à l'attribut
class Utilisateur(Base):
    __tablename__ = "utilisateurs_v2"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255))
    age: Mapped[int | None]

    @validates("email")
    def valider_email(self, cle: str, valeur: str) -> str:
        if "@" not in valeur:
            raise ValueError(f"Email invalide : {valeur}")
        return valeur.strip().lower()   # normalisation systématique

    @validates("age")
    def valider_age(self, cle: str, valeur: int | None) -> int | None:
        if valeur is not None and valeur < 0:
            raise ValueError("L'âge ne peut pas être négatif")
        return valeur

u = Utilisateur(email="  Jean@Exemple.COM  ", age=30)
print(u.email)  # "jean@exemple.com" -- normalisé automatiquement

# hybrid_property : fonctionne à la fois en Python (instance) ET en SQL (au niveau classe)
class Produit(Base):
    __tablename__ = "produits_v2"
    id: Mapped[int] = mapped_column(primary_key=True)
    prix_ht: Mapped[float]
    taux_tva: Mapped[float] = mapped_column(default=20.0)

    @hybrid_property
    def prix_ttc(self) -> float:
        # Accès Python : produit.prix_ttc sur une instance déjà chargée
        return self.prix_ht * (1 + self.taux_tva / 100)

    @prix_ttc.expression
    def prix_ttc(cls):
        # Accès SQL : utilisable dans un WHERE/ORDER BY, traduit en expression SQL
        return cls.prix_ht * (1 + cls.taux_tva / 100)

p = Produit(prix_ht=100, taux_tva=20)
print(p.prix_ttc)  # 120.0 -- calculé en Python

with SessionLocal() as session:
    # utilisé directement dans une requête -- traduit en SQL, PAS chargé puis filtré en Python
    stmt = select(Produit).where(Produit.prix_ttc > 100).order_by(Produit.prix_ttc.desc())
    resultats = session.scalars(stmt).all()

# hybrid_method : version paramétrable d'une hybrid_property
class Commande(Base):
    __tablename__ = "commandes_v2"
    id: Mapped[int] = mapped_column(primary_key=True)
    montant: Mapped[float]
    cree_le: Mapped[datetime]

    @hybrid_method
    def est_recente(self, jours: int = 30) -> bool:
        return (datetime.utcnow() - self.cree_le).days <= jours

    @est_recente.expression
    def est_recente(cls, jours: int = 30):
        from sqlalchemy import func
        return cls.cree_le >= func.now() - func.make_interval(days=jours)

# Validation au niveau applicatif avec Pydantic AVANT de toucher l'ORM (couche schemas/)
from pydantic import BaseModel, EmailStr, field_validator

class UtilisateurCreate(BaseModel):
    email: EmailStr
    age: int

    @field_validator("age")
    @classmethod
    def age_valide(cls, v: int) -> int:
        if v < 0 or v > 150:
            raise ValueError("Âge hors limites plausibles")
        return v
# Pattern recommandé : Pydantic valide l'entrée HTTP, @validates protège l'intégrité au niveau ORM

Résumé

  • @validates("champ") intercepte toute assignation à l'attribut, y compris via l'ORM (mais pas via un UPDATE SQL brut).
  • hybrid_property fournit une seule définition utilisable à la fois en Python (instance) et traduite en SQL (requête).
  • hybrid_method généralise ce principe à une méthode paramétrée.
  • En pratique : Pydantic valide les entrées HTTP en périphérie, @validates protège l'intégrité au niveau du modèle ORM.

Exercices pratiques

1 disponible
1

Mission : un script de maintenance qui contourne la validation des emails

Objectif : Comprendre la limite de @validates face à un UPDATE SQL brut, et exposer une hybrid_property filtrable directement en SQL.

Contexte

Le modèle Utilisateur a un @validates("email") qui refuse tout email sans @. Un script de maintenance exécute pourtant UPDATE utilisateurs SET email = 'invalide' directement en SQL brut lors d'une correction de données, et ça fonctionne sans la moindre erreur. Par ailleurs, l'équipe veut aussi pouvoir filtrer les produits par prix_ttc directement dans une requête select().

Résoudre l’exercice →