SQL moderne et bonnes pratiques#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import pandas as pd
import seaborn as sns
import sqlite3

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Ce dernier chapitre réunit les pratiques qui distinguent un SQL écrit par quelqu’un qui « sait utiliser SQL » de celui écrit par quelqu’un qui « maîtrise SQL ». Il ne s’agit pas de fonctionnalités isolées mais d’une philosophie : écrire du SQL lisible, maintenable, sécurisé et mesurable.

Style et conventions#

Formatage#

Un SQL bien formaté se lit comme de la prose structurée. Les conventions varient selon les équipes, mais la cohérence interne est primordiale.

# Exemple de style : avant / après
avant = """
select u.id,u.nom,count(c.id) as nb,sum(c.montant) as total from utilisateurs u left join commandes c on u.id=c.user_id where u.actif=1 and c.date_commande>='2024-01-01' group by u.id,u.nom having count(c.id)>5 order by total desc limit 20
"""

apres = """
SELECT
    u.id,
    u.nom,
    COUNT(c.id)        AS nb_commandes,
    SUM(c.montant)     AS total_achats
FROM utilisateurs AS u
LEFT JOIN commandes AS c
    ON u.id = c.user_id
WHERE
    u.actif = TRUE
    AND c.date_commande >= '2024-01-01'
GROUP BY
    u.id,
    u.nom
HAVING COUNT(c.id) > 5
ORDER BY total_achats DESC
LIMIT 20;
"""

print("=== AVANT (illisible) ===")
print(avant.strip())
print("\n=== APRÈS (style professionnel) ===")
print(apres.strip())
=== AVANT (illisible) ===
select u.id,u.nom,count(c.id) as nb,sum(c.montant) as total from utilisateurs u left join commandes c on u.id=c.user_id where u.actif=1 and c.date_commande>='2024-01-01' group by u.id,u.nom having count(c.id)>5 order by total desc limit 20

=== APRÈS (style professionnel) ===
SELECT
    u.id,
    u.nom,
    COUNT(c.id)        AS nb_commandes,
    SUM(c.montant)     AS total_achats
FROM utilisateurs AS u
LEFT JOIN commandes AS c
    ON u.id = c.user_id
WHERE
    u.actif = TRUE
    AND c.date_commande >= '2024-01-01'
GROUP BY
    u.id,
    u.nom
HAVING COUNT(c.id) > 5
ORDER BY total_achats DESC
LIMIT 20;

Règles de nommage#

Hide code cell source

conn = sqlite3.connect(":memory:")

# Mauvaises pratiques vs bonnes pratiques
mauvais = [
    ("USR", "Abréviation cryptique"),
    ("utilisateur_data", "Redondant"),
    ("tblCommandes", "Préfixe hongrois"),
    ("Id", "Casse incohérente"),
    ("date", "Mot réservé SQL"),
    ("infos_supp_client_v2", "Vague et versionné"),
]

bon = [
    ("utilisateurs", "Pluriel, snake_case"),
    ("commandes", "Nom métier clair"),
    ("paiements", "Simple et précis"),
    ("id", "Convention universelle"),
    ("date_creation", "Descriptif et non-réservé"),
    ("adresse_facturation", "Domaine explicite"),
]

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

for ax, data, titre, couleur in [
    (axes[0], mauvais, "Nommage à éviter", "#C44E52"),
    (axes[1], bon, "Nommage recommandé", "#55A868"),
]:
    ax.axis("off")
    ax.set_title(titre, fontweight="bold", fontsize=13, color=couleur)
    for i, (nom, raison) in enumerate(data):
        y = 0.9 - i * 0.15
        ax.text(0.05, y, f"`{nom}`", transform=ax.transAxes,
                fontfamily="monospace", fontsize=11,
                color=couleur, fontweight="bold")
        ax.text(0.4, y, f"← {raison}", transform=ax.transAxes,
                fontsize=10, color="gray")

plt.suptitle("Conventions de nommage SQL", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.show()
_images/0ddabf4fe4f2c26869a9f4ef1e98a55d346f9a03fc70fed9ab0f78569604e05a.png

Éviter les anti-patterns#

SELECT * en production#

conn = sqlite3.connect(":memory:")
conn.executescript("""
    CREATE TABLE produits (
        id INTEGER PRIMARY KEY,
        reference TEXT,
        nom TEXT,
        description TEXT,  -- peut être très long
        prix REAL,
        stock INTEGER,
        image_blob BLOB,   -- potentiellement des Mo
        date_creation TEXT,
        date_modification TEXT,
        actif INTEGER DEFAULT 1
    );
    INSERT INTO produits VALUES
        (1, 'REF-001', 'Laptop Pro', 'Description longue...', 1299.0, 50, NULL, '2024-01-01', '2024-06-01', 1),
        (2, 'REF-002', 'Souris Ergo', 'Souris ergonomique...', 49.9, 200, NULL, '2024-02-01', '2024-06-15', 1);
""")

# Anti-pattern : SELECT *
print("=== Anti-pattern : SELECT * ===")
print("Problèmes :")
print("  • Transfère toutes les colonnes (y compris image_blob de plusieurs Mo)")
print("  • Casse le code si la structure de la table change")
print("  • Empêche l'optimiseur de créer des index couvrants")
print("  • Illisible : on ne sait pas quelles données sont utilisées")

print("\n=== Bonne pratique : colonnes explicites ===")
df = pd.read_sql_query("""
    SELECT id, reference, nom, prix, stock
    FROM produits
    WHERE actif = 1
""", conn)
print(df.to_string(index=False))
print("\n→ Clair, performant, robuste aux évolutions de schéma")
=== Anti-pattern : SELECT * ===
Problèmes :
  • Transfère toutes les colonnes (y compris image_blob de plusieurs Mo)
  • Casse le code si la structure de la table change
  • Empêche l'optimiseur de créer des index couvrants
  • Illisible : on ne sait pas quelles données sont utilisées

=== Bonne pratique : colonnes explicites ===
 id reference         nom   prix  stock
  1   REF-001  Laptop Pro 1299.0     50
  2   REF-002 Souris Ergo   49.9    200

→ Clair, performant, robuste aux évolutions de schéma

NULL et la logique à trois valeurs#

SQL utilise une logique ternaire : TRUE, FALSE, et UNKNOWN. Toute comparaison avec NULL retourne UNKNOWN.

conn = sqlite3.connect(":memory:")
conn.executescript("""
    CREATE TABLE employes (
        id INTEGER PRIMARY KEY,
        nom TEXT,
        salaire REAL,
        manager_id INTEGER  -- NULL pour les directeurs
    );
    INSERT INTO employes VALUES
        (1, 'Alice', 80000, NULL),
        (2, 'Bob', 60000, 1),
        (3, 'Charlie', 55000, 1),
        (4, 'Diana', 70000, NULL),
        (5, 'Eve', 50000, 2);
""")

# Pièges courants avec NULL
print("=== Pièges avec NULL ===\n")

# Piège 1 : WHERE col != valeur n'inclut pas les NULL
r1 = conn.execute("SELECT COUNT(*) FROM employes WHERE manager_id != 1").fetchone()[0]
r2 = conn.execute("SELECT COUNT(*) FROM employes WHERE manager_id != 1 OR manager_id IS NULL").fetchone()[0]
print(f"manager_id != 1             → {r1} résultats (les NULL sont exclus !)")
print(f"manager_id != 1 OR IS NULL  → {r2} résultats (correct)")

# Piège 2 : NULL != NULL
r3 = conn.execute("SELECT NULL = NULL").fetchone()[0]
r4 = conn.execute("SELECT NULL IS NULL").fetchone()[0]
print(f"\nNULL = NULL    → {r3} (UNKNOWN, pas TRUE !)")
print(f"NULL IS NULL   → {r4} (TRUE, c'est la bonne façon)")

# Piège 3 : agrégats ignorent les NULL
conn.execute("UPDATE employes SET salaire = NULL WHERE id = 5")
conn.commit()
r5 = conn.execute("SELECT COUNT(*), COUNT(salaire), AVG(salaire) FROM employes").fetchone()
print(f"\nCOUNT(*) = {r5[0]}, COUNT(salaire) = {r5[1]}, AVG(salaire) = {r5[2]:.0f}")
print("→ AVG ignore les NULL (diviseur = 4, pas 5)")

# Bonne pratique : COALESCE
df = pd.read_sql_query("""
    SELECT
        nom,
        COALESCE(salaire, 0) AS salaire,
        CASE WHEN manager_id IS NULL THEN 'Directeur' ELSE 'Employé' END AS role
    FROM employes
""", conn)
print(f"\n{df.to_string(index=False)}")
=== Pièges avec NULL ===

manager_id != 1             → 1 résultats (les NULL sont exclus !)
manager_id != 1 OR IS NULL  → 3 résultats (correct)

NULL = NULL    → None (UNKNOWN, pas TRUE !)
NULL IS NULL   → 1 (TRUE, c'est la bonne façon)

COUNT(*) = 5, COUNT(salaire) = 4, AVG(salaire) = 66250
→ AVG ignore les NULL (diviseur = 4, pas 5)

    nom  salaire      role
  Alice  80000.0 Directeur
    Bob  60000.0   Employé
Charlie  55000.0   Employé
  Diana  70000.0 Directeur
    Eve      0.0   Employé

Requêtes N+1#

L’anti-pattern N+1 consiste à exécuter une requête pour obtenir N lignes, puis N requêtes supplémentaires pour les détails — soit N+1 allers-retours en base.

conn = sqlite3.connect(":memory:")
conn.executescript("""
    CREATE TABLE auteurs (id INTEGER PRIMARY KEY, nom TEXT);
    CREATE TABLE livres (id INTEGER PRIMARY KEY, titre TEXT, auteur_id INTEGER);
    INSERT INTO auteurs VALUES (1,'Zola'),(2,'Hugo'),(3,'Balzac');
    INSERT INTO livres VALUES
        (1,'Germinal',1),(2,'Nana',1),(3,'Les Misérables',2),
        (4,'Notre-Dame',2),(5,'Le Père Goriot',3),(6,'Eugénie Grandet',3);
""")

import time

# Anti-pattern N+1
debut = time.perf_counter()
auteurs = conn.execute("SELECT id, nom FROM auteurs").fetchall()
total_queries = 1
for auteur_id, nom in auteurs:
    livres = conn.execute(
        "SELECT titre FROM livres WHERE auteur_id = ?", (auteur_id,)
    ).fetchall()
    total_queries += 1
fin = time.perf_counter()
print(f"Anti-pattern N+1 : {total_queries} requêtes pour {len(auteurs)} auteurs")

# Solution : une seule requête avec JOIN
debut2 = time.perf_counter()
df = pd.read_sql_query("""
    SELECT a.nom AS auteur, l.titre
    FROM auteurs a
    JOIN livres l ON a.id = l.auteur_id
    ORDER BY a.nom, l.titre
""", conn)
fin2 = time.perf_counter()
print(f"Solution JOIN   : 1 requête pour tous les auteurs et livres")
print(f"\n{df.to_string(index=False)}")
Anti-pattern N+1 : 4 requêtes pour 3 auteurs
Solution JOIN   : 1 requête pour tous les auteurs et livres

auteur           titre
Balzac Eugénie Grandet
Balzac  Le Père Goriot
  Hugo  Les Misérables
  Hugo      Notre-Dame
  Zola        Germinal
  Zola            Nana

Sécurité#

Injection SQL#

L’injection SQL est la vulnérabilité de sécurité la plus classique des applications de base de données. Elle survient quand des données utilisateur sont concaténées directement dans une requête SQL.

conn = sqlite3.connect(":memory:")
conn.executescript("""
    CREATE TABLE utilisateurs (id INTEGER PRIMARY KEY, nom TEXT, mot_de_passe TEXT, role TEXT);
    INSERT INTO utilisateurs VALUES
        (1, 'alice', 'secret123', 'user'),
        (2, 'admin', 'tr3s_s3cr3t', 'admin');
""")

print("=== VULNÉRABILITÉ : concaténation de chaîne ===\n")

def login_vulnerable(nom, mdp):
    """NE JAMAIS FAIRE CECI."""
    query = f"SELECT * FROM utilisateurs WHERE nom = '{nom}' AND mot_de_passe = '{mdp}'"
    print(f"Requête exécutée : {query}")
    return conn.execute(query).fetchall()

# Attaque classique : ' OR '1'='1
print("Tentative d'injection :")
resultats = login_vulnerable("admin' --", "n'importe quoi")
print(f"Résultat : {resultats}")
print("→ L'attaquant est connecté sans connaître le mot de passe !\n")

print("=== SOLUTION : requêtes paramétrées ===\n")

def login_securise(nom, mdp):
    """Toujours utiliser des paramètres."""
    query = "SELECT * FROM utilisateurs WHERE nom = ? AND mot_de_passe = ?"
    print(f"Requête : {query}")
    print(f"Paramètres : ({nom!r}, {mdp!r})")
    return conn.execute(query, (nom, mdp)).fetchall()

print("Même tentative d'injection avec requête paramétrée :")
resultats = login_securise("admin' --", "n'importe quoi")
print(f"Résultat : {resultats}")
print("→ Aucun résultat, l'injection est bloquée")
=== VULNÉRABILITÉ : concaténation de chaîne ===

Tentative d'injection :
Requête exécutée : SELECT * FROM utilisateurs WHERE nom = 'admin' --' AND mot_de_passe = 'n'importe quoi'
Résultat : [(2, 'admin', 'tr3s_s3cr3t', 'admin')]
→ L'attaquant est connecté sans connaître le mot de passe !

=== SOLUTION : requêtes paramétrées ===

Même tentative d'injection avec requête paramétrée :
Requête : SELECT * FROM utilisateurs WHERE nom = ? AND mot_de_passe = ?
Paramètres : ("admin' --", "n'importe quoi")
Résultat : []
→ Aucun résultat, l'injection est bloquée

Principe du moindre privilège#

-- Créer des rôles avec des droits minimaux
CREATE ROLE lecture_seule;
GRANT CONNECT ON DATABASE ma_base TO lecture_seule;
GRANT USAGE ON SCHEMA public TO lecture_seule;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO lecture_seule;

-- Rôle pour l'application (lecture + écriture, sans DDL)
CREATE ROLE app_web;
GRANT CONNECT ON DATABASE ma_base TO app_web;
GRANT USAGE ON SCHEMA public TO app_web;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_web;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_web;

-- Révoquer les droits inutiles
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Audit : qui a accès à quoi ?
SELECT grantee, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
ORDER BY grantee, table_name;

Migrations et versionnement#

Principe des migrations#

Une migration est un script SQL (ou Python) qui fait évoluer le schéma de façon contrôlée et reproductible. Chaque migration a un identifiant unique et ne s’exécute qu’une seule fois.

conn = sqlite3.connect(":memory:")

# Simulation d'un système de migrations minimal
conn.executescript("""
    CREATE TABLE schema_migrations (
        version     TEXT PRIMARY KEY,
        appliquee   TEXT DEFAULT (datetime('now')),
        description TEXT
    );
""")

migrations = [
    ("001", "Création table utilisateurs", """
        CREATE TABLE utilisateurs (
            id       INTEGER PRIMARY KEY AUTOINCREMENT,
            email    TEXT UNIQUE NOT NULL,
            nom      TEXT NOT NULL,
            actif    INTEGER DEFAULT 1,
            cree_le  TEXT DEFAULT (datetime('now'))
        );
    """),
    ("002", "Ajout table commandes", """
        CREATE TABLE commandes (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id      INTEGER NOT NULL REFERENCES utilisateurs(id),
            total        REAL NOT NULL,
            statut       TEXT DEFAULT 'en_attente',
            cree_le      TEXT DEFAULT (datetime('now'))
        );
    """),
    ("003", "Index email utilisateurs", """
        CREATE INDEX idx_utilisateurs_email ON utilisateurs(email);
    """),
    ("004", "Ajout colonne telephone", """
        ALTER TABLE utilisateurs ADD COLUMN telephone TEXT;
    """),
]

def appliquer_migration(conn, version, description, sql):
    """Applique une migration si elle n'a pas déjà été exécutée."""
    deja = conn.execute(
        "SELECT version FROM schema_migrations WHERE version = ?", (version,)
    ).fetchone()
    if deja:
        print(f"  [SKIP] {version} — déjà appliquée")
        return
    conn.executescript(sql)
    conn.execute(
        "INSERT INTO schema_migrations (version, description) VALUES (?, ?)",
        (version, description)
    )
    conn.commit()
    print(f"  [OK]   {version}{description}")

print("Application des migrations :")
for v, desc, sql in migrations:
    appliquer_migration(conn, v, desc, sql)

print("\nHistorique des migrations :")
df = pd.read_sql_query(
    "SELECT version, description, appliquee FROM schema_migrations ORDER BY version",
    conn
)
print(df.to_string(index=False))
Application des migrations :
  [OK]   001 — Création table utilisateurs
  [OK]   002 — Ajout table commandes
  [OK]   003 — Index email utilisateurs
  [OK]   004 — Ajout colonne telephone

Historique des migrations :
version                 description           appliquee
    001 Création table utilisateurs 2026-03-31 23:05:21
    002       Ajout table commandes 2026-03-31 23:05:21
    003    Index email utilisateurs 2026-03-31 23:05:21
    004     Ajout colonne telephone 2026-03-31 23:05:21

Alembic avec SQLAlchemy#

En production Python, Alembic gère les migrations avec un système de révisions chaînées :

# Initialisation
# alembic init alembic
# alembic revision --autogenerate -m "add telephone column"

# Fichier de migration généré : alembic/versions/abc123_add_telephone.py
from alembic import op
import sqlalchemy as sa

def upgrade():
    op.add_column(
        'utilisateurs',
        sa.Column('telephone', sa.String(20), nullable=True)
    )
    op.create_index('idx_users_email', 'utilisateurs', ['email'], unique=True)

def downgrade():
    op.drop_index('idx_users_email', table_name='utilisateurs')
    op.drop_column('utilisateurs', 'telephone')

# Appliquer : alembic upgrade head
# Annuler  : alembic downgrade -1
# Historique : alembic history --verbose

SQL moderne : fonctionnalités récentes#

Colonnes générées#

conn = sqlite3.connect(":memory:")

# SQLite supporte les colonnes générées depuis 3.31.0
conn.executescript("""
    CREATE TABLE produits (
        id         INTEGER PRIMARY KEY,
        prix_ht    REAL NOT NULL,
        tva_taux   REAL NOT NULL DEFAULT 0.20,
        prix_ttc   REAL GENERATED ALWAYS AS (prix_ht * (1 + tva_taux)) STORED,
        categorie  TEXT
    );
    INSERT INTO produits (id, prix_ht, categorie) VALUES
        (1, 100.0, 'Électronique'),
        (2, 50.0,  'Alimentation'),
        (3, 200.0, 'Vêtements');
    UPDATE produits SET tva_taux = 0.055 WHERE categorie = 'Alimentation';
""")

df = pd.read_sql_query("SELECT id, prix_ht, tva_taux, prix_ttc, categorie FROM produits", conn)
print("Colonnes générées (prix_ttc calculé automatiquement) :")
print(df.to_string(index=False))
Colonnes générées (prix_ttc calculé automatiquement) :
 id  prix_ht  tva_taux  prix_ttc    categorie
  1    100.0     0.200    120.00 Électronique
  2     50.0     0.055     52.75 Alimentation
  3    200.0     0.200    240.00    Vêtements

FILTER dans les agrégats#

conn = sqlite3.connect(":memory:")
conn.executescript("""
    CREATE TABLE ventes (
        id        INTEGER PRIMARY KEY,
        region    TEXT,
        annee     INTEGER,
        montant   REAL
    );
""")

import random
random.seed(42)
regions = ['Nord', 'Sud', 'Est', 'Ouest']
for i in range(1, 201):
    conn.execute(
        "INSERT INTO ventes VALUES (?, ?, ?, ?)",
        (i, random.choice(regions), random.choice([2022, 2023, 2024]),
         round(random.uniform(100, 10000), 2))
    )
conn.commit()

# FILTER : agrégats conditionnels sans CASE WHEN imbriqué
df = pd.read_sql_query("""
    SELECT
        region,
        COUNT(*)                                    AS total_ventes,
        SUM(montant) FILTER (WHERE annee = 2022)    AS ca_2022,
        SUM(montant) FILTER (WHERE annee = 2023)    AS ca_2023,
        SUM(montant) FILTER (WHERE annee = 2024)    AS ca_2024
    FROM ventes
    GROUP BY region
    ORDER BY region
""", conn)

df[['ca_2022', 'ca_2023', 'ca_2024']] = df[['ca_2022', 'ca_2023', 'ca_2024']].round(0)
print("Agrégats avec FILTER (syntaxe moderne vs CASE WHEN) :")
print(df.to_string(index=False))
Agrégats avec FILTER (syntaxe moderne vs CASE WHEN) :
region  total_ventes  ca_2022  ca_2023  ca_2024
   Est            48  92538.0  60937.0  72916.0
  Nord            56 113580.0  69338.0 111019.0
 Ouest            43  78484.0  88232.0  55891.0
   Sud            53  69735.0  55093.0 122433.0

RETURNING#

-- Récupérer les données insérées/modifiées sans requête supplémentaire
-- PostgreSQL, SQLite 3.35+

INSERT INTO commandes (user_id, total, statut)
VALUES (42, 299.99, 'en_attente')
RETURNING id, cree_le;
-- → retourne l'id auto-généré et la date de création

UPDATE commandes
SET statut = 'expédié', date_expedition = NOW()
WHERE id = 1234
RETURNING id, statut, date_expedition;
-- → confirme les valeurs après mise à jour

DELETE FROM logs
WHERE cree_le < NOW() - INTERVAL '90 days'
RETURNING COUNT(*);
-- → nombre de lignes supprimées

Outils et écosystème#

Hide code cell source

# Panorama des outils SQL
outils = {
    "Clients graphiques": [
        ("DBeaver", "Universel, open source, tous SGBD"),
        ("pgAdmin 4", "PostgreSQL, interface web ou desktop"),
        ("TablePlus", "MacOS/Windows, interface moderne"),
        ("DataGrip", "JetBrains, IDE complet (payant)"),
    ],
    "Migration": [
        ("Alembic", "Python/SQLAlchemy, migrations versionnées"),
        ("Flyway", "Java/multi-langages, migrations SQL pures"),
        ("Liquibase", "XML/YAML/JSON, multi-SGBD"),
        ("sqitch", "Git-like, dépendances entre migrations"),
    ],
    "Performance": [
        ("pgBadger", "Analyse des logs PostgreSQL"),
        ("explain.dalibo.com", "Visualisation plans d'exécution"),
        ("PgHero", "Dashboard PostgreSQL"),
        ("Percona Toolkit", "Diagnostic MySQL/PostgreSQL"),
    ],
    "Développement": [
        ("sqlfluff", "Linter et formateur SQL"),
        ("dbt", "Transformations SQL versionnées"),
        ("SQLModel", "SQLAlchemy + Pydantic pour FastAPI"),
        ("Prisma", "ORM TypeScript, migrations auto"),
    ],
}

fig, axes = plt.subplots(2, 2, figsize=(14, 8))
axes = axes.flatten()

couleurs = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]

for ax, (categorie, items), couleur in zip(axes, outils.items(), couleurs):
    ax.axis("off")
    ax.set_title(categorie, fontweight="bold", fontsize=13, color=couleur,
                 bbox=dict(boxstyle="round", facecolor=couleur, alpha=0.15))
    for i, (outil, desc) in enumerate(items):
        y = 0.78 - i * 0.22
        ax.text(0.02, y, f"▸ {outil}", transform=ax.transAxes,
                fontweight="bold", fontsize=11, color=couleur)
        ax.text(0.02, y - 0.08, f"   {desc}", transform=ax.transAxes,
                fontsize=9, color="gray")

plt.suptitle("Écosystème SQL : outils essentiels", fontsize=15, fontweight="bold")
plt.tight_layout()
plt.show()
_images/246e336cdc063b16fc776e1e515f282384e5431632ce6101aa50e0931e969fc6.png

Checklist de qualité SQL#

Hide code cell source

# Checklist interactive
checklist = [
    ("Style", [
        ("Mots-clés SQL en MAJUSCULES", True),
        ("snake_case pour tables et colonnes", True),
        ("Colonnes explicites (pas de SELECT *)", True),
        ("Alias clairs avec AS", True),
        ("Indentation cohérente", True),
    ]),
    ("Performance", [
        ("Index sur les colonnes de JOIN et WHERE", True),
        ("EXPLAIN ANALYZE avant mise en production", True),
        ("Pagination avec LIMIT/OFFSET ou keyset", True),
        ("Éviter les fonctions dans les clauses WHERE indexées", True),
        ("Requêtes paramétrées (pas de concaténation)", True),
    ]),
    ("Sécurité", [
        ("Requêtes paramétrées pour toutes les entrées utilisateur", True),
        ("Principe du moindre privilège sur les rôles", True),
        ("Pas de mots de passe en dur dans le code", True),
        ("Logs d'audit pour les opérations sensibles", True),
        ("Chiffrement des données sensibles (PII)", True),
    ]),
    ("Maintenance", [
        ("Migrations versionnées (Alembic/Flyway)", True),
        ("Commentaires sur les colonnes et tables complexes", True),
        ("Tests d'intégration sur une base dédiée", True),
        ("Sauvegardes automatiques testées régulièrement", True),
        ("Documentation du schéma à jour", True),
    ]),
]

fig, axes = plt.subplots(2, 2, figsize=(14, 9))
axes = axes.flatten()
couleurs = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]

for ax, (section, items), couleur in zip(axes, checklist, couleurs):
    ax.axis("off")
    ax.set_title(section, fontweight="bold", fontsize=13, color=couleur)
    for i, (item, ok) in enumerate(items):
        y = 0.85 - i * 0.18
        symbole = "✓" if ok else "✗"
        ax.text(0.05, y, symbole, transform=ax.transAxes,
                fontsize=14, color="#27AE60" if ok else "#E74C3C", fontweight="bold")
        ax.text(0.15, y, item, transform=ax.transAxes, fontsize=10)

plt.suptitle("Checklist qualité SQL", fontsize=15, fontweight="bold")
plt.tight_layout()
plt.show()
_images/92cc48eb58056ddeadaa40feb00f27c991c2783f9df0047dcc1fccc7d6d51a51.png

Pour aller plus loin#

Ressources recommandées

Livres

  • SQL Performance Explained — Markus Winand (use-the-index-luke.com)

  • The Art of PostgreSQL — Dimitri Fontaine

  • Database Internals — Alex Petrov (architecture des SGBD)

En ligne

Pratique

  • Contribuer à un projet open source qui utilise une base de données

  • Analyser les slow queries de votre application avec pg_stat_statements

  • Pratiquer sur des jeux de données publics (data.gouv.fr, Kaggle)

Résumé du livre#

Ce livre a couvert SQL des fondations aux architectures distribuées :

Hide code cell source

# Récapitulatif visuel du parcours
parties = [
    ("Partie I\nFondations", ["Modèle relationnel", "SQL de base", "Types & contraintes", "Modélisation ER"], "#4C72B0"),
    ("Partie II\nRequêtes", ["Jointures", "Agrégation", "CTE & sous-requêtes", "Fenêtrées"], "#DD8452"),
    ("Partie III\nAdministration", ["Transactions", "Index & perf.", "Vues & triggers", "Sécurité"], "#55A868"),
    ("Partie IV\nAu-delà", ["PostgreSQL avancé", "PostGIS", "MongoDB & Redis", "Vectoriel"], "#C44E52"),
    ("Partie V\nProduction", ["SQLAlchemy", "DuckDB & OLAP", "Partitionnement", "CAP theorem"], "#9467BD"),
    ("Partie VI\nPratiques", ["Style & conventions", "Anti-patterns", "Migrations", "Outils"], "#8C564B"),
]

fig, ax = plt.subplots(figsize=(15, 6))
ax.set_xlim(-0.5, len(parties) - 0.5)
ax.set_ylim(-0.5, 5)
ax.axis("off")
ax.set_title("SQL et bases de données — Parcours complet", fontsize=14, fontweight="bold")

for i, (titre, chapitres, couleur) in enumerate(parties):
    # En-tête de partie
    rect = patches.FancyBboxPatch(
        (i - 0.42, 3.9), 0.84, 0.8,
        boxstyle="round,pad=0.05",
        facecolor=couleur, edgecolor="white", linewidth=2
    )
    ax.add_patch(rect)
    ax.text(i, 4.3, titre, ha="center", va="center",
            fontweight="bold", color="white", fontsize=9)

    # Chapitres
    for j, ch in enumerate(chapitres):
        y = 3.1 - j * 0.85
        rect2 = patches.FancyBboxPatch(
            (i - 0.42, y - 0.3), 0.84, 0.55,
            boxstyle="round,pad=0.04",
            facecolor=couleur, edgecolor="white", linewidth=1, alpha=0.4
        )
        ax.add_patch(rect2)
        ax.text(i, y, ch, ha="center", va="center", fontsize=8)

plt.tight_layout()
plt.show()
_images/55225ff0dfd328c27cb21905435118bd98e8abab5c36ebe528b9d318bdcbcec9.png

SQL est un langage vieux de cinquante ans qui n’a cessé de s’enrichir. Sa simplicité apparente cache une profondeur remarquable : un bon DBA ou développeur data peut passer des années à en explorer les subtilités. Ce livre vous a donné les fondations et les outils pour progresser — maintenant, la meilleure façon d’apprendre est de pratiquer sur de vrais projets, d’analyser des requêtes lentes, de concevoir des schémas, et de mesurer l’impact de vos décisions.