- Du modèle relationnel aux agents IA connectés à vos bases
- Environnement : Docker · MySQL/MariaDB · PostgreSQL · Tabularis
- Méthode : Théorie → On fait ensemble → Vous faites
- Concevoir et créer un schéma relationnel adapté à un besoin data
- Écrire des requêtes SQL : extraction, transformation, agrégation
- Manipuler plusieurs SGBD (MySQL/MariaDB, PostgreSQL) et comprendre leurs différences
- Garantir l'intégrité des données (contraintes, transactions)
- Optimiser des requêtes (index, EXPLAIN)
- Ouvrir la base aux outils IA de manière contrôlée (MCP)
flowchart TB
A[Historique & Setup] --> B[Modèle relationnel]
B --> C[DDL : créer]
C --> D[DML : manipuler]
D --> E[SELECT & filtres]
E --> F[Agrégations]
F --> G[Jointures]
G --> H[Sous-requêtes & CTE]
H --> I[Vues, Index, EXPLAIN]
I --> J[Transactions]
J --> K[MySQL vs PostgreSQL]
K --> L[Tabularis + Claude via MCP]
L --> M[Mini-projet]
- Chaque notion = 3 temps : Théorie / On fait ensemble / Vous faites
- Fil rouge : une base
boutique(clients, produits, commandes)
- 1965 : architecture Ansi/Sparc par Bachmann (prix Turing 1973)
- 1968 : Pick par Dick Pick - un OS avec SGDBR MV + langage de requêtage GIRLS
- 1970 : E.F. Codd (IBM) publie le modèle relationnel (prix Turing 1981)
- 1974 :
- SEQUEL chez IBM (projet System R)
- Ingres (qui donna Sybase puis... MS-SQL Server)
- 1979 : Oracle, premier SGBDR commercial
- 1985 : PostIngres = Postgres
- 1986 : SQL devient norme ANSI (SQL-86)
- 1995 :
- MySQL démocratise SQL dans le monde open-souce
- PostgreSQL (héritier d'Ingres / Postgres)
- 2000 : Création du projet SQLite un serveur SQL... sans serveur : juste un fichier
- 2008 : nov. rachat de AB Software (MySQL) par Sun Microsystems
- 2009 : avr. Oracle rachète Sun Microsystems
- 2009 : Vague NoSQL... qui n'a pas tué SQL
- 2010 : MariaDB, fork communautaire de MySQL (par les anciens de MySQL)
- 2020 : SQL partout, du cloud à DuckDB et même dans de l'embarqué grâce à SQLite
- 2025 :
- Les agents IA écrivent du SQL via MCP
- SQLite devient production proof
- Langage déclaratif : on décrit quoi, pas comment
- Norme depuis 40 ans → compétence transférable (MySQL, PostgreSQL, BigQuery, Spark SQL...)
- 80 % du travail data = extraire, nettoyer, agréger → du SQL
- Les pipelines ML commencent presque toujours par un
SELECT - Les LLM génèrent du SQL... qu'il faut savoir relire et valider
- Verdict historique : NoSQL a complété SQL, il ne l'a pas remplacé
À la racine du dépôt vous trouverez le fichier docker-compose.yml et les autres fichiers de configuration pour vous permettre de lancer à la fois MariaDB et PostgreSQL, mais aussi un phpMyAdmin et un pgAdmin pour vous permettre de manipuler vos bases de données, En plus d'un client web pour SQLite et un fichier DB SQLite accessible depuis votre machine dans le dossier ./sqlite-data
Il vous est demander d'installer Tabularis dès maintenant.

flowchart LR
subgraph
T[Tabularis<br/>client lourd]
SQ[( ./sqlite-data/DB_Cours.db)]
subgraph D[Docker]
subgraph C[Clients web]
A[phpMyAdmin :8081]
B[pgAdmin :8082]
Q[sqlite-web :8083]
end
M[(MariaDB :3306)]
P[(PostgreSQL :5433)]
S[SQLite]
end
end
T --> M
T --> P
T --> SQ
A --> M
B --> P
S --> SQ
Q --> SQ
style D fill:#005
style C fill:transparent
style C stroke:#00a
- MySQL(MariaDB) se trouve sur le port
3306 - PostgreSQL se trouve sur le port
5433 - Une DB SQLite se trouve dans le fichier ./sqlite-data/DB_Cours.db
- phpMyAdmin se trouve sur le port
8081 - pgAdmin se trouve sur le port
8082 - SQLite Web se trouve sur le port
8083
- Vous disposez déjà du fichier
docker-compose.ymlà la racine du dépôt - Ouvrez un terminal à la racine du dépôt cloné
- En suite vous pouvez faire :
# Toute la stack : MariaDB + phpMyAdmin + PostgreSQL + pgAdmin + SQLite + sqlite-web
docker compose up -d
docker compose ps # état des conteneurs
docker compose logs -f mariadb # logs d'un service (mariadb, postgres, sqlite-web...)⚠️ docker compose downpour éteindre la stack- Détail des services, ports, identifiants et connexions (y compris nettoyage des volumes) : voir readme-infra-docker.md
- phpMyAdmin / pgAdmin / SQLite web (web) : pratiques...
- mais un outil différent par moteur, éditeur SQL limité, ergonomie datée
- cependant, installé à coté en environnement protégé (staging etc...) permet de se connecter à la DB sans gros besoins de sécurité
- Tabularis (client lourd, open source, Windows/macOS/Linux) :
- un seul outil pour PostgreSQL, MySQL/MariaDB, SQLite (+ plugins : DuckDB, ClickHouse...)
- éditeur SQL Monaco, notebooks SQL, EXPLAIN visuel, diagrammes ER
- connexions enregistrées, mots de passe dans le trousseau système
- serveur MCP intégré → connectable à Claude (démo en fin de module)
- mais besoin d'un VPN / infra protégée pour accès distants
- Installation :
winget install Debba.Tabularis·brew install --cask tabularis· snap... ou simplement via le site web
- Readme.md — ce document : le déroulé pédagogique complet du module (théorie, exemples, exercices)
- readme-infra-docker.md — détail de l'infrastructure Docker : services, ports, identifiants, connexions, nettoyage des volumes
- docker-compose.yml — définition de la stack (MariaDB, PostgreSQL, SQLite, phpMyAdmin, pgAdmin, sqlite-web)
- readme-activites-mysql.md, readme-activites-postgresql.md, readme-activites-sqlite.md — les parties « 👥 On fait ensemble » et « 🎯 Vous faites » de ce Readme, déclinées et testées pour chaque SGBD (MySQL/MariaDB, PostgreSQL, SQLite), avec une solution complète pour chaque exercice
- sql_joins.jpg — illustration des types de jointures (Notion 6)
- sqlite-data/ — fichier de base SQLite (
DB_Cours.db) partagé avec le conteneursqlite/sqlite-web - pgadmin/ — configuration pré-enregistrée du serveur PostgreSQL dans pgAdmin
readme-activites-* dédiés — utilisez ceux-ci si vous travaillez principalement sur un autre moteur.
- Une base contient des tables ; une table = colonnes typées + lignes
- Clé primaire (PK) : identifie chaque ligne de façon unique
- Clé étrangère (FK) : référence la PK d'une autre table → crée la relation
- Schéma = structure · Données = contenu
erDiagram
CLIENTS ||--o{ COMMANDES : passe
COMMANDES ||--|{ LIGNES_COMMANDE : contient
PRODUITS ||--o{ LIGNES_COMMANDE : concerne
CLIENTS {
int id PK
varchar nom
varchar email
date inscription
}
PRODUITS {
int id PK
varchar nom
decimal prix
int stock
}
COMMANDES {
int id PK
int client_id FK
datetime date_commande
}
LIGNES_COMMANDE {
int commande_id FK
int produit_id FK
int quantite
}
docker compose up -d(depuis la racine du dépôt cloné, lance toute la stack)- Tabularis → New Connection → MySQL
- host
127.0.0.1, port3306, useruser, mdppassword, databaseDB_Cours - (accès admin : user
root, mdprootPassword)
- host
- Explorer
information_schemadans l'arborescence - Premiers SQL dans l'éditeur :
SHOW DATABASES;
SELECT VERSION();
SELECT NOW();- Faire la même exploration dans phpMyAdmin → comparer le confort
- Tabularis → New Connection → PostgreSQL
- host
127.0.0.1, port5433, useruser, mdppassword, databaseDB_Cours
- host
- Explorer
postgresdans l'arborescence - Mêmes requêtes, dialecte PostgreSQL :
SELECT datname FROM pg_database;
SELECT version();
SELECT now();- Faire la même exploration dans pgAdmin → comparer le confort
- Tabularis → New Connection → SQLite
- fichier
{là où vous avez cloné le cours}/sqlite-data/DB_Cours.db
- fichier
- Explorer l'arborescence : un seul fichier, pas de notion d'utilisateur ni de
information_schema - Premiers SQL dans l'éditeur :
SELECT sqlite_version();
SELECT datetime('now');- Faire la même exploration dans sqlite-web (
8083) → comparer le confort - Bilan :
- un seul outil (Tabularis) pour trois moteurs — trois dialectes, trois façons de dire « version » et « maintenant »
- la base du dialecte SQL est "la même" mais avec des particularités apportées par les différents SGDB
- mieux vaut aussi se faire la main et pratiquer avec phpMyAdmin et pgAdmin car souvent plus utilisés/utilisables/dispo en environnement de production/staging
- DDL = Data Definition Language :
CREATE,ALTER,DROP
| Besoin | MySQL/MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
| Entier auto | INT AUTO_INCREMENT |
GENERATED ALWAYS AS IDENTITY |
INTEGER PRIMARY KEY |
| Texte court | VARCHAR(100) |
VARCHAR(100) / TEXT |
TEXT (longueur non contrainte) |
| Décimal exact | DECIMAL(10,2) |
NUMERIC(10,2) |
NUMERIC (affinité seule, précision non garantie) |
| Date + heure | DATETIME |
TIMESTAMP |
TEXT (ISO8601) |
| Booléen | TINYINT(1) |
BOOLEAN |
INTEGER (0/1) |
- Contraintes :
PRIMARY KEY·FOREIGN KEY·NOT NULL·UNIQUE·DEFAULT·CHECK ⚠️ Jamais deFLOATpour l'argent →DECIMAL/NUMERIC⚠️ SQLite = typage dynamique (affinité de colonne) : les types déclarés ne sont pas contraignants comme sous MySQL/PostgreSQL
Exécuter le script SQL suivant dans phpMyAdmin :
⚠️ pour créer une database : il faut être root 😉
CREATE DATABASE boutique CHARACTER SET utf8mb4;
USE boutique;
CREATE TABLE clients (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
ville VARCHAR(80),
inscription DATE DEFAULT (CURRENT_DATE)
);
DESCRIBE clients;
ALTER TABLE clients ADD COLUMN telephone VARCHAR(20);- Et si on refait le script ? →
Table already exists
- Dans
boutique, créez :
produits:id(PK auto),nomobligatoire,prix DECIMAL(10,2)avecCHECK (prix >= 0),stock INT DEFAULT 0commandes:id(PK auto),client_idFK → clients,date_commande DATETIME DEFAULT CURRENT_TIMESTAMPlignes_commande:commande_id(FK),produit_id(FK),quantite INT NOT NULL, PK composée(commande_id, produit_id)
- Vérifiez :
SHOW CREATE TABLE commandes; - Testez la FK : insérer une commande avec
client_id = 999→ que se passe-t-il ? - Bonus : générez le diagramme ER dans Tabularis
INSERT INTO t (col1, col2) VALUES (v1, v2), (v3, v4);
UPDATE t SET col = valeur WHERE condition;
DELETE FROM t WHERE condition;⚠️ Règle d'or :UPDATE/DELETEsansWHERE= toute la table- Réflexe : valider le
WHEREavec unSELECTavant de modifier à la main - Dans du code : bien check si on a une clause
WHERE
flowchart LR
A[SELECT ... WHERE ...] --> B{Bonnes lignes ?}
B -->|oui| C[UPDATE / DELETE<br/>avec le même WHERE]
B -->|non| A
INSERT INTO clients (nom, email, ville) VALUES
('Alice Martin', 'alice@mail.fr', 'Montpellier'),
('Karim Benali', 'karim@mail.fr', 'Lyon'),
('Chloé Dubois', 'chloe@mail.fr', 'Montpellier'),
('Jean Petit', 'jean@mail.fr', NULL);
SELECT * FROM clients;
-- 1. viser
SELECT * FROM clients WHERE email = 'jean@mail.fr';
-- 2. tirer
UPDATE clients SET ville = 'Nantes' WHERE email = 'jean@mail.fr';- Échec volontaire : réinsérer
alice@mail.fr→ erreurUNIQUE
- Insérez 6 produits (clavier, souris, écran, casque, webcam, hub USB), prix et stocks variés
- Insérez 4 commandes (3 clients différents) + leurs
lignes_commande(2-3 produits chacune) - Promo : baissez le prix du clavier de 10 % (
UPDATEavec calcul) - Supprimez le produit jamais commandé (à vous de le trouver)
- Piège : supprimez un client qui a des commandes → expliquez l'erreur FK
- Livrable : un fichier
seed.sqlrejouable (avecDROP ... IF EXISTS)
SELECT colonnes | *
FROM table
WHERE condition -- =, <>, <, >, AND, OR, NOT
-- LIKE 'A%', IN (...), BETWEEN, IS NULL
ORDER BY col ASC|DESC
LIMIT n OFFSET m;DISTINCT: dédoublonner · alias avecAS- Ordre logique d'exécution ≠ ordre d'écriture :
flowchart LR
F[FROM] --> W[WHERE] --> S[SELECT] --> O[ORDER BY] --> L[LIMIT]
- Conséquence : un alias du
SELECTest utilisable dansORDER BY, pas dansWHERE
-- Clients de Montpellier
SELECT nom, email FROM clients WHERE ville = 'Montpellier';
-- Produits entre 20 et 100 €, du plus cher au moins cher
SELECT nom, prix FROM produits
WHERE prix BETWEEN 20 AND 100
ORDER BY prix DESC;
-- Emails en .fr, top 3
SELECT nom FROM clients WHERE email LIKE '%.fr' LIMIT 3;
-- Le piège NULL
SELECT * FROM clients WHERE ville = NULL; -- 0 ligne !
SELECT * FROM clients WHERE ville IS NULL; -- ✅- Produits en rupture ou stock faible (
stock < 5), triés par stock croissant - Produits dont le nom contient « usb » (insensible à la casse ?)
- Les 2 produits les plus chers — puis les 3e et 4e (
LIMIT ... OFFSET) - Liste des villes distinctes des clients, sans les
NULL - Clients inscrits cette année (
WHERE inscription >= '2026-01-01')
- Pour chaque requête : prédire le nombre de lignes avant d'exécuter
- Fonctions d'agrégation :
COUNT,SUM,AVG,MIN,MAX GROUP BY: une ligne de résultat par groupeWHEREfiltre les lignes avant groupage ;HAVINGfiltre les groupes après
flowchart LR
F[FROM] --> W[WHERE<br/>filtre lignes] --> G[GROUP BY] --> H[HAVING<br/>filtre groupes] --> S[SELECT] --> O[ORDER BY]
- Règle : toute colonne du
SELECThors agrégat doit être dans leGROUP BY COUNT(*)compte les lignes ·COUNT(col)ignore lesNULL
-- Combien de clients par ville ?
SELECT ville, COUNT(*) AS nb
FROM clients
GROUP BY ville
ORDER BY nb DESC;
-- Prix moyen, min, max du catalogue
SELECT ROUND(AVG(prix),2) AS prix_moyen,
MIN(prix) AS mini, MAX(prix) AS maxi
FROM produits;
-- Villes avec au moins 2 clients
SELECT ville, COUNT(*) AS nb
FROM clients
GROUP BY ville
HAVING nb >= 2; -- PostgreSQL : HAVING COUNT(*) >= 2- Nombre de commandes par client (
client_id) - Quantité totale vendue par produit (table
lignes_commande) - Valeur du stock :
SUM(prix * stock)surproduits - Produits commandés plus de 2 fois au total (→
HAVING) - Question piège : pourquoi
WHERE COUNT(*) > 2est-il refusé ?
- Relier les tables via les clés : c'est le superpouvoir du relationnel
SELECT c.nom, o.date_commande
FROM clients c
INNER JOIN commandes o ON o.client_id = c.id;INNER JOIN: seulement les correspondancesLEFT JOIN: tout à gauche + correspondances (sinonNULL)RIGHT JOIN: miroir du LEFT ·FULL OUTER JOIN: tout (PG ✅, MySQL ❌)
flowchart TB
subgraph INNER
A1[Clients AVEC commandes]
end
subgraph LEFT
B1[Tous les clients]
B2[+ commandes si elles existent, sinon NULL]
end
-- Qui a commandé quoi, quand ?
SELECT c.nom, p.nom AS produit, l.quantite, o.date_commande
FROM commandes o
INNER JOIN clients c ON o.client_id = c.id
INNER JOIN lignes_commande l ON l.commande_id = o.id
INNER JOIN produits p ON p.id = l.produit_id
ORDER BY o.date_commande;
-- Clients SANS commande : le pattern anti-jointure
SELECT c.nom
FROM clients c
LEFT JOIN commandes o ON o.client_id = c.id
WHERE o.id IS NULL;- On dessine le chemin des jointures sur le diagramme ER de Tabularis
- Chiffre d'affaires par client :
SUM(quantite * prix)(3 tables +GROUP BY) - Produits jamais commandés (
LEFT JOIN ... IS NULL) - Pour chaque commande : nombre de lignes et montant total
- Top 3 des produits par quantité vendue
- Défi : clients ayant commandé et l'écran et la souris
- Vérifiez chaque total à la main sur vos données de seed — un
JOINmal fait multiplie les lignes !
- Sous-requête : un
SELECTdans un autre
SELECT nom, prix FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);-
CTE (
WITH) : nommer une étape intermédiaire → lisibilitéCTE = Common Table Expression = Expression de Table Commune = une sélection temporaire qui permet de faire une autre sélection C'est un
SELECTdans unSELECT
WITH ca_client AS (
SELECT o.client_id, SUM(l.quantite * p.prix) AS ca
FROM commandes o
JOIN lignes_commande l ON l.commande_id = o.id
JOIN produits p ON p.id = l.produit_id
GROUP BY o.client_id
)
SELECT c.nom, ca_client.ca
FROM ca_client JOIN clients c ON c.id = ca_client.client_id;- Règle d'équipe : dès 2 niveaux d'imbrication → CTE = dès qu'on va devoir faire 2 niveaux d'imbrication, on fait une CTE
-- Sous-requête scalaire : produits au-dessus du prix moyen
SELECT nom, prix FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);
-- IN : clients ayant commandé
SELECT nom FROM clients
WHERE id IN (SELECT DISTINCT client_id FROM commandes);- On réécrit ensemble la 2e en
JOIN, puis en CTE - Discussion : quand la lisibilité prime-t-elle sur la concision ?
- Sous-requête : commandes dont le montant dépasse le montant moyen des commandes
- Réécrivez l'exercice « CA par client » de la notion 6 en CTE
- Enchaînez deux CTE :
ca_clientpuistop_clients(CA > moyenne des CA) - Comparez lisibilité : version imbriquée vs version CTE — laquelle défendriez-vous en revue de code ?
- Vue = requête nommée, interrogeable comme une table
CREATE VIEW v_ca_client AS
SELECT o.client_id, SUM(l.quantite * p.prix) AS ca
FROM commandes o
JOIN lignes_commande l ON l.commande_id = o.id
JOIN produits p ON p.id = l.produit_id
GROUP BY o.client_id;- Index = structure (B-tree) qui accélère les recherches
flowchart TB
R[Racine B-tree] --> N1[A - K]
R --> N2[L - Z]
N1 --> F1[ligne 'Benali']
N2 --> F2[ligne 'Martin']
- Coût : écritures plus lentes, espace disque → indexer les colonnes de
WHERE/JOIN - EXPLAIN : le plan d'exécution — full scan ou index scan ?
-- Générer du volume pour "sentir" l'index (MariaDB, le moteur du docker-compose)
SET SESSION max_recursive_iterations = 600000; -- sinon la CTE récursive est limitée à 1000 lignes
CREATE TABLE mesures AS
WITH RECURSIVE seq AS (
SELECT 1 AS id
UNION ALL
SELECT id + 1 FROM seq WHERE id < 500000
)
SELECT id, FLOOR(RAND()*1000) AS capteur, RAND()*40 AS temperature
FROM seq;
-- ⚠️ MariaDB n'a PAS "EXPLAIN ANALYZE" (syntaxe MySQL 8+) : elle utilise ANALYZE [FORMAT=JSON]
ANALYZE SELECT * FROM mesures WHERE capteur = 42;
-- → type = ALL (scan complet des ~500 000 lignes)
CREATE INDEX idx_mesures_capteur ON mesures(capteur);
ANALYZE SELECT * FROM mesures WHERE capteur = 42;
-- → type = ref (recherche via l'index) 🎉Sur un vrai MySQL (≥ 8.0.18), la variable s'appelle
cte_max_recursion_depthet la commande est bienEXPLAIN ANALYZE. Testé ci-dessus directement sur le conteneurmariadbdudocker-compose.yml: les deux divergent.
-- Générer du volume pour "sentir" l'index (PostgreSQL)
CREATE TABLE mesures AS
SELECT g AS id, (random()*1000)::int AS capteur, random()*40 AS temperature
FROM generate_series(1, 500000) g;
EXPLAIN ANALYZE SELECT * FROM mesures WHERE capteur = 42;
-- → Seq Scan
CREATE INDEX idx_mesures_capteur ON mesures(capteur);
EXPLAIN ANALYZE SELECT * FROM mesures WHERE capteur = 42;
-- → Index Scan 🎉- On ouvre l'EXPLAIN visuel de Tabularis : lire l'arbre au lieu du texte brut
- Créez la vue
v_stock_faible(produits avecstock < 5) et interrogez-la - Sur
mesures: mesurez le temps d'une requêteWHERE temperature > 39.5avant/après un index surtemperature EXPLAINune requête avecJOINsurboutique: identifiez le type de scan sur chaque table- Question : pourquoi ne pas indexer toutes les colonnes ?
- Livrable : capture de l'EXPLAIN visuel avant/après + 3 lignes d'analyse
- Atomicité · Cohérence · Isolation · Durabilité
- Tout ou rien : indispensable dès qu'une opération touche plusieurs tables
sequenceDiagram
participant App
participant DB
App->>DB: BEGIN
App->>DB: UPDATE produits SET stock = stock - 2 ...
App->>DB: INSERT INTO lignes_commande ...
alt tout est OK
App->>DB: COMMIT ✅
else erreur
App->>DB: ROLLBACK ↩️ (rien n'est appliqué)
end
- MySQL : moteur
InnoDBrequis · autocommit actif par défaut dans les deux SGBD
BEGIN; -- START TRANSACTION sous MySQL
UPDATE produits SET stock = stock - 2 WHERE id = 1;
SELECT stock FROM produits WHERE id = 1; -- modifié... pour NOUS
ROLLBACK;
SELECT stock FROM produits WHERE id = 1; -- revenu à l'état initial- Démo d'isolation : deux onglets SQL Tabularis = deux sessions
- Session A :
BEGIN+UPDATEsans commit - Session B : que voit-elle ? (spoiler : l'ancienne valeur)
- Session A :
COMMIT→ session B voit la nouvelle valeur
- Session A :
- Écrivez la transaction « passer une commande » : créer la commande, insérer 2 lignes, décrémenter les stocks — le tout dans un
BEGIN ... COMMIT - Rejouez-la avec un produit inexistant au milieu → vérifiez que
ROLLBACKannule tout - Ajoutez un
CHECK (stock >= 0)surproduitspuis tentez de vendre plus que le stock : que se passe-t-il ? - Expliquez en 2 phrases pourquoi un virement bancaire est l'exemple canonique de transaction
| Aspect | MySQL / MariaDB | PostgreSQL |
|---|---|---|
| Auto-incrément | AUTO_INCREMENT |
IDENTITY / SERIAL |
| Guillemets identifiants | `backticks` |
"doubles quotes" |
| Casse des noms | selon l'OS | minuscules par défaut |
FULL OUTER JOIN |
❌ (contournement UNION) |
✅ |
| Types avancés | JSON | JSONB, arrays, types custom |
| Concaténation | CONCAT() |
` |
| Réputation | web, simplicité | rigueur, data engineering |
- MariaDB = fork de MySQL (2010) : très compatible, gouvernance communautaire
- En Data/IA, PostgreSQL est devenu le choix par défaut — mais on croise les deux partout
- Les deux stacks tournent en parallèle (ports différents) — deux connexions Tabularis côte à côte
- On rejoue
seed.sqlsur PostgreSQL et on corrige en direct :
-- MySQL -- PostgreSQL
id INT AUTO_INCREMENT id INT GENERATED ALWAYS AS IDENTITY
DATETIME TIMESTAMP
`nom` "nom"
DESCRIBE clients; \d clients (ou l'arborescence Tabularis)- Même requête d'agrégation sur les deux → mêmes résultats, dialectes différents
- Portez tout votre
seed.sqlvers PostgreSQL (seed_pg.sql) - Vérifiez que vos 5 requêtes de la notion 6 donnent des résultats identiques sur les deux moteurs
- Trouvez une requête qui passe sur PostgreSQL mais pas sur MySQL (indice :
FULL OUTER JOIN) et écrivez son contournement MySQL - Tableau récap : 5 différences rencontrées par vous pendant le portage
- Vous avez rencontré le problème en Notion 3 : « supprimez un client qui a des commandes → erreur FK »
- Et en Notion 9 : les transactions garantissent le tout ou rien
- Ce chapitre bonus réunit les deux : que fait la base quand une ligne référencée disparaît ? Et comment garder la main quand ça se propage ?
Une FK peut déclarer ce qui se passe quand la ligne parente est supprimée (ON DELETE) ou que sa PK change (ON UPDATE) :
| Action | Comportement | Cas d'usage typique |
|---|---|---|
RESTRICT / NO ACTION |
❌ Refuse l'opération (c'est le défaut) | Protéger : un client avec commandes ne peut pas disparaître |
CASCADE |
🌊 Propage : supprime/modifie aussi les enfants | lignes_commande n'ont aucun sens sans leur commande |
SET NULL |
La FK des enfants passe à NULL |
Commande orpheline mais conservée (client anonymisé) |
SET DEFAULT |
La FK prend sa valeur DEFAULT |
Réaffecter à un « client générique » (PG ✅, MySQL/InnoDB ❌) |
flowchart TB
A[DELETE FROM clients WHERE id = 3] --> B{ON DELETE ?}
B -->|RESTRICT| C[❌ Erreur FK<br/>rien n'est supprimé]
B -->|CASCADE| D[🌊 Le client ET ses commandes<br/>ET leurs lignes disparaissent]
B -->|SET NULL| E[Client supprimé<br/>commandes.client_id = NULL]
⚠️ La cascade peut traverser plusieurs niveaux :clients→commandes→lignes_commande. Un seulDELETEpeut vider trois tables.⚠️ SET NULLexige que la colonne FK soit nullable (pas deNOT NULL)ON UPDATE CASCADEest rarement utile avec des PK auto-incrémentées (on ne change jamais unid)... mais devient précieux avec des clés naturelles (un code, une référence)
CREATE TABLE commandes (
id INT AUTO_INCREMENT PRIMARY KEY,
client_id INT,
date_commande DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id)
ON DELETE SET NULL
ON UPDATE CASCADE
);
CREATE TABLE lignes_commande (
commande_id INT NOT NULL,
produit_id INT NOT NULL,
quantite INT NOT NULL,
PRIMARY KEY (commande_id, produit_id),
FOREIGN KEY (commande_id) REFERENCES commandes(id)
ON DELETE CASCADE, -- une ligne sans commande n'existe pas
FOREIGN KEY (produit_id) REFERENCES produits(id)
ON DELETE RESTRICT -- on protège le catalogue
);| Aspect | MySQL / MariaDB (InnoDB) | PostgreSQL | SQLite |
|---|---|---|---|
| Défaut si rien n'est écrit | RESTRICT |
NO ACTION |
NO ACTION |
SET DEFAULT |
❌ (InnoDB refuse) | ✅ | ✅ |
| FK actives par défaut | ✅ | ✅ | PRAGMA foreign_keys = ON; à chaque connexion |
| Modifier l'action après coup | DROP FOREIGN KEY puis ADD |
DROP CONSTRAINT puis ADD |
Impossible → recréer la table |
⚠️ Piège SQLite classique : vos FK (et donc vos cascades) sont ignorées tant que lePRAGMAn'est pas activé. Tabularis et sqlite-web ne le font pas forcément pour vous.
- Une cascade est atomique par nature : parent + enfants disparaissent dans la même instruction. Si un enfant ne peut pas être supprimé (FK
RESTRICTplus bas), tout est annulé. - Mais une cascade est aussi silencieuse : le
DELETEne vous dit pas combien de lignes filles ont disparu. D'où le réflexe pro :
sequenceDiagram
participant Vous
participant DB
Vous->>DB: BEGIN
Vous->>DB: DELETE FROM clients WHERE id = 3
Vous->>DB: SELECT COUNT(*) FROM commandes / lignes_commande
alt dégâts conformes aux attentes
Vous->>DB: COMMIT ✅
else surprise (trop de lignes disparues)
Vous->>DB: ROLLBACK ↩️ tout revient
end
- C'est le pattern « viser / tirer » de la Notion 3, version destruction massive : la transaction est votre filet, le
ROLLBACKvotre bouton annuler
- Un
ROLLBACKannule toute la transaction. UnSAVEPOINTpose un point de sauvegarde intermédiaire :
BEGIN;
DELETE FROM clients WHERE id = 3; -- cascade OK
SAVEPOINT apres_client;
DELETE FROM produits WHERE stock = 0; -- oups, erreur FK RESTRICT
ROLLBACK TO SAVEPOINT apres_client; -- annule SEULEMENT le 2e DELETE
COMMIT; -- le 1er DELETE est validé- Supporté par MySQL, PostgreSQL et SQLite — enfin une syntaxe commune 🎉
On repart de boutique (MySQL, phpMyAdmin ou Tabularis). Nos FK actuelles sont en RESTRICT implicite : modifions-les.
-- Retrouver le nom de la contrainte existante
SHOW CREATE TABLE lignes_commande;
-- La remplacer (adaptez le nom : lignes_commande_ibfk_1, etc.)
ALTER TABLE lignes_commande DROP FOREIGN KEY lignes_commande_ibfk_1;
ALTER TABLE lignes_commande
ADD CONSTRAINT fk_lc_commande
FOREIGN KEY (commande_id) REFERENCES commandes(id)
ON DELETE CASCADE;BEGIN; -- START TRANSACTION sous MySQL
SELECT COUNT(*) FROM lignes_commande; -- avant : notez le chiffre
DELETE FROM commandes WHERE id = 1; -- plus d'erreur FK !
SELECT COUNT(*) FROM lignes_commande; -- les lignes ont suivi 🌊
SELECT * FROM lignes_commande WHERE commande_id = 1; -- 0 ligne
ROLLBACK; -- on annule tout
SELECT COUNT(*) FROM lignes_commande; -- tout est revenu ✅- Observation clé : le
ROLLBACKrestaure aussi les lignes supprimées par cascade — l'atomicité couvre les effets en chaîne
ALTER TABLE commandes DROP FOREIGN KEY commandes_ibfk_1;
ALTER TABLE commandes
ADD CONSTRAINT fk_cmd_client
FOREIGN KEY (client_id) REFERENCES clients(id)
ON DELETE SET NULL;
BEGIN;
DELETE FROM clients WHERE email = 'karim@mail.fr';
SELECT id, client_id FROM commandes; -- ses commandes existent, client_id = NULL
ROLLBACK;- Discussion : dans une vraie boutique, que préférez-vous pour un client qui exerce son droit RGPD à l'effacement —
CASCADE(perdre l'historique de CA) ouSET NULL(anonymiser) ?
- Cartographiez vos FK : pour chacune des 4 relations de
boutique, choisissezRESTRICT,CASCADEouSET NULLet justifiez en une phrase. Appliquez vos choix parALTER TABLE. - Cascade à deux étages : mettez
clients → commandesenCASCADE(temporairement), puis dans une transaction, supprimez un client et prouvez avec desCOUNT(*)que trois tables ont été touchées par un seulDELETE. Terminez parROLLBACK. - Le piège SQLite : sur
./sqlite-data/DB_Cours.db, créez deux tables liées avecON DELETE CASCADE, supprimez un parent sans lePRAGMA, constatez, puis refaites avecPRAGMA foreign_keys = ON;. Que concluez-vous pour vos scripts ? - SAVEPOINT : réécrivez la transaction « passer une commande » de la Notion 9 avec un
SAVEPOINTaprès la création de la commande : si l'insertion des lignes échoue, revenez au savepoint et insérez une ligne dans une tablecommandes_en_erreuravant deCOMMIT. - Portage : rejouez l'exercice 1 sur PostgreSQL (
DROP CONSTRAINTau lieu deDROP FOREIGN KEY). Ajoutez la ligne « actions référentielles » à votre tableau de différences de la Notion 10. - Question débat (lien avec le module MCP) : faut-il autoriser un agent IA à exécuter des
DELETEsur des tables portant desON DELETE CASCADE? Quelles protections cumuleriez-vous ?
- Livrable : un fichier
cascade.sqlrejouable + 5 lignes de justification de vos choix d'actions référentielles
- Le défaut (
RESTRICT) protège,CASCADEpropage,SET NULLdétache — le bon choix est métier, pas technique - Une cascade est atomique mais silencieuse → toujours l'essayer sous transaction avec des
COUNT(*)avant/après ROLLBACKannule aussi les suppressions en chaîne ;SAVEPOINTpermet un retour partiel- SQLite n'applique les FK qu'avec
PRAGMA foreign_keys = ON;— à mettre en tête de tous vos scripts - Réflexe agent IA : jamais de
CASCADEaccessible en écriture à un LLM sans approval gate
- Jusqu'ici, la logique vivait côté client : c'est vous (ou votre code Python) qui enchaînez les requêtes
- Une procédure stockée déplace cette logique dans la base : un bloc de SQL nommé, paramétrable, exécuté par un simple
CALL - Lien direct avec la Notion 9 : la transaction « passer une commande » (créer la commande, insérer les lignes, décrémenter le stock) est exactement le genre de logique qu'on encapsule dans une procédure
flowchart LR
subgraph SANS[Sans procédure]
A[App] -->|BEGIN| DB1[(DB)]
A -->|INSERT commande| DB1
A -->|INSERT lignes| DB1
A -->|UPDATE stock| DB1
A -->|COMMIT| DB1
end
subgraph AVEC[Avec procédure]
B[App] -->|"CALL passer_commande(3, 1, 2)"| DB2[(DB)]
end
Procédure (PROCEDURE) |
Fonction (FUNCTION) |
|
|---|---|---|
| Appel | CALL ma_proc(...) |
Dans une requête : SELECT ma_fonction(...) |
| Retour | Rien (ou via paramètres OUT) |
Une valeur (ou une table) |
| Transactions internes | ✅ (PG 11+ : COMMIT/ROLLBACK dedans) |
❌ |
| Vocation | Faire (écrire, orchestrer) | Calculer (renvoyer un résultat) |
IN: entrée (défaut) — la procédure lit la valeurOUT: sortie — la procédure écrit dedans, l'appelant récupèreINOUT: les deux
| Aspect | MySQL / MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
| Procédures stockées | ✅ | ✅ (v11+) | ❌ aucune |
| Langage | SQL/PSM (BEGIN ... END) |
PL/pgSQL (+ Python, Perl...) | — |
| Séparateur | DELIMITER $$ obligatoire dans les clients |
Corps entre $$ ... $$ (dollar-quoting) |
— |
| Appel | CALL proc(...) |
CALL proc(...) |
— |
| Variables | DECLARE x INT; |
DECLARE x INT; (section dédiée) |
— |
⚠️ Le piège n°1 sous MySQL : le client coupe votre procédure au premier;interne. D'où leDELIMITER $$avant,DELIMITER ;après. PostgreSQL contourne élégamment avec le dollar-quoting$$ ... $$⚠️ SQLite n'a pas de procédures : la logique reste côté application (Python). C'est un critère de choix de SGBD !
IF condition THEN ... ELSEIF ... ELSE ... END IF;
WHILE condition DO ... END WHILE; -- MySQL
WHILE condition LOOP ... END LOOP; -- PostgreSQL- ✅ Atomicité garantie côté serveur : impossible d'oublier le
COMMITou de sauter une étape - ✅ Un seul aller-retour réseau au lieu de 5 → performance
- ✅ Sécurité : on peut donner le droit
EXECUTEsur la procédure sans donnerUPDATEsur les tables — une ceinture de plus pour un agent IA 👀 - ❌ Logique métier éparpillée entre app et base → difficile à versionner, tester, déboguer
- ❌ Dialecte non portable : une procédure MySQL ne tourne pas telle quelle sur PostgreSQL
- Verdict d'équipe : parfait pour les opérations transactionnelles critiques proches des données ; à éviter pour la logique métier qui évolue souvent
DELIMITER $$
CREATE PROCEDURE restocker(IN p_produit_id INT, IN p_quantite INT)
BEGIN
UPDATE produits
SET stock = stock + p_quantite
WHERE id = p_produit_id;
END $$
DELIMITER ;
-- Utilisation
SELECT stock FROM produits WHERE id = 1;
CALL restocker(1, 50);
SELECT stock FROM produits WHERE id = 1; -- +50 ✅DELIMITER $$
CREATE PROCEDURE valeur_stock(OUT p_total DECIMAL(12,2))
BEGIN
SELECT SUM(prix * stock) INTO p_total FROM produits;
END $$
DELIMITER ;
CALL valeur_stock(@total); -- @total = variable de session MySQL
SELECT @total;DELIMITER $$
CREATE PROCEDURE passer_commande(
IN p_client_id INT,
IN p_produit_id INT,
IN p_quantite INT
)
BEGIN
DECLARE v_stock INT;
-- En cas d'erreur SQL : tout annuler puis propager l'erreur
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
SELECT stock INTO v_stock FROM produits WHERE id = p_produit_id FOR UPDATE;
IF v_stock IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Produit inconnu';
ELSEIF v_stock < p_quantite THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Stock insuffisant';
END IF;
INSERT INTO commandes (client_id) VALUES (p_client_id);
INSERT INTO lignes_commande (commande_id, produit_id, quantite)
VALUES (LAST_INSERT_ID(), p_produit_id, p_quantite);
UPDATE produits SET stock = stock - p_quantite WHERE id = p_produit_id;
COMMIT;
END $$
DELIMITER ;- On teste les trois chemins :
CALL passer_commande(1, 2, 1); -- ✅ commande créée, stock décrémenté
CALL passer_commande(1, 999, 1); -- ❌ 'Produit inconnu'
CALL passer_commande(1, 2, 9999); -- ❌ 'Stock insuffisant'
-- Vérification : les échecs n'ont laissé AUCUNE trace
SELECT * FROM commandes ORDER BY id DESC LIMIT 3;- Trois nouveautés au passage :
SELECT ... INTO variable: stocker un résultatFOR UPDATE: verrouille la ligne le temps de la transaction (lien Notion 9, isolation)SIGNAL: lever une erreur métier · leHANDLER= letry/exceptdu SQL
CREATE OR REPLACE PROCEDURE restocker(p_produit_id INT, p_quantite INT)
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE produits
SET stock = stock + p_quantite
WHERE id = p_produit_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Produit % inconnu', p_produit_id;
END IF;
END;
$$;
CALL restocker(1, 50);- À noter : pas de
DELIMITER(merci le dollar-quoting),RAISE EXCEPTIONremplaceSIGNAL, etFOUNDteste si l'UPDATEa touché une ligne - On compare les deux versions côte à côte dans Tabularis : même intention, deux dialectes — l'écart est plus grand qu'en SQL « classique »
archiver_client(p_client_id): procédure MySQL qui passe la ville du client à'--archivé--'et son email àCONCAT('archive_', id, '@nulle.part'). Levez une erreurSIGNALsi le client n'existe pas.stats_client(IN p_client_id, OUT p_nb_commandes, OUT p_ca): renvoie le nombre de commandes et le CA du client (réutilisez votre requête CA de la Notion 6). Testez avecCALL stats_client(1, @nb, @ca); SELECT @nb, @ca;- Améliorez
passer_commande: ajoutez un paramètreOUT p_commande_idqui renvoie l'id de la commande créée, puis un second produit (deuxINSERTde lignes) — vérifiez qu'un échec sur le 2e produit annule bien le 1er. - Portage : réécrivez
stats_clienten PostgreSQL. Question : en PG, serait-ce plutôt uneFUNCTIONqu'unePROCEDURE? Pourquoi ? - Sécurité & IA : créez un utilisateur MySQL qui a uniquement
EXECUTEsurpasser_commande(aucun droit sur les tables) :
CREATE USER 'app_commande'@'%' IDENTIFIED BY '********';
GRANT EXECUTE ON PROCEDURE boutique.passer_commande TO 'app_commande'@'%';Connectez-vous avec ce compte : SELECT * FROM produits échoue, mais CALL passer_commande(...) fonctionne. Expliquez en 3 lignes pourquoi c'est une excellente ceinture de sécurité pour un agent IA connecté via MCP.
6. Débat (5 min) : votre binôme soutient que « toute la logique doit être dans des procédures stockées ». Trouvez deux arguments pour, deux contre, en pensant au versionning Git et aux tests.
- Livrable : un fichier
procedures.sqlrejouable (avecDROP PROCEDURE IF EXISTS ...) + vos réponses aux questions 4 et 5
- Une procédure = de la logique SQL nommée et paramétrée, exécutée par
CALL, au plus près des données IN/OUT/INOUTpour dialoguer ·SIGNAL/RAISE+HANDLERpour les erreurs métier- Le trio gagnant : procédure + transaction +
FOR UPDATE= opération métier atomique et concurrente-safe - MySQL exige
DELIMITER, PostgreSQL préfère PL/pgSQL et$$, SQLite n'en a pas du tout GRANT EXECUTEsans droit sur les tables : le principe du moindre privilège, taillé pour les agents IA- Contrepartie : dialecte non portable et logique hors de Git — à réserver aux opérations critiques
- MCP (Model Context Protocol) : protocole standard pour connecter un LLM à des outils
- Tabularis embarque un serveur MCP : Claude peut lire le schéma et exécuter des requêtes via Tabularis
sequenceDiagram
participant U as Vous
participant C as Claude Desktop
participant T as Tabularis (mode --mcp, stdio)
participant DB as MySQL / PostgreSQL
U->>C: "Quel est le CA par client ?"
C->>T: list_tables / lecture du schéma
T->>DB: requêtes de métadonnées
C->>T: execute_query(SELECT ...)
T->>DB: SQL
DB-->>T: résultats
T-->>C: résultats
C-->>U: réponse + SQL utilisé
- Tout est local (stdio, pas de port réseau) ; les identifiants restent dans Tabularis, jamais dans le chat
⚠️ L'agent peut exécuter du SQL destructeur → Read-only Mode, Approval Gates, ou utilisateur SQL en lecture seule
- Tabularis → Settings → MCP Server Integration
- Install Config en un clic pour Claude Desktop → redémarrer Claude
- Activer le Read-only Mode (on est prudents)
- Dans Claude : « Liste les tables de ma base boutique » → il appelle
list_tables - Puis : « Quel est le chiffre d'affaires par client ? Montre-moi le SQL »
- On relit ensemble la requête générée : jointures correctes ?
GROUP BYcomplet ? - On la rejoue à la main dans l'éditeur pour valider
- Message clé : l'IA rédige, l'humain valide — votre SQL sert à auditer le sien
- Configurez le serveur MCP sur votre poste (mode read-only obligatoire)
- Demandez à Claude 3 requêtes de difficulté croissante sur
boutique:- une simple (filtre), une agrégation, une multi-jointures
- Pour chacune : la requête est-elle correcte ? optimale ? Corrigez-la si besoin
- Créez un utilisateur MySQL en lecture seule et expliquez pourquoi c'est une seconde ceinture de sécurité :
CREATE USER 'claude_ro'@'%' IDENTIFIED BY '********';
GRANT SELECT ON boutique.* TO 'claude_ro'@'%';- Débat (5 min) : que doit-on interdire à un agent IA sur une base de production ?
- En binôme, 3 h — base au choix : bibliothèque, covoiturage, suivi sportif, capteurs IoT...
- Livrables :
- schéma ER (export Tabularis) : 4 tables minimum, FK, contraintes
schema.sql+seed.sqlrejouables sur MySQL et PostgreSQL- 8 requêtes commentées : filtres, agrégation, 2 jointures min., 1 CTE, 1 vue
- 1 transaction métier justifiée
- 1
EXPLAINavant/après index avec analyse - bonus : notebook Tabularis avec graphique + 1 requête co-écrite avec Claude (et critiquée !)
- SQL : 50 ans, toujours central — surtout à l'ère des agents IA
- Réflexes acquis :
WHEREavantUPDATE· CTE pour la lisibilité ·EXPLAINavant d'optimiser · transactions pour l'intégrité · IA supervisée, jamais autonome sur les données - Pour aller plus loin :
- docs officielles PostgreSQL & MySQL (références absolues)
sqlbolt.com,pgexercises.com: entraînement interactif- Tabularis :
tabularis.dev(wiki MCP inclus) - spec MCP :
modelcontextprotocol.io
- Prochain module : SQL analytique (fonctions de fenêtrage) & intégration Python (
sqlalchemy,pandas.read_sql)
