Retour au cours

data / sqlalchemy

Types de colonnes et options avancées

Leçon 21 exercice

Explication

Ce que vous allez apprendre

  • Choisir Numeric/Decimal plutôt que Float pour toute valeur monétaire
  • Contraindre un ensemble de valeurs possibles avec un Enum Python mappé en SQL
  • Stocker du semi-structuré avec JSON en connaissant ses limites de filtrage
  • Factoriser une configuration de colonne répétitive avec Annotated
  • Définir une propriété calculée en Python qui n'est pas stockée en base

Dans quel contexte ?

Un développeur ajoute une colonne montant à un modèle Commande et hésite entre Float et Numeric. Après quelques milliers de commandes, un rapprochement comptable révèle un écart de quelques centimes entre le total calculé par l'application et celui du relevé bancaire. Cette leçon explique pourquoi ce genre d'écart apparaît et comment bien choisir le type de chaque colonne dès la conception du modèle.

D'abord, un choix qui semble anodin

Quand on définit une colonne, on ne fait pas que "stocker une valeur" : on choisit une représentation qui a des conséquences concrètes sur la précision et la validité des données. Un mauvais choix de type reste souvent invisible... jusqu'à ce que la table se remplisse de vraies données.

Étape 1 : le piège classique de l'argent

Imagine que tu stockes un prix dans un float. Les nombres à virgule flottante utilisent une représentation binaire qui ne peut pas représenter exactement certaines valeurs décimales, comme 0.1. Sur des calculs répétés (des milliers de factures), ces micro-erreurs s'accumulent et peuvent réellement fausser un total.

La solution : Numeric/Decimal

Numeric (associé à Decimal côté Python) stocke la valeur exacte, chiffre par chiffre, sans approximation binaire. C'est pourquoi toute valeur monétaire doit systématiquement passer par ce type — jamais par un float, quelle que soit la tentation de simplicité.

Piège fréquent

montant: Mapped[float] = mapped_column(Float) sur une colonne de prix semble fonctionner en test, mais après des milliers de lignes, sum(commande.montant for commande in commandes) peut diverger de quelques centimes du total réellement dû, un bug très difficile à détecter a posteriori.

Étape 2 : contraindre les valeurs possibles

Une simple chaîne de caractères pour un statut de commande ("payee", "annulee"...) n'empêche personne d'écrire une faute de frappe qui passera inaperçue jusqu'au bug en production. Un Enum Python mappé en type SQL résout ce problème directement : il empêche physiquement la base d'accepter une valeur hors de la liste autorisée.

Type SQLAlchemyCas d'usageÀ éviter pour
Numeric / DecimalArgent, valeurs exactes-
FloatCalculs scientifiques approximatifsMontants monétaires
SAEnum(MonEnum)Statuts, catégories ferméesValeurs libres
JSONMétadonnées semi-structuréesDonnées filtrées/indexées finement

Étape 3 : accepter que tout ne rentre pas dans une colonne stricte

À l'inverse, certaines données sont naturellement changeantes ou semi-structurées, comme des métadonnées variables selon le client. JSON permet de les stocker sans définir de colonnes rigides, au prix de requêtes plus difficiles à filtrer et indexer efficacement — un compromis à choisir consciemment, jamais par défaut.

Pour aller plus loin : ne pas se répéter

Une fois plusieurs modèles définis, on remarque vite les mêmes options qui reviennent, comme une clé primaire entière ou une chaîne de 100 caractères. Les types Annotated de Python permettent de définir ce "type de colonne réutilisable" une seule fois, et de l'appliquer partout.

Bonne pratique

Dès qu'une combinaison Mapped[...] = mapped_column(...) se répète dans plus de deux ou trois modèles (comme une clé primaire ou une chaîne bornée courante), factorise-la avec Annotated (IntPK = Annotated[int, mapped_column(primary_key=True)]) pour éviter les incohérences de copier-coller.

Vers la suite

Maintenant que chaque colonne est bien typée, l'étape suivante consiste à relier plusieurs tables entre elles avec de vraies relations — le sujet de la prochaine leçon.

Commandes & code

Types de colonnes

python
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import String, Text, Numeric, JSON, Enum as SAEnum, LargeBinary
from sqlalchemy.orm import Mapped, mapped_column
from enum import Enum as PyEnum

class StatutCommande(PyEnum):
    EN_ATTENTE = "en_attente"
    PAYEE = "payee"
    EXPEDIEE = "expediee"
    ANNULEE = "annulee"

class Commande(Base):
    __tablename__ = "commandes"

    id: Mapped[int] = mapped_column(primary_key=True)

    # VARCHAR borné vs TEXT illimité
    reference: Mapped[str] = mapped_column(String(20), unique=True)
    notes: Mapped[str | None] = mapped_column(Text)

    # Decimal pour l'argent : jamais float (imprécision binaire)
    montant: Mapped[Decimal] = mapped_column(Numeric(10, 2))

    # Enum Python mappé en type SQL natif (ou VARCHAR + CHECK selon le SGBD)
    statut: Mapped[StatutCommande] = mapped_column(
        SAEnum(StatutCommande), default=StatutCommande.EN_ATTENTE
    )

    # Dates/heures
    date_livraison_prevue: Mapped[date | None]
    cree_le: Mapped[datetime] = mapped_column(default=datetime.utcnow)

    # JSON semi-structuré (fonctionne sur PostgreSQL, MySQL, SQLite récents)
    metadata_client: Mapped[dict | None] = mapped_column(JSON)

    # Données binaires (rare, ex: petite pièce jointe)
    signature: Mapped[bytes | None] = mapped_column(LargeBinary)

# Colonne calculée en Python (property, pas stockée en base)
class Produit(Base):
    __tablename__ = "produits"

    id: Mapped[int] = mapped_column(primary_key=True)
    prix_ht: Mapped[Decimal] = mapped_column(Numeric(10, 2))
    taux_tva: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("20.00"))

    @property
    def prix_ttc(self) -> Decimal:
        return self.prix_ht * (1 + self.taux_tva / 100)

# Type personnalisé réutilisable via annotated_types (SQLAlchemy 2.0)
from typing import Annotated

IntPK = Annotated[int, mapped_column(primary_key=True)]
Str100 = Annotated[str, mapped_column(String(100))]

class Categorie(Base):
    __tablename__ = "categories"
    id: Mapped[IntPK]
    nom: Mapped[Str100]

Résumé

  • Numeric/Decimal pour toute valeur monétaire, jamais Float.
  • SAEnum mappe un enum.Enum Python vers un type SQL contraint (ENUM natif ou CHECK selon le SGBD).
  • Les types Annotated permettent de factoriser des configurations de colonnes répétitives.
  • JSON en colonne convient pour du semi-structuré non interrogé finement ; sinon préférer des colonnes/tables dédiées.

Exercices pratiques

1 disponible
1

Mission : corriger un écart comptable causé par un mauvais type de colonne

Objectif : Diagnostiquer un écart de centimes dans les totaux de commandes dû à l'usage de Float, et le corriger avec Numeric/Decimal.

Contexte

Le modèle Commande utilise montant: Mapped[float] = mapped_column(Float). Après plusieurs milliers de commandes, un rapprochement comptable révèle un écart de quelques centimes entre le total calculé par l'application (sum(commande.montant for commande in commandes)) et le relevé bancaire réel.

Résoudre l’exercice →