Anaïs Sparesotto
SQL · PratiqueIntermédiaire≈ 1h30 · 7 cas pratiques

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_NUMBER par catégorie
  • On filtre le numéro dans une sous-requête, pas directement dans le WHERE (les fenêtres sont évaluées après WHERE)

ROW_NUMBER vs RANK

Si tu remplaces 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 jan

Pièges classiques

1. Ne pas filtrer l'année dans le 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 que Alice@x.fr et alice@x.fr soient 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

En MySQL 8, ARRAY_AGG n'existe pas. Utilise GROUP_CONCAT(id ORDER BY id) à la place.

Empêcher les futurs doublons

Une fois les doublons traités, ajoute un index unique : 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_TRUNC par DATE_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_trgm dé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

MySQL 8 n'a pas 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 annulees

FILTER, le bon réflexe

Disponible en PostgreSQL, et SQL Standard depuis SQL:2003. Plus lisible, plus rapide à écrire. Utilise-le partout où c'est supporté (MySQL n'a toujours pas 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 niveau
  • jsonb_path_query : queries complexes avec JSON Path

L'index GIN n'aide que @&gt;

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

  1. 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.
  2. 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).
  3. Commandes fantômes : détecte les orders qui n'ont aucune ligne dans order_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. 1

    ROW_NUMBER vs RANK sur des ex-aequo ?

  2. 2

    Peut-on filtrer un ROW_NUMBER directement dans le WHERE de la même requête ?

  3. 3

    Comment faire un pivot en MySQL 8 (qui n'a pas FILTER) ?

  4. 4

    Comment compter des doublons d'emails en ignorant la casse ?

  5. 5

    Pourquoi utiliser NULLIF(x, 0) dans une division ?

  6. 6

    LAG sans ORDER BY ?

  7. 7

    Quel index est utile pour WHERE name ILIKE '%abc%' en PostgreSQL ?

  8. 8

    Différence entre WHERE status='paid' et COUNT(*) FILTER (WHERE status='paid') ?

  9. 9

    Quel opérateur JSONB exploite l'index GIN ?

  10. 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 →