data / sqlalchemy
Validation et hybrid properties
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
@validatesface à unUPDATESQL 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_propertydirectement dans unWHEREsans 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écanisme | Intercepte | Limite |
|---|---|---|
| Validation API (Pydantic...) | les données entrant par l'API | ne protège pas les scripts internes |
@validates (ORM) | toute assignation Python à l'attribut | n'intercepte pas un UPDATE SQL direct |
Contrainte SQL (CHECK) | tout accès, y compris hors ORM | message 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
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 ORMRésumé
@validates("champ")intercepte toute assignation à l'attribut, y compris via l'ORM (mais pas via unUPDATESQL brut).hybrid_propertyfournit une seule définition utilisable à la fois en Python (instance) et traduite en SQL (requête).hybrid_methodgénéralise ce principe à une méthode paramétrée.- En pratique : Pydantic valide les entrées HTTP en périphérie,
@validatesprotège l'intégrité au niveau du modèle ORM.
Exercices pratiques
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().