50 leçons progressives pour maîtriser les bases de données relationnelles — explications approfondies, exemples concrets, pièges à éviter.
SQL — Structured Query Language — est le langage standardisé pour communiquer avec les bases de données relationnelles. Conçu en 1974 par Edgar Codd et Donald Chamberlin chez IBM sous le nom SEQUEL, il est devenu l'ISO standard en 1987 et reste à ce jour le langage de données le plus utilisé au monde.
Ce qui rend SQL universel, c'est sa lisibilité : une requête SQL se lit presque comme une phrase en anglais — "Sélectionne les noms des clients qui habitent à Dakar, triés alphabétiquement". La syntaxe est déclarative : vous décrivez quoi vous voulez, pas comment le récupérer.
| Famille | Commandes principales | Rôle |
|---|---|---|
| DDL | CREATE, ALTER, DROP, TRUNCATE | Data Definition Language — Définit et modifie la structure des objets (tables, vues, index). |
| DML | SELECT, INSERT, UPDATE, DELETE | Data Manipulation Language — Lit et manipule les données stockées. |
| DCL | GRANT, REVOKE | Data Control Language — Gère les permissions et accès utilisateurs. |
| TCL | COMMIT, ROLLBACK, SAVEPOINT | Transaction Control Language — Contrôle les unités de travail atomiques. |
Le plus répandu sur le web. Léger, rapide en lecture. Idéal pour les applications web (WordPress, PHP). MariaDB est son fork open-source maintenu par la communauté.
Le plus riche en fonctionnalités. Supporte les types JSON, les tableaux, les fonctions fenêtres avancées. Choix par défaut pour les nouvelles applications sérieuses.
Embarqué dans le fichier de l'application. Zéro serveur, zéro configuration. Utilisé dans iOS, Android, navigateurs web, Electron. Parfait pour le développement local.
Moteurs d'entreprise avec outils d'administration avancés, haute disponibilité, et licences commerciales. Courants dans les grandes organisations.
// votre première requête SQLSELECT 'Bonjour SQL' AS message, NOW() AS date_actuelle;
| message | date_actuelle |
|---|---|
| Bonjour SQL | 2025-06-15 14:30:00 |
Une base de données relationnelle organise les informations en tables liées entre elles. Ce modèle, formalisé par Edgar Codd en 1970, repose sur la théorie des ensembles et la logique des prédicats. Il est encore dominant parce qu'il répond à deux défis fondamentaux : éviter la redondance et garantir la cohérence.
Imaginez une librairie en ligne. Sans base relationnelle, vous stockeriez pour chaque commande le nom du client, son email, son adresse, le nom de l'auteur, l'éditeur... Résultat : si un client change d'adresse, vous devez modifier des centaines de lignes. Si un livre change de prix, toutes les anciennes commandes sont affectées. C'est le cauchemar de la redondance.
eleves), un dossier par cours (table cours), un cahier des présences (table inscriptions). Chaque dossier = une table, chaque page = une ligne (enregistrement), chaque rubrique = une colonne (attribut).Chaque table représente un concept métier unique. Le prix d'un livre est stocké une seule fois dans la table livres. L'adresse d'un client est dans adresses. Les relations entre ces concepts passent par des clés (identifiants numériques qui pointent d'une table vers l'autre).
Une table est une grille à deux dimensions : des colonnes (définies à la création — les attributs) et des lignes ou enregistrements (ajoutés dynamiquement — les données). Une table bien conçue représente exactement un seul concept ou entité du monde réel.
// création d'une table complèteCREATE TABLE IF NOT EXISTS produits ( id INT AUTO_INCREMENT PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, statut ENUM('disponible', 'rupture', 'archivé') DEFAULT 'disponible', created_at DATETIME DEFAULT NOW(), updated_at DATETIME DEFAULT NOW() ON UPDATE NOW() );
utilisateurs, produits, commandes — pas Utilisateur ni tbl_user.date_naissance, nom_complet — jamais d'accents ni d'espaces.client_id est infiniment plus clair que cid ou fk1.created_at dans une table, utilisez le même nom dans toutes vos tables.| Colonne | Type | Null | Default |
|---|---|---|---|
| id | INT AUTO_INCREMENT | NO | — |
| nom | VARCHAR(200) | NO | — |
| prix | DECIMAL(10,2) | NO | — |
| stock | INT | YES | 0 |
| statut | ENUM(...) | YES | disponible |
Choisir le bon type de données est une décision architecturale importante. Elle détermine l'espace disque utilisé, les opérations possibles, et l'intégrité des données. Un mauvais choix peut impacter les performances de façon significative sur des millions de lignes.
| Type | Plage / Taille | Usage recommandé |
|---|---|---|
| TINYINT | −128 à 127 (1 octet) | Booléens, scores, petits compteurs. TINYINT UNSIGNED = 0–255. |
| INT | −2,1 milliards à +2,1 milliards (4 octets) | IDs, compteurs courants. INT UNSIGNED double la plage positive. |
| BIGINT | ±9,2 × 10¹⁸ (8 octets) | IDs de très grandes tables (Twitter, Facebook utilisent BIGINT). |
| DECIMAL(p,s) | Précision exacte | Obligatoire pour les prix et montants. DECIMAL(10,2) = jusqu'à 99999999.99. |
| FLOAT / DOUBLE | Approximatif | Mesures scientifiques. Jamais pour l'argent (0.1 + 0.2 ≠ 0.3 exactement). |
| Type | Longueur max | Usage recommandé |
|---|---|---|
| CHAR(n) | Fixe — exactement n caractères | Codes fixes : codes pays (FR, SN), numéros SIRET. Légèrement plus rapide que VARCHAR. |
| VARCHAR(n) | Variable — jusqu'à n caractères | Noms, emails, titres. Le plus courant. N'occupe que l'espace nécessaire. |
| TEXT | 65 535 caractères | Descriptions, articles courts, commentaires. |
| MEDIUMTEXT | 16 millions de caractères | Articles longs, contenu HTML, JSON. |
| LONGTEXT | 4 milliards de caractères | Fichiers texte très volumineux, logs massifs. |
SELECT 0.1 + 0.2; → retourne 0.30000000000000004 en FLOAT.SELECT CAST(0.1 AS DECIMAL(10,2)) + CAST(0.2 AS DECIMAL(10,2)); → retourne 0.30 exactement.DECIMAL.DATE (2025-06-15), TIME (14:30:00), DATETIME (2025-06-15 14:30:00, pas de fuseau horaire), TIMESTAMP (stocké en UTC, converti automatiquement selon le fuseau du serveur — préférez-le pour les horodatages). YEAR pour stocker une année seule.Avant de créer des tables, vous travaillez dans un contexte : une base de données (aussi appelée schema dans certains moteurs). C'est le conteneur principal. Un serveur MySQL peut héberger des centaines de bases isolées — chacune avec ses propres tables, utilisateurs et configurations.
// gestion de la base de données-- 1. Créer avec encodage complet (utf8mb4 supporte tous les caractères Unicode + emojis) CREATE DATABASE IF NOT EXISTS ecole CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 2. Sélectionner comme contexte de travail USE ecole; -- 3. Lister toutes les bases du serveur SHOW DATABASES; -- 4. Voir les tables de la base active SHOW TABLES; -- 5. Décrire la structure d'une table DESCRIBE utilisateurs; -- Ou en plus détaillé : SHOW CREATE TABLE utilisateurs;
utf8 de MySQL est une implémentation incomplète qui ne stocke que 3 octets par caractère maximum. Les emojis et certains caractères CJK (chinois/japonais/coréen) nécessitent 4 octets. Utilisez toujours utf8mb4. La collation utf8mb4_unicode_ci fait les comparaisons de façon insensible à la casse (ci = case insensitive).Créez une base boutique_online en utf8mb4, sélectionnez-la, puis créez dedans une table produits avec les colonnes : id (INT AUTO_INCREMENT PRIMARY KEY), nom (VARCHAR 200, NOT NULL), prix (DECIMAL 10,2), stock (INT DEFAULT 0), created_at (DATETIME DEFAULT NOW()).
Vérifiez la création avec DESCRIBE produits.
Avant d'écrire du SQL, il faut modéliser. Le modèle entité-relation (MER) identifie les entités (les "choses" à stocker), leurs attributs, et les relations entre elles. C'est la phase de conception qui détermine la qualité de toute la base.
Trois types de relations existent : 1 à 1 (un utilisateur = un profil), 1 à plusieurs (un client = plusieurs commandes), plusieurs à plusieurs (un étudiant suit plusieurs cours, un cours a plusieurs étudiants). Les relations M à N nécessitent une table de jonction.
Un utilisateur a exactement un profil. Stockez une colonne utilisateur_id UNIQUE dans la table profils. Peu fréquent — souvent, on fusionne les deux tables.
Un client peut avoir plusieurs commandes. Mettez client_id dans la table commandes. Le "N" (plusieurs) porte la clé étrangère vers le "1".
Étudiants ↔ Cours. Créez une table de jonction inscriptions avec etudiant_id et cours_id. La table peut porter des attributs propres à la relation (la note).
Un employé a un manager... qui est aussi un employé. employes(id, nom, manager_id) où manager_id référence la même table.
La clé primaire est l'identifiant unique et invariable de chaque ligne. Elle garantit qu'on peut toujours retrouver, modifier ou supprimer un enregistrement précis sans ambiguïté. Toute table sérieuse doit en avoir une.
En pratique, trois types de clés primaires coexistent :
Simple, performante, universelle. Le moteur génère automatiquement 1, 2, 3... Idéale pour 99% des tables. INT pour < 2 milliards de lignes, BIGINT sinon.
Chaîne hexadécimale de 36 caractères. Avantage : pas de collision entre serveurs (sharding, réplication). Inconvénient : plus lourd, plus lent sur les index.
Un attribut métier unique : IBAN, numéro de passeport. Déconseillé : ces valeurs peuvent changer dans le monde réel, ce qui casse les relations.
Combinaison de deux colonnes : (etudiant_id, cours_id) pour les inscriptions. Garantit l'unicité de la paire. Utilisée dans les tables de jonction.
// différentes formes de clés primaires-- Option 1 : INT AUTO_INCREMENT (recommandé) CREATE TABLE clients ( id INT AUTO_INCREMENT PRIMARY KEY, nom VARCHAR(100) NOT NULL ); -- Option 2 : BIGINT pour les très grandes tables CREATE TABLE transactions ( id BIGINT AUTO_INCREMENT PRIMARY KEY, ... ); -- Option 3 : UUID CREATE TABLE sessions ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), ... ); -- Option 4 : Clé primaire composite CREATE TABLE inscriptions ( etudiant_id INT, cours_id INT, note DECIMAL(4,2), PRIMARY KEY (etudiant_id, cours_id) );
INT AUTO_INCREMENT et stockez l'attribut naturel dans une colonne séparée avec contrainte UNIQUE.Une clé étrangère est une colonne dont la valeur doit correspondre à une clé primaire existante dans une autre table. Elle garantit l'intégrité référentielle : impossible d'avoir une commande pour un client qui n'existe pas, ou de supprimer un client qui a des commandes actives.
// déclaration d'une clé étrangère complèteCREATE TABLE commandes ( id INT AUTO_INCREMENT PRIMARY KEY, client_id INT NOT NULL, montant DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT NOW(), -- Déclaration de la contrainte FK CONSTRAINT fk_commandes_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT -- Ne peut pas supprimer un client avec des commandes ON UPDATE CASCADE -- Si l'id client change, répercuter automatiquement );
| Option | ON DELETE | ON UPDATE |
|---|---|---|
| RESTRICT | Bloque la suppression du parent s'il a des enfants. Comportement recommandé par défaut. | Bloque la modification de la clé parent. |
| CASCADE | Supprime automatiquement tous les enregistrements enfants. Utile pour les logs, les sessions. | Propage le changement à tous les enfants. |
| SET NULL | Met la FK à NULL dans les enfants. La colonne doit être nullable. | Met la FK à NULL si le parent change. |
| NO ACTION | Comportement similaire à RESTRICT dans MySQL. | Idem. |
SET FOREIGN_KEY_CHECKS = 0;Les contraintes sont des règles d'intégrité définies au niveau de la base de données. Elles constituent la dernière ligne de défense contre des données incohérentes — même si votre application a un bug ou qu'un développeur insère des données directement en console.
// contraintes exhaustivesCREATE TABLE utilisateurs ( id INT AUTO_INCREMENT PRIMARY KEY, nom VARCHAR(100) NOT NULL, -- Obligatoire email VARCHAR(200) UNIQUE NOT NULL, -- Obligatoire + unique age INT CHECK (age BETWEEN 0 AND 150), -- Validation de plage role ENUM('admin','user','guest') DEFAULT 'user', -- Valeur par défaut telephone VARCHAR(20), -- Optionnel (NULL autorisé) created_at DATETIME DEFAULT NOW(), updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(), -- Contrainte unique composite : combinaison unique UNIQUE KEY uq_nom_tel (nom, telephone) ); -- Ajouter des contraintes sur une table existante ALTER TABLE produits ADD CONSTRAINT chk_prix_positif CHECK (prix > 0), ADD CONSTRAINT chk_stock_non_negatif CHECK (stock >= 0);
UNIQUE NOT NULL. La contrainte PRIMARY KEY est implicitement UNIQUE NOT NULL.INSERT INTO ajoute de nouveaux enregistrements. Vous pouvez insérer une seule ligne ou plusieurs milliers en une seule requête — bien plus efficace car le moteur optimise le traitement par lot.
// toutes les formes d'INSERT-- Insertion simple avec colonnes explicites (TOUJOURS spécifier les colonnes) INSERT INTO clients (nom, email, ville) VALUES ('Aminata Diallo', 'aminata@gmail.com', 'Dakar'); -- Insertion multiple : bien plus rapide qu'une boucle d'INSERTs individuels INSERT INTO clients (nom, email, ville) VALUES ('Ibrahima Sow', 'ibrahima@yahoo.fr', 'Thiès'), ('Fatou Ndiaye', 'fatou@outlook.com', 'Dakar'), ('Moussa Traoré', 'moussa@gmail.com', 'Saint-Louis'), ('Aïcha Koné', 'aicha@orange.sn', 'Ziguinchor'); -- INSERT ... SELECT : copier des données d'une table à l'autre INSERT INTO clients_archive (nom, email, archived_at) SELECT nom, email, NOW() FROM clients WHERE created_at < '2023-01-01' AND statut = 'inactif'; -- INSERT ... ON DUPLICATE KEY UPDATE : upsert (insérer ou mettre à jour) INSERT INTO stats_quotidiennes (date, nb_visites) VALUES (CURDATE(), 1) ON DUPLICATE KEY UPDATE nb_visites = nb_visites + 1; -- Récupérer l'id du dernier INSERT SELECT LAST_INSERT_ID() AS nouvel_id;
INSERT INTO t VALUES (...) est fragile — il suffit d'ajouter une colonne ou de les réordonner pour que la requête plante ou insère des données dans les mauvaises colonnes. Listez toujours explicitement les colonnes cibles.SELECT est la commande la plus puissante et la plus utilisée de SQL. Elle interroge une ou plusieurs tables et retourne un jeu de résultats (un tableau virtuel). La clé pour de bonnes performances : ne sélectionner que ce dont on a besoin.
// anatomie complète d'un SELECT-- Ordre OBLIGATOIRE des clauses : SELECT colonnes_ou_expressions -- Quoi retourner FROM table_principale -- Où chercher JOIN autre_table ON condition -- Relier d'autres tables WHERE condition_ligne -- Filtrer les lignes GROUP BY colonnes_agrégation -- Regrouper HAVING condition_groupe -- Filtrer les groupes ORDER BY colonnes_tri -- Trier LIMIT n OFFSET m; -- Paginer -- Exemples pratiques progressifs SELECT * FROM clients; -- Toutes les colonnes (déconseillé en production) SELECT nom, email FROM clients; -- Colonnes spécifiques SELECT nom AS "Nom du client" FROM clients; -- Avec alias SELECT nom, prix * 1.18 AS prix_ttc FROM produits; -- Calcul à la volée SELECT CONCAT(prenom, ' ', nom) AS nom_complet FROM employes; -- Fonction SELECT 2025 - YEAR(date_naissance) AS age FROM employes; -- Calcul sur date
WHERE est le filtre fondamental de SQL. Il évalue une condition pour chaque ligne et ne retourne que les lignes où la condition est vraie. Sans WHERE, toutes les lignes sont traitées — sur une table de 10 millions de lignes, cela peut prendre plusieurs minutes.
// filtrage avec WHERE — exemples complets-- Valeur exacte SELECT * FROM clients WHERE id = 42; -- Texte (sensible à la casse selon la collation) SELECT * FROM clients WHERE ville = 'Dakar'; -- Plage de dates SELECT * FROM commandes WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- Préférez < au lieu de <= sur les dates -- Calcul dans la condition SELECT nom, salaire FROM employes WHERE salaire * 12 > 6000000; -- Salaire annuel > 6 millions FCFA -- Combinaisons de conditions SELECT * FROM produits WHERE (prix BETWEEN 5000 AND 50000) AND statut = 'disponible' AND stock > 0;
WHERE email = 'x' est sargable. WHERE UPPER(email) = 'X' ne l'est pas (la fonction empêche l'utilisation de l'index). Évitez d'appliquer des fonctions à la colonne filtrée.Les opérateurs de comparaison construisent les conditions dans WHERE, HAVING, et CASE WHEN. Ils fonctionnent sur les nombres, les textes (comparaison lexicographique) et les dates (comparaison chronologique).
| Opérateur | Signification | Exemple |
|---|---|---|
| = | Égal à | WHERE statut = 'actif' |
| != ou <> | Différent de (les deux sont équivalents) | WHERE pays != 'FR' |
| > | Strictement supérieur | WHERE prix > 10000 |
| < | Strictement inférieur | WHERE stock < 5 |
| >= | Supérieur ou égal (inclut la borne) | WHERE note >= 10 |
| <= | Inférieur ou égal (inclut la borne) | WHERE age <= 25 |
| IS NULL | La valeur est absente/inconnue | WHERE telephone IS NULL |
| IS NOT NULL | La valeur est présente | WHERE manager_id IS NOT NULL |
// opérateurs sur les dates-- Commandes passées APRÈS le 1er janvier 2024 SELECT * FROM commandes WHERE date_commande > '2024-01-01'; -- Clients nés en 1990 ou avant SELECT * FROM clients WHERE YEAR(date_naissance) <= 1990; -- Comparaison de texte (ordre alphabétique) SELECT * FROM clients WHERE nom > 'M'; -- Noms après 'M' alphabétiquement
WHERE age = NULL retourne toujours 0 ligne, même s'il y a des NULL ! En SQL, NULL = NULL ne vaut pas TRUE mais NULL. Utilisez obligatoirement IS NULL ou IS NOT NULL.Les opérateurs logiques combinent plusieurs conditions. Priorité : NOT s'évalue en premier, puis AND, puis OR. Quand vos conditions mélangent AND et OR, les parenthèses sont essentielles pour clarifier l'intention.
// logique booléenne en SQL-- AND : toutes les conditions doivent être vraies SELECT * FROM employes WHERE salaire > 400000 AND departement = 'Tech' AND anciennete_annees >= 2 AND statut != 'inactif'; -- OR : au moins une condition suffit SELECT * FROM clients WHERE ville = 'Dakar' OR ville = 'Abidjan' OR ville = 'Bamako'; -- Plus élégant avec IN : WHERE ville IN ('Dakar', 'Abidjan', 'Bamako') -- ❌ AMBIGU — AND a priorité sur OR WHERE statut = 'vip' OR solde > 1000000 AND anciennete > 2; -- Interprété comme : statut = 'vip' OR (solde > 1000000 AND anciennete > 2) -- ✅ EXPLICITE — parenthèses obligatoires WHERE (statut = 'vip' OR solde > 1000000) AND anciennete > 2; -- NOT : inverse une condition WHERE NOT (ville = 'Dakar' AND statut = 'bloqué')
LIKE effectue une recherche par motif (pattern matching). Deux jokers : % représente zéro ou plusieurs caractères quelconques, _ représente exactement un caractère quelconque.
// tous les cas d'usage de LIKE-- Commence par 'A' SELECT * FROM clients WHERE nom LIKE 'A%'; -- Se termine par 'diallo' (insensible à la casse selon collation) SELECT * FROM clients WHERE nom LIKE '%diallo'; -- Contient 'orange' n'importe où SELECT * FROM produits WHERE description LIKE '%orange%'; -- Emails Gmail SELECT * FROM utilisateurs WHERE email LIKE '%@gmail.com'; -- Codes produit : 3 lettres + exactement 4 chiffres SELECT * FROM produits WHERE code LIKE '___####'; -- Ou en SQL standard avec _ pour un caractère : SELECT * FROM produits WHERE code LIKE '_______'; -- 7 underscores = 7 caractères -- Deuxième lettre est 'a' SELECT * FROM villes WHERE nom LIKE '_a%'; -- NOT LIKE : exclure un motif SELECT * FROM clients WHERE email NOT LIKE '%@hotmail%'; -- Recherche insensible à la casse forcée : SELECT * FROM clients WHERE LOWER(nom) LIKE '%diallo%';
LIKE 'texte%' (commence par) peut utiliser un index. LIKE '%texte%' ou LIKE '%texte' (avec % au début) ne peut pas — il force un full scan. Pour des recherches full-text performantes sur de grandes tables, utilisez MATCH() AGAINST() avec un index FULLTEXT, ou un moteur externe (Elasticsearch, Meilisearch).BETWEEN a AND b teste si une valeur est dans une plage avec les deux bornes incluses. IN (liste) teste l'appartenance à un ensemble — bien plus lisible et souvent plus rapide que de chaîner des OR.
// BETWEEN et IN-- BETWEEN sur nombres (bornes incluses) SELECT * FROM produits WHERE prix BETWEEN 5000 AND 50000; -- Strictement équivalent à : WHERE prix >= 5000 AND prix <= 50000; -- BETWEEN sur des dates SELECT * FROM commandes WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59'; -- Attention : '2024-12-31' seul exclut les lignes après minuit ce jour-là ! -- NOT BETWEEN : hors de la plage SELECT * FROM employes WHERE anciennete NOT BETWEEN 1 AND 3; -- IN : liste de valeurs (beaucoup plus lisible que des OR) SELECT * FROM clients WHERE ville IN ('Dakar', 'Thiès', 'Saint-Louis', 'Ziguinchor', 'Kaolack'); -- IN avec des entiers SELECT * FROM commandes WHERE statut_id IN (1, 3, 5); -- NOT IN : exclure une liste SELECT * FROM produits WHERE categorie NOT IN ('archivé', 'supprimé', 'test');
WHERE id NOT IN (1, 2, NULL) retourne zéro résultat ! Car 5 != NULL est NULL (ni vrai ni faux), donc aucune ligne ne passe. Si la liste peut contenir des NULL (notamment si elle vient d'une sous-requête), utilisez NOT EXISTS à la place.NULL ne signifie pas zéro, ni une chaîne vide, ni false. NULL signifie "information absente ou inconnue". C'est une valeur spéciale qui a des règles de comportement propres, source de nombreux bugs chez les développeurs qui ne les connaissent pas.
// comportement de NULL-- Tester la présence/absence d'une valeur SELECT * FROM utilisateurs WHERE telephone IS NULL; SELECT * FROM employes WHERE manager_id IS NOT NULL; -- ❌ NE FONCTIONNE JAMAIS (retourne toujours 0 ligne) SELECT * FROM utilisateurs WHERE telephone = NULL; SELECT * FROM utilisateurs WHERE telephone != NULL; -- NULL dans les calculs arithmétiques (contagieux !) SELECT 100 + NULL; -- → NULL (pas 100) SELECT NULL * 500; -- → NULL SELECT CONCAT('Bonjour', NULL); -- → NULL en MySQL ! -- Remplacer NULL par une valeur par défaut SELECT nom, IFNULL(telephone, 'Non renseigné') FROM clients; -- NULL dans les fonctions d'agrégation SELECT COUNT(*) AS total_lignes, -- Compte TOUT, y compris NULL COUNT(telephone) AS avec_telephone, -- Ignore les NULL AVG(bonus) AS bonus_moyen -- Ignore les NULL dans le calcul FROM employes;
| Expression | Résultat | Pourquoi ? |
|---|---|---|
| NULL = NULL | NULL | L'inconnu n'est pas égal à l'inconnu |
| NULL IS NULL | TRUE ✓ | IS NULL est fait pour ça |
| 5 + NULL | NULL | NULL est "contagieux" |
| TRUE AND NULL | NULL | On ne sait pas si c'est vrai |
| FALSE AND NULL | FALSE | Peu importe l'inconnu, c'est faux |
| TRUE OR NULL | TRUE | Au moins un est vrai |
Sans ORDER BY, l'ordre des lignes retournées par une requête est totalement indéterminé. Le moteur peut les retourner dans l'ordre d'insertion, l'ordre de l'index utilisé, ou n'importe quel autre ordre interne. Si l'ordre compte, utilisez toujours ORDER BY.
// tris simples et avancés-- Tri alphabétique croissant (ASC est le défaut) SELECT * FROM clients ORDER BY nom; SELECT * FROM clients ORDER BY nom ASC; -- Identique -- Du plus récent au plus ancien SELECT * FROM articles ORDER BY created_at DESC; -- Tri multi-niveaux : par ville, puis par nom dans chaque ville SELECT nom, ville FROM clients ORDER BY ville ASC, nom ASC; -- Trier par alias calculé SELECT nom, prix * 1.18 AS prix_ttc FROM produits ORDER BY prix_ttc DESC; -- Trier par position de colonne (déconseillé pour la lisibilité) SELECT nom, ville, email FROM clients ORDER BY 2; -- Trie par 'ville' -- Trier avec CASE : ordre personnalisé SELECT * FROM commandes ORDER BY CASE statut WHEN 'urgent' THEN 1 WHEN 'en_cours' THEN 2 WHEN 'livré' THEN 3 ELSE 4 END;
ORDER BY colonne ASC NULLS LAST. En MySQL, astuce : ORDER BY colonne IS NULL ASC, colonne ASC.DISTINCT retourne uniquement les combinaisons de valeurs uniques dans les colonnes sélectionnées. Il compare la combinaison complète de toutes les colonnes listées dans le SELECT.
// DISTINCT et ses variations-- Lister les villes où vous avez des clients (sans doublons) SELECT DISTINCT ville FROM clients; -- Paires uniques ville + pays SELECT DISTINCT ville, pays FROM clients; -- DISTINCT s'applique à la combinaison (ville, pays), pas séparément -- Compter les valeurs uniques SELECT COUNT(*) AS total_commandes, COUNT(DISTINCT client_id) AS clients_uniques, COUNT(DISTINCT produit_id) AS produits_commandés FROM lignes_commande; -- Equivalent avec GROUP BY (souvent plus performant sur grandes tables) SELECT ville FROM clients GROUP BY ville; -- GROUP BY permet aussi de filtrer avec HAVING, ce que DISTINCT ne peut pas
| SELECT ville FROM clients (14 lignes) | SELECT DISTINCT ville (4 lignes) |
|---|---|
| Dakar | Dakar |
| Dakar | Thiès |
| Thiès | Saint-Louis |
| Dakar | Ziguinchor |
| Saint-Louis | |
| Dakar |
LIMIT est essentiel en production. Sans lui, une requête sur une table de plusieurs millions de lignes peut saturer la mémoire du serveur, bloquer la connexion, et faire planter votre application. Il est aussi la base de la pagination.
// pagination et cas d'usage-- Les 10 premiers articles (page 1) SELECT * FROM articles ORDER BY created_at DESC LIMIT 10; -- Pagination classique -- Formule : OFFSET = (numéro_page - 1) × nb_par_page SELECT * FROM articles ORDER BY created_at DESC LIMIT 10 OFFSET 0; -- Page 1 SELECT * FROM articles ORDER BY created_at DESC LIMIT 10 OFFSET 10; -- Page 2 SELECT * FROM articles ORDER BY created_at DESC LIMIT 10 OFFSET 20; -- Page 3 -- Compter le total pour calculer le nombre de pages SELECT COUNT(*) FROM articles WHERE statut = 'publié'; -- Trouver le N-ième élément (5ème produit le plus cher) SELECT nom, prix FROM produits ORDER BY prix DESC LIMIT 1 OFFSET 4; -- OFFSET 4 = saute les 4 premiers -- ✅ Keyset pagination (bien plus performant avec de grandes tables) -- Page suivante après l'article d'id 1250 : SELECT * FROM articles WHERE id < 1250 -- Au lieu de OFFSET 50000 ORDER BY id DESC LIMIT 10;
LIMIT 10 OFFSET 100000 force MySQL à lire et ignorer 100 000 lignes avant de retourner les 10 suivantes. Avec la keyset pagination (WHERE id > dernierIdVu), le moteur saute directement au bon endroit via l'index. Indispensable pour les tables de millions de lignes.Écrivez les requêtes pour une API qui pagine les produits par 20 :
1. Récupérer la page 4 des produits disponibles, triés par prix croissant.
2. Compter le nombre total de produits disponibles pour calculer le nombre de pages.
3. Implémenter la même page 4 avec la keyset pagination (en supposant que le dernier id de la page 3 est 847).
UPDATE modifie des enregistrements existants. Vous pouvez modifier une ou plusieurs colonnes simultanément, sur une ou plusieurs lignes. C'est une opération permanente — sans transaction, elle est immédiatement persistée sur disque.
// toutes les formes d'UPDATE-- Mise à jour d'un seul enregistrement (toujours par la clé primaire) UPDATE utilisateurs SET email = 'newemail@gmail.com' WHERE id = 42; -- Plusieurs colonnes simultanément UPDATE employes SET salaire = salaire * 1.10, -- Augmentation de 10% grade = 'Senior', updated_at = NOW() WHERE departement = 'Ingénierie' AND anciennete_annees >= 3; -- Mise à jour conditionnelle avec CASE UPDATE produits SET statut = CASE WHEN stock = 0 THEN 'rupture' WHEN stock < 5 THEN 'faible_stock' WHEN stock < 20 THEN 'stock_limité' ELSE 'disponible' END; -- Soft delete : ne pas vraiment supprimer, juste marquer UPDATE utilisateurs SET deleted_at = NOW() WHERE id = 15;
SELECT * FROM employes WHERE departement = 'Ingénierie' AND anciennete_annees >= 3. Vérifiez le nombre et la liste. Ensuite seulement, lancez l'UPDATE. Un UPDATE sans WHERE sur une grande table peut être catastrophique.DELETE supprime définitivement des enregistrements. Contrairement à TRUNCATE, il : déclenche les triggers, respecte les contraintes de clés étrangères, s'inscrit dans les logs de transactions (annulable avec ROLLBACK), et peut être filtré avec WHERE.
// DELETE sécurisé-- Étape 1 : TOUJOURS vérifier d'abord ce qui sera supprimé SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM logs WHERE niveau = 'debug' AND created_at < DATE_SUB(NOW(), INTERVAL 90 DAY); -- Étape 2 : Si le résultat est cohérent, procéder DELETE FROM logs WHERE niveau = 'debug' AND created_at < DATE_SUB(NOW(), INTERVAL 90 DAY); -- Suppression en masse avec LIMIT (plus sûr, évite les locks longs) -- Supprimer par lots de 1000 dans une boucle applicative : DELETE FROM logs WHERE niveau = 'debug' LIMIT 1000; -- Répéter jusqu'à ce que 0 lignes soient affectées -- Supprimer avec JOIN (MySQL) DELETE c FROM commandes c JOIN clients cl ON c.client_id = cl.id WHERE cl.statut = 'test';
deleted_at DATETIME DEFAULT NULL. "Supprimer" = UPDATE ... SET deleted_at = NOW(). Toutes les requêtes ajoutent WHERE deleted_at IS NULL. Avantage : récupération en cas d'erreur, auditabilité, conformité légale (RGPD peut nécessiter de garder des traces).Les fonctions d'agrégation résument un ensemble de lignes en une seule valeur. Elles sont la base de toute analyse de données. Règle importante : elles ignorent les NULL (sauf COUNT(*) qui compte toutes les lignes, y compris celles avec des NULL).
// fonctions d'agrégation complètesSELECT COUNT(*) AS total_commandes, -- Toutes les lignes COUNT(coupon_code) AS avec_coupon, -- Ignore NULL COUNT(DISTINCT client_id) AS clients_uniques, SUM(montant) AS chiffre_affaires, AVG(montant) AS panier_moyen, ROUND(AVG(montant), 2) AS panier_moyen_arrondi, MIN(montant) AS plus_petite_commande, MAX(montant) AS plus_grande_commande, SUM(montant) / COUNT(*) AS moyenne_manuelle, MAX(created_at) AS derniere_commande, STDDEV(montant) AS ecart_type -- Dispersion statistique FROM commandes WHERE statut = 'livré' AND YEAR(created_at) = 2024;
| total | clients_uniques | ca_total | panier_moyen | min | max |
|---|---|---|---|---|---|
| 1 247 | 389 | 6 543 200 | 5 247.80 | 500 | 245 000 |
COUNT(DISTINCT client_id) compte les clients ayant passé au moins une commande. AVG ignore les NULL — ce qui peut fausser les moyennes si de nombreuses valeurs sont absentes.GROUP BY divise les lignes en groupes partageant la même valeur dans une ou plusieurs colonnes, puis applique une fonction d'agrégation à chaque groupe. C'est l'outil fondamental de l'analyse de données en SQL.
// GROUP BY — exemples réels-- Ventes par catégorie de produit SELECT c.nom AS categorie, COUNT(*) AS nb_produits, SUM(ventes) AS total_ventes, AVG(prix) AS prix_moyen FROM produits p JOIN categories c ON p.categorie_id = c.id GROUP BY c.id, c.nom ORDER BY total_ventes DESC; -- Chiffre d'affaires mensuel SELECT YEAR(created_at) AS annee, MONTH(created_at) AS mois, DATE_FORMAT(created_at, '%Y-%m') AS periode, COUNT(*) AS nb_commandes, SUM(montant) AS ca_mensuel, AVG(montant) AS panier_moyen FROM commandes WHERE statut = 'livré' GROUP BY annee, mois ORDER BY annee, mois;
SELECT nom, departement, AVG(salaire), alors GROUP BY nom, departement. MySQL est parfois permissif sur cette règle (mode non-strict), mais PostgreSQL et SQL Server l'appliquent strictement.HAVING filtre les groupes après l'agrégation, là où WHERE filtre les lignes avant. C'est la seule façon de filtrer sur un résultat d'agrégation comme COUNT(*) ou SUM().
// HAVING vs WHERE — différences clés-- WHERE filtre les LIGNES avant agrégation -- HAVING filtre les GROUPES après agrégation SELECT client_id, COUNT(*) AS nb_commandes, SUM(montant) AS total_depense FROM commandes WHERE statut = 'livré' -- ① Filtre d'abord les lignes (statut livré) GROUP BY client_id HAVING COUNT(*) > 5 -- ② Puis garde seulement les gros clients (> 5 commandes) AND SUM(montant) > 100000 -- ③ Et dont le total dépasse 100 000 ORDER BY total_depense DESC; -- Trouver les villes avec plus de 10 clients actifs SELECT ville, COUNT(*) AS nb_clients, AVG(solde) AS solde_moyen FROM clients WHERE statut = 'actif' GROUP BY ville HAVING COUNT(*) >= 10 ORDER BY nb_clients DESC;
| Étape | Clause | Ce qui se passe |
|---|---|---|
| 1 | FROM / JOIN | Charge les tables et les assemble |
| 2 | WHERE | Filtre les lignes individuelles |
| 3 | GROUP BY | Crée les groupes |
| 4 | HAVING | Filtre les groupes sur agrégats |
| 5 | SELECT | Calcule les expressions retournées |
| 6 | ORDER BY | Trie le résultat final |
| 7 | LIMIT | Coupe le nombre de lignes |
Les alias donnent des noms temporaires aux colonnes et aux tables. Essentiels pour la lisibilité des requêtes complexes, indispensables pour nommer des expressions calculées, et obligatoires pour les tables dans les sous-requêtes.
// alias de colonnes et de tables-- Alias de colonnes SELECT nom AS "Nom du client", email AS contact, UPPER(nom) AS nom_majuscule, DATEDIFF(NOW(), created_at) AS jours_anciennete, prix * 1.18 AS prix_ttc, CONCAT(prenom, ' ', nom) AS nom_complet FROM utilisateurs; -- AS est facultatif syntaxiquement, mais recommandé pour la clarté SELECT nom nom_client FROM clients; -- Fonctionne, mais moins lisible -- Alias de tables — indispensable pour les jointures SELECT c.nom AS client, cmd.id AS commande_id, cmd.montant, p.nom AS produit FROM clients c JOIN commandes cmd ON c.id = cmd.client_id JOIN lignes_commande lc ON cmd.id = lc.commande_id JOIN produits p ON lc.produit_id = p.id; -- Attention : un alias défini dans SELECT n'est PAS disponible dans WHERE -- ❌ Ceci ne fonctionne pas : SELECT prix * 1.18 AS prix_ttc FROM produits WHERE prix_ttc > 5000; -- ✅ Solution : répéter l'expression ou utiliser une sous-requête SELECT prix * 1.18 AS prix_ttc FROM produits WHERE prix * 1.18 > 5000;
SQL offre un riche ensemble de fonctions texte pour nettoyer, transformer et extraire des informations des chaînes — directement dans la base, sans passer par le code applicatif.
// fonctions texte essentiellesSELECT UPPER(nom) -- 'AMINATA DIALLO' LOWER(email) -- 'aminata@gmail.com' LENGTH(nom) -- Nombre de caractères CHAR_LENGTH(nom) -- Idem mais compte les chars unicode TRIM(nom) -- Supprime espaces début et fin LTRIM(' hello') -- Supprime espaces à gauche → 'hello' RTRIM('hello ') -- Supprime espaces à droite → 'hello' CONCAT(prenom, ' ', nom) -- 'Aminata Diallo' CONCAT_WS(', ', ville, pays) -- 'Dakar, Sénégal' (séparateur sûr avec NULL) SUBSTRING(email, 1, 5) -- 5 premiers caractères SUBSTRING_INDEX(email, '@', 1) -- Partie avant le @ → 'aminata' SUBSTRING_INDEX(email, '@', -1) -- Partie après le @ → 'gmail.com' REPLACE(telephone, ' ', '') -- Supprime espaces du numéro REGEXP_REPLACE(texte, '[0-9]', 'X') -- Regex replace (MySQL 8+) LPAD(id, 6, '0') -- '000042' — padder un id sur 6 chiffres FORMAT(prix, 2, 'fr_FR') -- '1 234,56' FROM utilisateurs;
Après un import CSV désorganisé, nettoyer en une requête :
UPDATE clients SET nom = TRIM(UPPER(nom)), email = TRIM(LOWER(email)), telephone = REPLACE(REPLACE(telephone, ' ', ''), '-', '')
Cette requête normalise les noms en majuscules, les emails en minuscules, et supprime espaces et tirets des numéros de téléphone — pour toute la table en une seule opération.
Les dates sont omniprésentes en bases de données. SQL dispose de fonctions puissantes pour les manipuler — extraire des composantes, calculer des durées, formater l'affichage, faire de l'arithmétique temporelle.
// fonctions de date complètesSELECT NOW() -- 2025-06-15 14:30:22 CURDATE() -- 2025-06-15 CURTIME() -- 14:30:22 UNIX_TIMESTAMP() -- Timestamp Unix -- Extraire des composantes YEAR(created_at) -- 2025 MONTH(created_at) -- 6 DAY(created_at) -- 15 HOUR(created_at) -- 14 DAYNAME(NOW()) -- 'Sunday' MONTHNAME(NOW()) -- 'June' WEEK(NOW()) -- Numéro de semaine (1-53) QUARTER(NOW()) -- Trimestre (1-4) -- Calculer des durées DATEDIFF(NOW(), date_embauche) -- Différence en jours TIMESTAMPDIFF(YEAR, date_naissance, NOW()) -- Âge exact en années TIMESTAMPDIFF(MONTH, date_debut, NOW()) -- Durée en mois -- Ajouter / soustraire du temps DATE_ADD(NOW(), INTERVAL 30 DAY) -- Dans 30 jours DATE_SUB(NOW(), INTERVAL 1 YEAR) -- Il y a 1 an NOW() + INTERVAL 2 HOUR -- Dans 2 heures -- Formater DATE_FORMAT(created_at, '%d/%m/%Y') -- '15/06/2025' DATE_FORMAT(created_at, '%d %M %Y à %H:%i') -- '15 June 2025 à 14:30' FROM employes;
WHERE YEAR(created_at) = 2024 empêche l'utilisation d'un index sur created_at. Préférez toujours : WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'. La requête devient "sargable" et utilise l'index.CASE WHEN est l'équivalent SQL du if/else if/else. Utilisable dans SELECT, WHERE, ORDER BY, UPDATE — c'est l'une des fonctionnalités les plus polyvalentes. Il existe en deux formes.
// deux formes de CASE WHEN-- Forme simple (teste l'égalité d'une expression) CASE statut WHEN 'livré' THEN '✓ Livré' WHEN 'en_cours' THEN '⟳ En cours' WHEN 'annulé' THEN '✗ Annulé' ELSE '? Inconnu' END AS statut_affichage -- Forme recherchée (conditions booléennes, plus flexible) CASE WHEN salaire >= 800000 THEN 'Expert' WHEN salaire >= 500000 THEN 'Senior' WHEN salaire >= 300000 THEN 'Confirmé' WHEN salaire >= 150000 THEN 'Junior' ELSE 'Stagiaire' END AS niveau -- Technique avancée : Pivot avec CASE (transformer lignes en colonnes) SELECT DATE_FORMAT(created_at, '%Y-%m') AS mois, SUM(CASE WHEN statut = 'livré' THEN montant ELSE 0 END) AS ca_livre, SUM(CASE WHEN statut = 'annulé' THEN montant ELSE 0 END) AS ca_annule, COUNT(CASE WHEN statut = 'livré' THEN 1 END) AS nb_livrees, COUNT(CASE WHEN statut = 'annulé' THEN 1 END) AS nb_annulees FROM commandes GROUP BY mois ORDER BY mois;
Trois fonctions essentielles pour remplacer, intercepter ou générer des NULL. COALESCE est la plus puissante et la plus portable (standard SQL).
// gestion des NULL-- COALESCE : retourne le premier argument non-NULL SELECT nom, COALESCE(mobile, fixe, email, 'Aucun contact disponible') AS contact_principal FROM clients; -- IFNULL : version simplifiée de COALESCE à 2 arguments SELECT nom, IFNULL(bonus, 0) + salaire AS remuneration_totale, IFNULL(DATE_FORMAT(derniere_cnx, '%d/%m/%Y'), 'Jamais connecté') AS cnx FROM employes; -- NULLIF : retourne NULL si les deux valeurs sont égales -- Utile pour éviter la division par zéro SELECT commandes / NULLIF(visites, 0) AS taux_conversion, -- Si visites = 0, retourne NULL au lieu d'une erreur division par zéro NULLIF(TRIM(nom), '') AS nom_propre -- Si nom est vide après TRIM, retourne NULL plutôt qu'une chaîne vide FROM stats; -- Pattern courant : valeur ou fallback UPDATE factures SET adresse_livraison = COALESCE(adresse_livraison, adresse_facturation) WHERE adresse_livraison IS NULL;
COALESCE est SQL standard et fonctionne sur tous les moteurs (MySQL, PostgreSQL, SQLite, SQL Server, Oracle). IFNULL est propre à MySQL/MariaDB. NVL est l'équivalent Oracle. Pour du code portable : utilisez toujours COALESCE.La normalisation répartit les données dans plusieurs tables spécialisées. Les jointures (JOIN) permettent de les recombiner à la volée lors des requêtes, en reliant les tables via leurs colonnes communes (les clés). C'est le mécanisme central du modèle relationnel.
Lignes avec correspondance dans les DEUX tables. La plus courante. Si un client n'a pas de commande, il n'apparaît pas.
TOUTES les lignes de gauche + correspondances de droite. NULL si pas de match. Très utilisé pour "trouver ce qui n'a pas de correspondance".
Inverse du LEFT JOIN. Rarement nécessaire — un RIGHT JOIN peut toujours être réécrit en LEFT JOIN en inversant l'ordre des tables.
Produit cartésien — chaque ligne de gauche avec chaque ligne de droite. Rarement utile, mais parfois nécessaire pour générer des combinaisons.
JOIN promotions p ON p.date_debut <= o.date AND p.date_fin >= o.date est un JOIN valide.INNER JOIN retourne uniquement les lignes ayant une correspondance dans les deux tables. C'est le JOIN par défaut (le mot INNER est optionnel). Il produit l'intersection des deux ensembles.
// INNER JOIN — de simple à complexe-- Jointure simple : clients avec leurs commandes SELECT c.nom AS client, c.ville, cmd.id AS commande, cmd.montant, cmd.created_at FROM clients c INNER JOIN commandes cmd ON c.id = cmd.client_id ORDER BY cmd.created_at DESC; -- Jointure sur 4 tables : du client jusqu'au produit SELECT c.nom AS client, cmd.id AS commande, p.nom AS produit, cat.nom AS categorie, lc.qte, lc.prix_unitaire, lc.qte * lc.prix_unitaire AS sous_total FROM clients c JOIN commandes cmd ON c.id = cmd.client_id JOIN lignes_commande lc ON cmd.id = lc.commande_id JOIN produits p ON lc.produit_id = p.id JOIN categories cat ON p.categorie_id = cat.id WHERE cmd.statut = 'livré' ORDER BY cmd.id, p.nom;
JOIN seul est un INNER JOIN implicite — les deux syntaxes sont équivalentes. La clarté est préférable : être explicite avec INNER JOIN, LEFT JOIN, etc. aide à comprendre l'intention d'une requête complexe au premier regard.LEFT JOIN retourne toutes les lignes de la table gauche, qu'elles aient une correspondance ou non dans la table droite. Les colonnes de droite affichent NULL quand il n'y a pas de correspondance. C'est le JOIN le plus utilisé après INNER JOIN.
// LEFT JOIN — cas d'usage essentiels-- Tous les clients avec le total de leurs commandes (y compris ceux sans commande) SELECT c.nom, c.email, COUNT(cmd.id) AS nb_commandes, -- 0 si aucune commande COALESCE(SUM(cmd.montant), 0) AS total_depense -- 0 si aucune commande FROM clients c LEFT JOIN commandes cmd ON c.id = cmd.client_id GROUP BY c.id, c.nom, c.email ORDER BY total_depense DESC; -- Pattern anti-jointure : trouver les lignes SANS correspondance -- (clients n'ayant JAMAIS passé commande) SELECT c.nom, c.email, c.created_at FROM clients c LEFT JOIN commandes cmd ON c.id = cmd.client_id WHERE cmd.id IS NULL; -- NULL = pas de correspondance trouvée -- Articles sans commentaires (autre usage de l'anti-jointure) SELECT a.titre, a.created_at FROM articles a LEFT JOIN commentaires co ON a.id = co.article_id WHERE co.id IS NULL ORDER BY a.created_at DESC;
Les autres types de jointures, moins courants mais utiles dans des cas spécifiques.
// autres types de jointures-- RIGHT JOIN : toutes les lignes de la table droite -- (Peu utilisé — un RIGHT JOIN peut toujours être réécrit en LEFT JOIN) SELECT c.nom, cmd.id, cmd.montant FROM commandes cmd RIGHT JOIN clients c ON cmd.client_id = c.id; -- Équivalent en LEFT JOIN : SELECT c.nom, cmd.id, cmd.montant FROM clients c LEFT JOIN commandes cmd ON c.id = cmd.client_id; -- FULL OUTER JOIN : toutes les lignes des deux tables -- (MySQL ne le supporte pas nativement — simulation avec UNION) SELECT c.nom, cmd.montant FROM clients c LEFT JOIN commandes cmd ON c.id = cmd.client_id UNION SELECT c.nom, cmd.montant FROM clients c RIGHT JOIN commandes cmd ON c.id = cmd.client_id; -- CROSS JOIN : produit cartésien (chaque ligne × chaque ligne) -- Exemple : générer toutes les combinaisons taille × couleur pour un produit SELECT t.valeur AS taille, c.valeur AS couleur FROM tailles t CROSS JOIN couleurs c; -- 4 tailles × 6 couleurs = 24 combinaisons
UNION combine verticalement les résultats de deux requêtes SELECT compatibles. UNION ALL fait la même chose mais conserve les doublons — plus rapide car il n'a pas à les éliminer.
// UNION et UNION ALL-- UNION : liste unique de tous les emails (clients + fournisseurs) SELECT email, nom, 'client' AS type_contact FROM clients UNION SELECT email, nom, 'fournisseur' AS type_contact FROM fournisseurs ORDER BY nom; -- ORDER BY s'applique au résultat final -- UNION ALL : cumul des ventes 2023 + 2024 (avec doublons potentiels) SELECT * FROM ventes_2023 UNION ALL SELECT * FROM ventes_2024; -- UNION avec conditions différentes par partie -- Rapport : clients vip ET clients récents SELECT id, nom, 'VIP' AS raison FROM clients WHERE statut = 'vip' UNION SELECT id, nom, 'Nouveau' AS raison FROM clients WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY); -- Un client VIP récent apparaît deux fois — utilisez UNION ALL si c'est voulu
Une sous-requête est une requête imbriquée dans une autre. Elle peut être scalaire (retourne 1 valeur), en ligne (retourne 1 ligne), ou en table (retourne plusieurs lignes). Puissante, mais parfois remplaçable par un JOIN plus performant.
// types de sous-requêtes-- Sous-requête scalaire dans WHERE : produits au-dessus du prix moyen SELECT nom, prix FROM produits WHERE prix > (SELECT AVG(prix) FROM produits); -- Retourne 1 valeur : la moyenne -- Sous-requête dans FROM (table dérivée) : doit avoir un alias SELECT departement, moy_salaire FROM ( SELECT departement, ROUND(AVG(salaire), 0) AS moy_salaire FROM employes GROUP BY departement ) AS stats_dept WHERE moy_salaire > 400000 ORDER BY moy_salaire DESC; -- Sous-requête corrélée : référence la requête externe (réévaluée pour chaque ligne) SELECT nom, salaire, departement FROM employes e1 WHERE salaire > ( SELECT AVG(salaire) FROM employes e2 WHERE e2.departement = e1.departement -- Corrélée : utilise e1 ); -- Retourne : chaque employé qui gagne plus que la moyenne de son département
EXISTS teste si une sous-requête retourne au moins une ligne. Il est souvent plus performant qu'IN car il s'arrête dès qu'il trouve une correspondance. NOT EXISTS est la façon la plus robuste de trouver les lignes sans correspondance (alternative à l'anti-jointure).
// EXISTS vs IN — performance et usage-- EXISTS : clients ayant au moins une commande > 50 000 SELECT nom, email FROM clients c WHERE EXISTS ( SELECT 1 -- Peu importe ce qu'on sélectionne (1 ou * ou n'importe quoi) FROM commandes cmd WHERE cmd.client_id = c.id -- Corrélée : lie avec la requête externe AND cmd.montant > 50000 ); -- NOT EXISTS : clients n'ayant JAMAIS commandé -- (plus robuste que LEFT JOIN IS NULL ou NOT IN) SELECT nom, email, created_at FROM clients c WHERE NOT EXISTS ( SELECT 1 FROM commandes cmd WHERE cmd.client_id = c.id ); -- Comparaison : 3 façons équivalentes de "clients sans commande" -- 1. Anti-jointure (généralement la plus rapide) WHERE c.id NOT IN (SELECT client_id FROM commandes WHERE client_id IS NOT NULL) -- 2. LEFT JOIN IS NULL LEFT JOIN commandes cmd ON c.id = cmd.client_id WHERE cmd.id IS NULL -- 3. NOT EXISTS (le plus sûr avec les NULL) WHERE NOT EXISTS (SELECT 1 FROM commandes WHERE client_id = c.id)
ANY est vrai si la condition est vraie pour au moins une valeur de la sous-requête. ALL exige que la condition soit vraie pour toutes les valeurs. En pratique, souvent remplacés par MIN/MAX pour la lisibilité.
// ANY et ALL-- ANY : au moins un employé de la liste gagne moins -- Trouver les managers qui gagnent plus qu'au moins un junior SELECT nom, salaire FROM employes WHERE type = 'manager' AND salaire > ANY ( SELECT salaire FROM employes WHERE type = 'junior' ); -- Équivalent (plus lisible) : AND salaire > (SELECT MIN(salaire) FROM employes WHERE type = 'junior') -- ALL : plus cher que TOUS les produits en promotion SELECT nom, prix FROM produits_premium WHERE prix > ALL ( SELECT prix_promo FROM promotions_actives ); -- Équivalent : WHERE prix > (SELECT MAX(prix_promo) FROM promotions_actives)
= ANY (sous-requête) est strictement équivalent à IN (sous-requête). <> ALL (sous-requête) est équivalent à NOT IN (sous-requête) (avec le même piège des NULL). En pratique, préférez IN/NOT IN ou les sous-requêtes avec MIN/MAX pour la lisibilité.Un self JOIN relie une table à elle-même. Indispensable pour les structures hiérarchiques (organigrammes, catégories imbriquées, commentaires avec réponses) où les relations existent au sein de la même table.
// self JOIN — hiérarchies et relations réflexives-- Table : employes(id, nom, poste, manager_id) -- manager_id référence id de la même table -- Chaque employé avec le nom de son manager SELECT emp.nom AS employe, emp.poste, mgr.nom AS manager, mgr.poste AS poste_manager FROM employes emp LEFT JOIN employes mgr ON emp.manager_id = mgr.id; -- LEFT JOIN car le PDG n'a pas de manager (manager_id IS NULL) -- Trouver les collègues du même département SELECT e1.nom AS employe, e2.nom AS collegue FROM employes e1 JOIN employes e2 ON e1.departement = e2.departement AND e1.id != e2.id -- Exclure les paires avec soi-même ORDER BY e1.nom, e2.nom; -- Catégories avec leurs sous-catégories (1 niveau) SELECT parent.nom AS categorie, enfant.nom AS sous_categorie FROM categories parent LEFT JOIN categories enfant ON enfant.parent_id = parent.id WHERE parent.parent_id IS NULL -- Seulement les catégories racine ORDER BY parent.nom, enfant.nom;
En production, les requêtes complexes combinent souvent 5 à 10 tables. La clé : structurer lisiblement avec des alias cohérents, ajouter les jointures progressivement, et vérifier le résultat à chaque étape.
// requête analytique complète — rapport de ventesSELECT c.nom AS client, c.ville, cat.nom AS categorie, p.nom AS produit, SUM(lc.qte) AS quantite_totale, SUM(lc.qte * lc.prix_unitaire) AS ca_total, COUNT(DISTINCT cmd.id) AS nb_commandes, MIN(cmd.created_at) AS premiere_commande, MAX(cmd.created_at) AS derniere_commande, AVG(lc.qte * lc.prix_unitaire) AS panier_moyen_par_article FROM clients c JOIN commandes cmd ON c.id = cmd.client_id JOIN lignes_commande lc ON cmd.id = lc.commande_id JOIN produits p ON lc.produit_id = p.id JOIN categories cat ON p.categorie_id = cat.id LEFT JOIN remises r ON cmd.remise_id = r.id -- Optionnel WHERE cmd.statut = 'livré' AND cmd.created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH) AND cat.active = 1 GROUP BY c.id, c.nom, c.ville, cat.id, cat.nom, p.id, p.nom HAVING SUM(lc.qte * lc.prix_unitaire) > 50000 ORDER BY ca_total DESC LIMIT 100;
Commencez par la table principale seule. Ajoutez un JOIN à la fois. Vérifiez le nombre de lignes après chaque ajout. Si le nombre explose, votre condition ON est incorrecte (produit cartésien). Si des lignes disparaissent, passez à LEFT JOIN pour diagnostiquer.
Un index est une structure de données auxiliaire (généralement un B-Tree) qui maintient une copie triée des valeurs d'une ou plusieurs colonnes. Il permet au moteur de localiser rapidement les lignes sans lire toute la table — comme un index de livre évite de le lire entièrement.
// gestion complète des index-- Index simple : accélère les WHERE sur email CREATE INDEX idx_email ON utilisateurs(email); -- Index unique : garantit l'unicité + accélère les recherches CREATE UNIQUE INDEX idx_email_unique ON utilisateurs(email); -- Index composite : pour WHERE qui filtre sur plusieurs colonnes -- L'ordre des colonnes dans l'index est crucial ! CREATE INDEX idx_ville_statut ON clients(ville, statut); -- Efficace pour : WHERE ville = ? AND statut = ? -- Efficace pour : WHERE ville = ? (préfixe gauche) -- Inefficace pour : WHERE statut = ? (pas de préfixe gauche) -- Index sur expression (MySQL 8+ / PostgreSQL) CREATE INDEX idx_email_lower ON utilisateurs((LOWER(email))); -- Index couvrant (covering index) : inclut toutes les colonnes du SELECT -- Le moteur ne lit jamais la table — tout vient de l'index CREATE INDEX idx_couvrant ON commandes(client_id, created_at, montant, statut); -- Index fulltext : pour les recherches dans du texte CREATE FULLTEXT INDEX idx_ft_contenu ON articles(titre, contenu); SELECT * FROM articles WHERE MATCH(titre, contenu) AGAINST('SQL performance'); -- Voir et supprimer les index SHOW INDEX FROM utilisateurs; DROP INDEX idx_email ON utilisateurs;
| Requête | Sans index | Avec index | Gain |
|---|---|---|---|
| WHERE email = ? | ~200ms | ~0.3ms | ×600 |
| WHERE ville + statut | ~450ms | ~1ms | ×450 |
| ORDER BY created_at | ~800ms | ~5ms | ×160 |
SELECT * FROM sys.schema_unused_indexes;Une transaction est un groupe d'opérations traité comme une unité indivisible. Soit tout réussit et est persisté (COMMIT), soit tout est annulé comme si rien ne s'était passé (ROLLBACK). Les transactions respectent les propriétés ACID.
Tout ou rien. Si une opération de la transaction échoue, toutes les autres sont annulées.
Avant et après la transaction, la base est dans un état valide. Les contraintes d'intégrité sont respectées.
Les transactions concurrentes ne se voient pas mutuellement pendant leur exécution.
Un COMMIT persiste définitivement, même en cas de panne système ou de redémarrage.
// transaction complète avec gestion d'erreurSTART TRANSACTION; -- Virement bancaire : 50 000 FCFA du compte 1 vers le compte 2 UPDATE comptes SET solde = solde - 50000 WHERE id = 1; -- Vérifier que le solde n'est pas négatif -- Si solde < 0 dans votre application → ROLLBACK SELECT solde FROM comptes WHERE id = 1 FOR UPDATE; -- Verrou pendant la transaction UPDATE comptes SET solde = solde + 50000 WHERE id = 2; -- Enregistrer l'opération INSERT INTO historique_virements (de_compte, vers_compte, montant, date) VALUES (1, 2, 50000, NOW()); -- Tout s'est bien passé → valider définitivement COMMIT; -- En cas d'erreur → annuler tout ROLLBACK; -- Sauvegarde intermédiaire SAVEPOINT apres_debit; ROLLBACK TO SAVEPOINT apres_debit; -- Retour partiel possible
SET autocommit = 0;Une vue est une requête nommée et sauvegardée qui se comporte comme une table virtuelle. Elle ne stocke pas les données — à chaque appel, elle exécute la requête sous-jacente. Puissant outil d'abstraction, de sécurité et de simplification.
// vues — création et utilisation avancée-- Créer une vue : résumé complet des clients CREATE OR REPLACE VIEW v_tableau_bord_clients AS SELECT c.id, c.nom, c.email, c.ville, c.created_at AS date_inscription, COUNT(cmd.id) AS nb_commandes, COALESCE(SUM(cmd.montant), 0) AS total_depense, MAX(cmd.created_at) AS derniere_commande, CASE WHEN SUM(cmd.montant) >= 1000000 THEN 'Platine' WHEN SUM(cmd.montant) >= 500000 THEN 'Or' WHEN SUM(cmd.montant) >= 100000 THEN 'Argent' ELSE 'Standard' END AS segment FROM clients c LEFT JOIN commandes cmd ON c.id = cmd.client_id AND cmd.statut = 'livré' WHERE c.deleted_at IS NULL GROUP BY c.id, c.nom, c.email, c.ville, c.created_at; -- Utiliser comme une table normale SELECT * FROM v_tableau_bord_clients WHERE segment = 'Platine' ORDER BY total_depense DESC; SELECT segment, COUNT(*) FROM v_tableau_bord_clients GROUP BY segment; -- Vue pour restreindre l'accès aux données sensibles CREATE VIEW v_employes_public AS SELECT id, nom, prenom, departement, poste FROM employes; -- Les colonnes salaire et données personnelles sont masquées
ALTER TABLE modifie la structure d'une table existante sans perdre les données. Attention : sur de grandes tables en production, certaines opérations ALTER TABLE peuvent verrouiller la table pendant plusieurs minutes ou heures.
// toutes les opérations ALTER TABLE-- Ajouter une colonne ALTER TABLE utilisateurs ADD COLUMN telephone VARCHAR(20) DEFAULT NULL, ADD COLUMN avatar_url VARCHAR(500) AFTER email; -- Position précise -- Modifier le type d'une colonne ALTER TABLE produits MODIFY COLUMN description MEDIUMTEXT; -- Renommer une colonne (MySQL 8+) ALTER TABLE clients RENAME COLUMN tel TO telephone; -- Supprimer une colonne ALTER TABLE utilisateurs DROP COLUMN colonne_obsolete; -- Ajouter/supprimer un index ALTER TABLE commandes ADD INDEX idx_client_date (client_id, created_at), DROP INDEX idx_ancien; -- Ajouter une clé étrangère ALTER TABLE commandes ADD CONSTRAINT fk_cmd_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT; -- Changer le moteur de stockage ALTER TABLE ma_table ENGINE = InnoDB; -- Renommer la table RENAME TABLE anciens_clients TO clients_archive;
Les window functions (fonctions fenêtre) calculent des valeurs sur un ensemble de lignes lié à la ligne courante, sans agréger — chaque ligne garde son identité. Disponibles depuis SQL:2003, elles sont parmi les outils analytiques les plus puissants.
// window functions complètesSELECT nom, departement, salaire, -- Rang et numérotation ROW_NUMBER() OVER (ORDER BY salaire DESC) AS rang_absolu, RANK() OVER (ORDER BY salaire DESC) AS rang_ex_aequo, -- Trous DENSE_RANK() OVER (ORDER BY salaire DESC) AS rang_dense, -- Sans trous -- Rang au sein de chaque département RANK() OVER (PARTITION BY departement ORDER BY salaire DESC) AS rang_dept, -- Agrégats sans GROUP BY AVG(salaire) OVER () AS moy_globale, AVG(salaire) OVER (PARTITION BY departement) AS moy_departement, salaire - AVG(salaire) OVER (PARTITION BY departement) AS ecart_moy_dept, -- Cumul SUM(salaire) OVER (ORDER BY date_embauche) AS cumul_masse_salariale, -- Décalage temporel LAG(salaire, 1, 0) OVER (ORDER BY date_embauche) AS salaire_precedent, LEAD(salaire, 1, 0) OVER (ORDER BY date_embauche) AS salaire_suivant, -- Percentile PERCENT_RANK() OVER (ORDER BY salaire) AS percentile, NTILE(4) OVER (ORDER BY salaire) AS quartile FROM employes;
WHERE rang_region = 1 après avoir calculé RANK() OVER (PARTITION BY region ORDER BY ventes DESC) AS rang_region dans une CTE ou sous-requête. Impossible à exprimer simplement avec GROUP BY seul.Les CTE (WITH ... AS) créent des tables temporaires nommées pour la durée d'une requête. Elles rendent les requêtes complexes lisibles en les décomposant en étapes nommées. Les CTE récursives permettent de traverser des hiérarchies.
// CTE simples et récursives-- CTE multiple : décomposer une requête complexe en étapes WITH clients_actifs AS ( SELECT id, nom, email, ville FROM clients WHERE statut = 'actif' AND deleted_at IS NULL ), stats_commandes AS ( SELECT client_id, COUNT(*) AS nb, SUM(montant) AS total, MAX(created_at) AS derniere FROM commandes WHERE statut = 'livré' GROUP BY client_id ), segments AS ( SELECT ca.*, COALESCE(sc.nb, 0) AS nb_commandes, COALESCE(sc.total, 0) AS total_depense, sc.derniere FROM clients_actifs ca LEFT JOIN stats_commandes sc ON ca.id = sc.client_id ) SELECT ville, COUNT(*) AS nb_clients, AVG(total_depense) AS depense_moyenne FROM segments GROUP BY ville ORDER BY depense_moyenne DESC; -- CTE récursive : traverser l'organigramme complet WITH RECURSIVE organigramme AS ( -- Cas de base : le PDG (sans manager) SELECT id, nom, manager_id, 0 AS niveau, CAST(nom AS CHAR(500)) AS chemin FROM employes WHERE manager_id IS NULL UNION ALL -- Cas récursif : sous-ordres directs SELECT e.id, e.nom, e.manager_id, o.niveau + 1, CONCAT(o.chemin, ' > ', e.nom) FROM employes e JOIN organigramme o ON e.manager_id = o.id ) SELECT REPEAT(' ', niveau) AS indentation, nom, niveau, chemin FROM organigramme ORDER BY chemin;
EXPLAIN révèle le plan d'exécution choisi par le moteur : quelles tables sont lues, dans quel ordre, si des index sont utilisés, combien de lignes sont estimées. C'est l'outil de diagnostic indispensable pour toute requête lente.
// diagnostic de performance-- Analyser une requête EXPLAIN SELECT * FROM commandes WHERE client_id = 42; -- Format JSON : beaucoup plus de détails (MySQL 8+) EXPLAIN FORMAT=JSON SELECT * FROM commandes c JOIN clients cl ON c.client_id = cl.id WHERE c.statut = 'livré' AND cl.ville = 'Dakar'; -- ANALYZE : exécute réellement la requête et mesure les vraies métriques EXPLAIN ANALYZE SELECT ... -- PostgreSQL / MySQL 8.0.18+
| Colonne | Valeur | Interprétation |
|---|---|---|
| type | ALL | ⚠️ Full scan — lit chaque ligne. Catastrophique sur grandes tables. |
| type | range | ✓ Lit une plage de l'index (BETWEEN, <, >). Acceptable. |
| type | ref | ✓✓ Utilise un index non-unique. Bon. |
| type | const | ✓✓✓ Clé primaire ou unique = 1 ligne. Parfait. |
| key | NULL | ⚠️ Aucun index utilisé. Créez un index sur les colonnes WHERE/JOIN. |
| rows | 1 000 000 | ⚠️ Estime devoir lire 1M lignes. Optimisez avec un index. |
| Extra | Using index | ✓✓ Index couvrant — la table n'est pas lue du tout. |
| Extra | Using filesort | ⚠️ Tri en mémoire. Indexez la colonne ORDER BY. |
| Extra | Using temporary | ⚠️ Table temporaire créée. Souvent dû à GROUP BY non indexé. |
1. Identifiez les requêtes lentes avec le slow query log : SET GLOBAL slow_query_log = ON;
2. Analysez avec EXPLAIN. Cherchez type = ALL et rows élevé.
3. Créez l'index manquant sur les colonnes WHERE et JOIN ON.
4. Re-vérifiez avec EXPLAIN — le type devrait passer à ref ou const.
Les procédures stockées encapsulent la logique métier dans la base. Les triggers s'exécutent automatiquement en réponse aux opérations DML. Deux outils puissants pour garantir la cohérence indépendamment de l'application.
// procédure stockée avancéeDELIMITER // CREATE PROCEDURE TraiterCommande( IN p_client_id INT, IN p_produits JSON, -- [{"id":1,"qte":2},{"id":3,"qte":1}] OUT p_commande_id INT, OUT p_total DECIMAL(10,2) ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; -- Repropage l'erreur END; START TRANSACTION; -- Créer la commande INSERT INTO commandes (client_id) VALUES (p_client_id); SET p_commande_id = LAST_INSERT_ID(); SET p_total = 0; -- Insérer chaque ligne (simplifié) INSERT INTO lignes_commande (commande_id, produit_id, qte, prix_unitaire) SELECT p_commande_id, p.id, j.qte, p.prix FROM JSON_TABLE(p_produits, '$[*]' COLUMNS(id INT PATH '$.id', qte INT PATH '$.qte')) j JOIN produits p ON p.id = j.id; SELECT SUM(qte * prix_unitaire) INTO p_total FROM lignes_commande WHERE commande_id = p_commande_id; UPDATE commandes SET montant_total = p_total WHERE id = p_commande_id; COMMIT; END // DELIMITER ; -- Trigger : archiver avant suppression DELIMITER // CREATE TRIGGER trig_archive_client BEFORE DELETE ON clients FOR EACH ROW BEGIN INSERT INTO clients_archive SELECT *, NOW() FROM clients WHERE id = OLD.id; END // DELIMITER ;
L'injection SQL est classée dans le Top 3 OWASP des vulnérabilités web depuis plus d'une décennie. Elle permet à un attaquant de lire, modifier, supprimer vos données, ou même exécuter des commandes système — simplement en manipulant un champ de formulaire.
// démonstration d'injection SQL-- ❌ DANGEREUX : concaténation directe d'input utilisateur -- Champ login : l'utilisateur entre → ' OR '1'='1 requête = "SELECT * FROM users WHERE login = '" + input_login + "'" -- Résultat : SELECT * FROM users WHERE login = '' OR '1'='1' -- Retourne TOUS les utilisateurs → authentification contournée -- Attaque destructrice : -- Input : '; DROP TABLE users; -- -- Résultat : SELECT ... WHERE login = ''; DROP TABLE users; --' -- Efface toute la table users ! -- Exfiltration de données : -- Input : ' UNION SELECT login, password, NULL FROM admins --
// protection : requêtes préparées dans différents langages-- PHP avec PDO (recommandé) $stmt = $pdo->prepare("SELECT * FROM users WHERE login = ? AND password = ?"); $stmt->execute([$_POST['login'], hash('sha256', $_POST['password'])]); -- Node.js avec mysql2 const [rows] = await pool.execute( "SELECT * FROM users WHERE login = ? AND password = ?", [req.body.login, sha256(req.body.password)] ); -- Python avec mysql-connector cursor.execute( "SELECT * FROM users WHERE login = %s AND password = %s", (login, sha256(password)) ) -- Java avec PreparedStatement PreparedStatement stmt = conn.prepareStatement( "SELECT * FROM users WHERE login = ? AND password = ?" ); stmt.setString(1, login); stmt.setString(2, sha256Password);
Vous avez parcouru les 50 leçons — des fondements jusqu'aux techniques avancées d'optimisation. Voici les principes qui distinguent un développeur SQL professionnel et des recommandations pour la suite.
INT AUTO_INCREMENT PRIMARY KEY. Sans identifiant unique, les mises à jour ciblées, les jointures, et la suppression précise deviennent impossibles.INSERT INTO t (col1, col2) VALUES (...). Jamais INSERT INTO t VALUES (...). Une colonne ajoutée ou réordonnée suffit à casser silencieusement vos insertions.type = ALL sur une grande table est un problème à résoudre. Vérifiez les colonnes WHERE et JOIN avec EXPLAIN. Créez les index manquants. Remesurez.CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci. Pas de douleurs avec les emojis, l'arabe, le chinois. Une fois les données corrompues, la migration est pénible.mysqldump quotidien. Testez la restauration chaque mois sur une machine de staging. Une sauvegarde non testée n'est pas une sauvegarde — c'est une fausse sécurité.La prochaine étape : construire de vrais projets. Une API REST avec Node.js ou Python, un tableau de bord analytique, un système de facturation. Chaque requête concrète consolide la compréhension plus que dix leçons théoriques. Le voyage ne fait que commencer. 🚀