Requêtes SQL : 7 cas d'usage métier
Top N par catégorie, pivot mensuel, détection de doublons, fenêtrage, recherche fuzzy, agrégation conditionnelle, JSONB. Le SQL en situation réelle.
À la fin du cours, tu sais
- Sortir un top N par catégorie avec ROW_NUMBER
- Faire un pivot de données (matrice mensuelle)
- Détecter et compter des doublons proprement
- Calculer une évolution mois sur mois avec LAG
- Faire de la recherche fuzzy avec pg_trgm
- Utiliser l'agrégation conditionnelle (FILTER)
- Interroger du JSONB avec des index efficaces
Prérequis
- Avoir suivi « Intro SQL » ou maîtriser les bases (SELECT, JOIN, GROUP BY)
- Avoir accès à un PostgreSQL 16 (ou MySQL 8) pour tester
Chapitre 1
Cas 1 · Top N par catégorie
Énoncé : sortir les 3 produits les plus vendus dans chaque catégorie. Un grand classique des questions d'entretien.
Schéma
products(id, name, category_id)
order_items(product_id, quantity)Requête
SELECT category_id, name, total
FROM (
SELECT
p.category_id,
p.name,
SUM(oi.quantity) AS total,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY SUM(oi.quantity) DESC
) AS rn
FROM products p
JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.category_id, p.name
) t
WHERE rn <= 3;La logique
- On agrège d'abord les ventes par produit (
GROUP BY) - On numérote ensuite avec
ROW_NUMBERpar catégorie - On filtre le numéro dans une sous-requête, pas directement dans le
WHERE(les fenêtres sont évaluées aprèsWHERE)
ROW_NUMBER vs RANK
ROW_NUMBER par RANK, les ex-aequo gardent le même rang et la requête peut renvoyer plus de 3 lignes par catégorie. Choisis selon le besoin : « exactement 3 par catégorie » = ROW_NUMBER, « tout ce qui est dans le top 3 » = RANK.Chapitre 2
Cas 2 · Pivot en matrice mensuelle
Énoncé : chiffre d'affaires par produit (lignes) et par mois (colonnes) sur l'année 2026.
Schéma
orders(id, created_at)
order_items(order_id, product_id, price, quantity)Requête PostgreSQL (avec FILTER)
SELECT
p.name,
SUM(oi.price * oi.quantity)
FILTER (WHERE EXTRACT(MONTH FROM o.created_at) = 1) AS jan,
SUM(oi.price * oi.quantity)
FILTER (WHERE EXTRACT(MONTH FROM o.created_at) = 2) AS fev,
SUM(oi.price * oi.quantity)
FILTER (WHERE EXTRACT(MONTH FROM o.created_at) = 3) AS mars
-- ... etc pour les 12 mois
FROM products p
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
WHERE o.created_at >= '2026-01-01'
AND o.created_at < '2027-01-01'
GROUP BY p.name;Équivalent MySQL 8
-- Pas de FILTER en MySQL, on utilise SUM(CASE WHEN ...)
SUM(CASE WHEN MONTH(o.created_at) = 1 THEN oi.price * oi.quantity ELSE 0 END) AS janPièges classiques
WHERE = scan complet de la table. Toujours borner. 2. En MySQL, oublier le ELSE 0 renvoie NULL, ce qui propage dans les sommes finales.Chapitre 3
Cas 3 · Détection de doublons
Énoncé : trouver les comptes utilisateurs qui ont un email dupliqué (sans considérer la casse ni les espaces).
SELECT
LOWER(TRIM(email)) AS email_norm,
COUNT(*) AS n,
ARRAY_AGG(id ORDER BY id) AS ids
FROM users
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1;La logique
- Normalisation :
LOWER(TRIM(email))pour queAlice@x.fretalice@x.frsoient considérés identiques - GROUP BY sur l'expression normalisée
- HAVING COUNT(*) > 1 pour ne garder que les doublons
- ARRAY_AGG(id) pour récupérer la liste des IDs concernés (utile pour merger ensuite)
Équivalent MySQL
ARRAY_AGG n'existe pas. Utilise GROUP_CONCAT(id ORDER BY id) à la place.Empêcher les futurs doublons
CREATE UNIQUE INDEX idx_users_email_norm ON users (LOWER(TRIM(email)));. PostgreSQL accepte les index sur expressions, MySQL aussi depuis la 8.Chapitre 4
Cas 4 · Évolution mois sur mois avec LAG
Énoncé : pour chaque mois, calculer la variation du CA par rapport au mois précédent.
WITH m AS (
SELECT
DATE_TRUNC('month', created_at) AS mois,
SUM(total) AS ca
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
)
SELECT
mois,
ca,
LAG(ca) OVER (ORDER BY mois) AS ca_precedent,
ROUND(
100.0 * (ca - LAG(ca) OVER (ORDER BY mois))
/ NULLIF(LAG(ca) OVER (ORDER BY mois), 0),
2
) AS evolution_pct
FROM m
ORDER BY mois;Points clés
- CTE intermédiaire pour calculer le CA mensuel avant le fenêtrage
- LAG(ca) OVER (ORDER BY mois) donne la valeur du mois précédent
- NULLIF(x, 0) évite la division par zéro (renvoie NULL, donc le résultat est NULL)
- Pour MySQL 8 : remplace
DATE_TRUNCparDATE_FORMAT(created_at, '%Y-%m-01')
Toujours ORDER BY avec LAG
LAG sans ORDER BY donne un résultat non déterministe (l'ordre des lignes peut changer entre 2 exécutions). C'est une erreur de logique silencieuse, à ne pas oublier.Chapitre 5
Cas 5 · Recherche fuzzy (fautes de frappe)
Énoncé : retrouver un·e client·e malgré une faute de frappe sur le nom. PostgreSQL a une extension dédiée géniale : pg_trgm.
Setup (une seule fois)
-- Activer l'extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Créer un index GIN trigram sur la colonne nom
CREATE INDEX idx_customers_name_trgm
ON customers
USING gin (name gin_trgm_ops);Requête
SELECT
id,
name,
similarity(name, 'sparesoto') AS score
FROM customers
WHERE name % 'sparesoto' -- % = opérateur de similarité trigram
ORDER BY score DESC
LIMIT 10;La logique
pg_trgmdécoupe les chaînes en trigrammes (séquences de 3 caractères) et mesure leur ressemblance%renvoie true si la similarité dépasse un seuil (par défaut 0.3)similarity()donne le score brut, utile pour trier- L'index GIN trgm rend tout ça rapide même sur des tables énormes
Alternative MySQL
pg_trgm. Tu peux utiliser SOUNDEX(name) = SOUNDEX('sparesoto') (phonétique anglais, moyen sur le français), ou un LIKE '%sparesot%' avec un index full-text. C'est moins fin que trigram.Chapitre 6
Cas 6 · Agrégation conditionnelle avec FILTER
Énoncé : par vendeur·euse, nombre de commandes payées, annulées, et taux d'annulation.
SELECT
seller_id,
COUNT(*) FILTER (WHERE status = 'paid') AS payees,
COUNT(*) FILTER (WHERE status = 'cancelled') AS annulees,
COUNT(*) AS total,
ROUND(
100.0 * COUNT(*) FILTER (WHERE status = 'cancelled')
/ NULLIF(COUNT(*), 0),
1
) AS taux_annul_pct
FROM orders
GROUP BY seller_id
ORDER BY total DESC;Pourquoi FILTER plutôt que WHERE
Avec un WHERE status='paid', tu filtres toutes les lignes : tu ne peux plus compter les annulées en même temps. FILTER applique le filtre uniquement à l'agrégat, en gardant les autres dans le résultat.
Équivalent legacy (compatibilité large)
-- Le vieux pattern, qui marche partout
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS payees,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS annuleesFILTER, le bon réflexe
FILTER, snif).Chapitre 7
Cas 7 · Recherche dans du JSONB
Énoncé : trouver toutes les commandes dont les métadonnées (champ JSONB) contiennent source = "newsletter".
Schéma
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
meta JSONB NOT NULL DEFAULT '{}'
);
-- Exemple de meta : {"source": "newsletter", "utm": {"campaign": "spring2026"}}Requête optimale
SELECT
id,
meta->>'source' AS source,
meta->'utm'->>'campaign' AS campagne
FROM orders
WHERE meta @> '{"source": "newsletter"}'::jsonb;L'index qui change tout
CREATE INDEX idx_orders_meta
ON orders
USING gin (meta jsonb_path_ops);Les opérateurs JSONB clés
->: accède à une clé JSON (renvoie du JSON)->>: accède à une clé en tant que texte@>: contient (exploite l'index GIN)?: la clé existe au premier niveaujsonb_path_query: queries complexes avec JSON Path
L'index GIN n'aide que @>
WHERE meta->>'source' = 'newsletter' n'utilise pas l'index GIN : le moteur doit scanner toute la table. Utilise meta @> '{"source":"newsletter"}'::jsonb pour exploiter l'index.🛠️ Exercice optionnel
Trois problèmes SQL sur une base e-commerce
Base : customers(id, name, country, created_at), products(id, name, category_id, price), orders(id, customer_id, created_at), order_items(order_id, product_id, quantity). À toi de résoudre les 3 problèmes ci-dessous.
Ta mission
- Top clients fidèles : liste les clients qui ont passé au moins 3 commandes en 2026 et dont le panier moyen dépasse 50 €. Trie par CA décroissant.
- Part de CA par produit dans sa catégorie : pour chaque produit, calcule le pourcentage de CA qu'il représente dans sa catégorie (window function).
- Commandes fantômes : détecte les
ordersqui n'ont aucune ligne dansorder_items(anti-jointure).
Tu bloques ? Des indices, à dévoiler quand tu en as besoin.
Indice 1
Indice masqué.
Indice 2
Indice masqué.
Indice 3
Indice masqué.
✅ QCM de fin de cours
Teste tes acquis
10 questions, plusieurs réponses parfois possibles. Coche tout ce qui te semble juste, puis valide pour voir ton score et les explications.
- 1
ROW_NUMBERvsRANKsur des ex-aequo ? - 2
Peut-on filtrer un
ROW_NUMBERdirectement dans leWHEREde la même requête ? - 3
Comment faire un pivot en MySQL 8 (qui n'a pas
FILTER) ? - 4
Comment compter des doublons d'emails en ignorant la casse ?
- 5
Pourquoi utiliser
NULLIF(x, 0)dans une division ? - 6
LAGsansORDER BY? - 7
Quel index est utile pour
WHERE name ILIKE '%abc%'en PostgreSQL ? - 8
Différence entre
WHERE status='paid'etCOUNT(*) FILTER (WHERE status='paid')? - 9
Quel opérateur JSONB exploite l'index
GIN? - 10
En PostgreSQL 12+, quelle différence entre CTE et sous-requête en termes de performance ?
Tu peux laisser des questions sans réponse, elles compteront comme fausses.
Tu veux ce cours pour ton équipe ?
Je peux adapter et animer ce cours pour tes formateur·ices ou tes apprenant·es, en présentiel ou en distanciel. Parlons-en pendant l'audit gratuit.
Réserver un audit gratuit →