Retour au cours

data / sqlalchemy

Modèles déclaratifs avec Mapped

Leçon 11 exercice

Explication

Un peu d'histoire

SQLAlchemy est créé par Michael Bayer, qui en publie la première version en 2006. Il se distingue dès le départ des autres ORM (Object-Relational Mapper) de l'époque par un choix fort : offrir à la fois une couche haut niveau (l'ORM à proprement parler, qui manipule des objets Python) ET une couche bas niveau (SQLAlchemy Core), qui laisse un contrôle fin sur les requêtes SQL réellement générées, plutôt que de tout cacher derrière une "boîte noire".

Pourquoi apprendre SQLAlchemy aujourd'hui

SQLAlchemy est l'un des ORM Python les plus utilisés en production, très présent dans l'écosystème FastAPI/Flask. Il évite d'écrire du SQL brut partout dans son code (ce qui devient vite difficile à maintenir) tout en gardant la possibilité d'optimiser précisément une requête quand c'est nécessaire, contrairement à des ORM plus rigides. C'est une compétence clé pour tout développeur backend Python qui travaille avec une base de données relationnelle.

Ce que vous allez apprendre

  • Déclarer un modèle Python qui décrit une table avec DeclarativeBase
  • Utiliser Mapped[type] et mapped_column(...) pour typer précisément une colonne
  • Distinguer default (calculé côté Python) de server_default (calculé côté base)
  • Générer les tables en base à partir des modèles avec Base.metadata.create_all
  • Repérer quand Mapped[int | None] infère automatiquement nullable=True

Dans quel contexte ?

Une équipe démarre une nouvelle API FastAPI et doit modéliser une table utilisateurs avec un email unique, un nom, et une date de création automatique. Plutôt que d'écrire le CREATE TABLE à la main et de convertir chaque ligne SQL en objet Python, elle déclare une classe Utilisateur avec SQLAlchemy 2.0, comme le montre cette leçon, et laisse l'ORM faire le pont entre les deux mondes.

D'abord, le problème que l'ORM résout

Sans ORM, chaque interaction avec la base demande d'écrire du SQL à la main, puis de convertir manuellement chaque ligne de résultat en objet Python utilisable dans le code. Un ORM (Object-Relational Mapper) fait ce pont automatiquement entre le monde des objets Python (classes, attributs, listes) et le monde relationnel (tables, colonnes, lignes).

Étape 1 : une classe qui décrit une table

Dans SQLAlchemy 2.0, on écrit une classe Python héritant de DeclarativeBase : chaque attribut de la classe devient une colonne de la table. C'est une approche "déclarative" — on décrit CE QUE la table doit contenir, pas COMMENT la créer techniquement, le CREATE TABLE étant généré automatiquement.

Étape 2 : deux informations à donner pour chaque colonne

Une colonne a besoin de deux précisions complémentaires. Mapped[type] indique à Python (et à l'éditeur) quel type de donnée attendre, comme une étiquette. mapped_column(...) précise ensuite les détails purement SQL : taille maximale, contrainte d'unicité, valeur par défaut.

ÉlémentRôleExemple
Mapped[type]Annotation Python (type + nullabilité)Mapped[int], Mapped[str | None]
mapped_column(...)Détails SQL (taille, unicité, défaut)String(255), unique=True
default=Valeur calculée côté Python, à l'insertion via l'ORMdefault=datetime.utcnow
server_default=Valeur calculée côté base, en SQLserver_default=func.now()

Un piège à connaître avant d'avancer

Il faut distinguer default (calculé côté Python, uniquement à l'insertion via l'ORM) et server_default (calculé côté base de données, en SQL). Utiliser le mauvais peut casser un import de données fait en dehors de l'ORM — un cas classique quand une équipe charge des données par un autre outil.

Piège fréquent

Un default=datetime.utcnow sur la colonne cree_le ne s'applique QUE lors d'un INSERT fait via l'ORM SQLAlchemy. Un script d'import qui insère directement en SQL brut, ou un autre service qui écrit dans la même table, ne déclenchera jamais ce default Python et laissera la colonne vide sans server_default en secours.

Bonne pratique

Pour une colonne dont la valeur par défaut doit s'appliquer quel que soit le chemin d'écriture (ORM, script SQL, autre service), préfère toujours server_default=func.now() à default=datetime.utcnow, qui ne protège que le chemin ORM.

Vers la suite

Une fois qu'on sait décrire une table sous forme de classe, l'étape logique suivante est d'affiner le choix de chaque type de colonne (argent, énumérations, JSON) — exactement le sujet de la prochaine leçon.

Commandes & code

Modèles déclaratifs avec Mapped

python
from datetime import datetime
from sqlalchemy import String, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

# Base commune à tous les modèles de l'application
class Base(DeclarativeBase):
    pass

class Utilisateur(Base):
    __tablename__ = "utilisateurs"

    # Mapped[int] déclare le type Python ET SQL, mapped_column configure la colonne
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    nom: Mapped[str] = mapped_column(String(100))
    age: Mapped[int | None] = mapped_column(nullable=True)          # Optional -> nullable=True inféré
    est_actif: Mapped[bool] = mapped_column(default=True)
    cree_le: Mapped[datetime] = mapped_column(default=datetime.utcnow)

    def __repr__(self) -> str:
        return f"Utilisateur(id={self.id!r}, email={self.email!r})"

# Créer les tables à partir des modèles (en dev / tests -- Alembic en production)
from sqlalchemy import create_engine

engine = create_engine("sqlite:///./app.db", echo=True)  # echo=True log le SQL généré
Base.metadata.create_all(engine)

# Instancier et inspecter un objet Python simple (pas encore en base)
u = Utilisateur(email="jean@exemple.com", nom="Jean Dupont", age=30)
print(u)  # Utilisateur(id=None, email='jean@exemple.com')

# __tablename__ explicite vs génération automatique (mixin utilitaire)
class BaseAvecNomAuto(DeclarativeBase):
    pass

class TableNameMixin:
    @classmethod
    def __tablename__(cls) -> str:
        return cls.__name__.lower() + "s"

# Colonnes avec contraintes et valeurs par défaut serveur
from sqlalchemy import func

class Article(Base):
    __tablename__ = "articles"

    id: Mapped[int] = mapped_column(primary_key=True)
    titre: Mapped[str] = mapped_column(String(200), nullable=False)
    vues: Mapped[int] = mapped_column(default=0, server_default="0")
    cree_le: Mapped[datetime] = mapped_column(server_default=func.now())  # calculé côté base

Résumé

  • Mapped[type] porte l'annotation Python, mapped_column(...) configure les options SQL.
  • Mapped[int | None] infère automatiquement nullable=True.
  • default= s'applique côté Python (à l'insertion via l'ORM), server_default= côté base (SQL brut, ex: func.now()).
  • Base.metadata.create_all(engine) convient en dev/tests ; en production, on utilise Alembic (voir leçon dédiée).

Exercices pratiques

1 disponible
1

Mission : traquer une colonne cree_le vide après un import CSV

Objectif : Diagnostiquer pourquoi une colonne à valeur par défaut reste vide après un import fait hors ORM, puis corriger le modèle.

Contexte

Le modèle Utilisateur définit cree_le: Mapped[datetime] = mapped_column(default=datetime.utcnow). Une équipe data ajoute un script qui insère des lignes directement en SQL brut dans la table utilisateurs (hors ORM), pour un import en masse depuis un ancien système. Quelques jours plus tard, on remarque que toutes les lignes importées par ce script ont cree_le à NULL, alors que les inscriptions faites via l'API affichent une date correcte.

Résoudre l’exercice →