╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS           ║
║                     PARTIE 1 — FONDATIONS DES BASES DE DONNÉES                   ║
║                              Chapitres 1 à 5                                     ║
╚══════════════════════════════════════════════════════════════════════════════════╝

BASE DE DONNÉES UTILISÉE TOUT AU LONG DU GUIDE
══════════════════════════════════════════════

Nous utiliserons une base de données réaliste d'une entreprise de e-commerce
appelée "ShopFlow". Elle contient les tables suivantes (introduites progressivement) :

  - clients          -> informations sur les clients
  - produits         -> catalogue des produits
  - categories       -> catégories de produits
  - commandes        -> en-têtes de commandes
  - lignes_commande  -> détail des articles commandés
  - employes         -> équipe interne
  - fournisseurs     -> fournisseurs de produits
  - paiements        -> transactions de paiement
  - avis             -> avis clients sur les produits
  - entrepots        -> gestion des stocks

Cette base sera votre terrain d'entraînement réel tout au long du guide.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 1 — QU'EST-CE QU'UNE BASE DE DONNÉES ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Qu'est-ce que c'est ?
  Une base de données est un système organisé qui permet de stocker, gérer
  et récupérer des informations de manière structurée et efficace.

Pourquoi ça existe ?
  Avant les bases de données, les informations étaient stockées dans des fichiers
  texte ou tableurs. Problèmes :
  - Données dupliquées (même client dans 10 fichiers différents)
  - Pas de relations entre les données
  - Lenteur pour chercher des informations dans des millions de lignes
  - Pas de contrôle des accès (n'importe qui peut modifier)
  - Pas de protection contre la corruption

Dans quels cas on l'utilise ?
  -> Absolument partout : Amazon, Netflix, votre banque, votre application
    de livraison, votre réseau social, votre hôpital...
  -> Dès que vous avez besoin de stocker plus de quelques dizaines de données
  -> Dès que plusieurs personnes accèdent aux mêmes données simultanément

1.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
──────────────────────────────────────────

Définition formelle :
  Une base de données (BD) est un ensemble structuré de données stockées sur
  un support persistant, accessible par un logiciel appelé SGBD
  (Système de Gestion de Base de Données).

Les 4 propriétés fondamentales d'une BD (modèle ACID) :

  A — Atomicité
      Une transaction est soit entièrement exécutée, soit pas du tout.
      Exemple : virement bancaire de 100€
        -> débiter compte A ET créditer compte B
        -> si l'une échoue, l'autre est annulée aussi

  C — Cohérence
      La base passe toujours d'un état valide à un autre état valide.
      Exemple : un client ne peut pas commander un produit inexistant

  I — Isolation
      Les transactions concurrentes n'interfèrent pas entre elles.
      Exemple : deux caissiers peuvent traiter des commandes simultanément
        sans se "polluer" mutuellement

  D — Durabilité
      Une transaction validée est permanente même en cas de panne.
      Exemple : après confirmation d'achat, même si le serveur plante,
        la commande est sauvegardée

Structure logique d'une base de données relationnelle :

  BASE DE DONNÉES
  └── SCHÉMA (namespace logique)
      ├── TABLE clients
      │   ├── COLONNE id_client    (INTEGER, clé primaire)
      │   ├── COLONNE nom          (VARCHAR)
      │   ├── COLONNE email        (VARCHAR, unique)
      │   └── COLONNE date_inscription (DATE)
      ├── TABLE commandes
      │   ├── COLONNE id_commande  (INTEGER, clé primaire)
      │   ├── COLONNE id_client    (INTEGER, clé étrangère -> clients)
      │   └── COLONNE montant_total (DECIMAL)
      └── INDEX, VUES, PROCÉDURES, TRIGGERS...

Analogie réelle : imaginez une bibliothèque
  - La bibliothèque = la base de données
  - Les rayons = les tables
  - Les livres = les enregistrements (lignes)
  - Les fiches descriptives des livres = les colonnes
  - Le catalogue = les index (pour trouver vite)
  - Le bibliothécaire = le SGBD

1.3 COMMENT ÇA FONCTIONNE EN INTERNE ?
────────────────────────────────────────

Architecture d'un SGBD :

  ┌─────────────────────────────────────────┐
  │         APPLICATION / CLIENT            │
  │   (votre code Python, PHP, Java...)     │
  └──────────────────┬──────────────────────┘
                     │ SQL Query
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │           COUCHE RÉSEAU                 │
  │   (TCP/IP, socket, connexion BD)        │
  └──────────────────┬──────────────────────┘
                     │
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │           SQL PARSER                    │
  │   Analyse la syntaxe de la requête      │
  │   Construit l'Arbre Syntaxique (AST)    │
  └──────────────────┬──────────────────────┘
                     │
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │         QUERY OPTIMIZER                 │
  │   Choisit le meilleur plan d'exécution  │
  │   Utilise les statistiques des tables   │
  │   Décide d'utiliser les index ou non    │
  └──────────────────┬──────────────────────┘
                     │
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │        EXECUTION ENGINE                 │
  │   Exécute le plan choisi par l'optimizer│
  │   Accède aux données via le Buffer Pool │
  └──────────────────┬──────────────────────┘
                     │
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │         STORAGE ENGINE                  │
  │   Gère les fichiers disque              │
  │   InnoDB (MySQL), PostgreSQL Heap, etc. │
  │   Gère les transactions et verrous      │
  └──────────────────┬──────────────────────┘
                     │
                     [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │              DISQUE                     │
  │   Fichiers de données (.ibd, .dat...)   │
  │   Journaux de transactions (WAL)        │
  └─────────────────────────────────────────┘

Le Buffer Pool (très important pour la performance) :
  Le SGBD maintient en mémoire RAM un cache des pages de données.
  Lire en RAM = nanosecondes
  Lire sur disque = millisecondes (1 000 000x plus lent !)
  -> L'optimisation SQL cherche toujours à maximiser les lectures en RAM

1.4 POURQUOI UTILISER UNE BASE DE DONNÉES ?
─────────────────────────────────────────────

Performance :
  -> Index B-Tree : trouver 1 enregistrement parmi 1 milliard en < 1 milliseconde
  -> Optimiseur de requêtes automatique
  -> Cache intelligent en mémoire

Scalabilité :
  -> Gère des téraoctets de données
  -> Milliers de connexions simultanées
  -> Partitionnement horizontal et vertical

Intégrité des données :
  -> Contraintes (PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE)
  -> Transactions ACID
  -> Protection contre la corruption

Sécurité :
  -> Authentification (utilisateurs, mots de passe)
  -> Autorisation (qui peut lire/écrire quoi)
  -> Chiffrement des données sensibles

1.5 QUAND UTILISER UNE BASE DE DONNÉES ?
─────────────────────────────────────────

Cas réels :

  E-COMMERCE (Amazon, Shopify) :
  -> Millions de produits, clients, commandes
  -> Stocks en temps réel
  -> Historique des achats

  BANQUE (BNP, Société Générale) :
  -> Transactions en temps réel
  -> Conformité réglementaire (historique inaltérable)
  -> Calculs d'intérêts sur des millions de comptes

  SAAS (Salesforce, HubSpot) :
  -> Multi-tenant (données de milliers d'entreprises isolées)
  -> Rapports et analytics complexes

  ANALYTICS (Netflix, Spotify) :
  -> Historique de visionnage/écoute
  -> Recommandations basées sur les données
  -> A/B testing

1.6 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Listez 3 applications que vous utilisez quotidiennement et identifiez
        quelles données elles pourraient stocker en base de données.

  Ex2 : Pour une application de covoiturage (type Yango/Uber), listez
        au moins 8 types d'informations à stocker.

  Ex3 : Expliquez avec vos propres mots ce qu'est le principe ACID
        en utilisant l'exemple d'une réservation de billet d'avion.

NIVEAU INTERMÉDIAIRE :
  Ex4 : Dessinez l'architecture d'une base de données pour un hôpital :
        quelles tables vous sembleraient nécessaires ?
        (Indication : patients, médecins, rendez-vous, prescriptions, chambres...)

  Ex5 : Pourquoi une base de données est-elle préférable à un fichier Excel
        pour gérer 500 000 clients ? Listez au moins 5 raisons.

  Ex6 : Expliquez pourquoi le Buffer Pool est crucial pour la performance
        d'un SGBD. Donnez un exemple concret avec des chiffres.

NIVEAU AVANCÉ :
  Ex7 : Recherchez la définition du théorème CAP (Consistency, Availability,
        Partition tolerance) et expliquez pourquoi les BD relationnelles
        font un choix particulier entre ces trois propriétés.

  Ex8 : Comparez les performances théoriques : pourquoi une BD qui cherche
        1 enregistrement parmi N peut le faire en O(log N) avec un index
        au lieu de O(N) sans index ?

  Ex9 : Imaginez une BD pour un réseau social avec 1 milliard d'utilisateurs.
        Quels défis de scalabilité identifiez-vous et quelles solutions
        architecturales proposeriez-vous ?

1.7 CORRIGÉS DÉTAILLÉS
────────────────────────

CORRIGÉ Ex1 :
  WhatsApp -> messages, contacts, groupes, médias, statuts de lecture
  Instagram -> photos, vidéos, commentaires, likes, followers, stories
  Google Maps -> lieux, avis, photos, itinéraires, horaires

CORRIGÉ Ex3 (ACID avec billet d'avion) :
  Transaction : "Réserver siège 14A sur le vol DA123"

  Atomicité : soit le siège est réservé ET le paiement débité,
              soit rien ne se passe. Pas de "siège réservé sans paiement".

  Cohérence : avant la transaction, le siège 14A est libre.
              après la transaction, il est occupé. Jamais dans un état bâtard.

  Isolation : si deux clients essaient d'acheter 14A simultanément,
              un seul réussit. L'autre voit "siège non disponible".

  Durabilité : une fois la confirmation reçue, même si le serveur
               plante 1 seconde après, la réservation est sauvegardée.

CORRIGÉ Ex8 (O(log N) vs O(N)) :
  Sans index (scan complet) :
  -> Pour 1 000 000 enregistrements, on lit en moyenne 500 000 lignes
  -> Si lire 1 ligne = 0.001ms -> total = 500 secondes !

  Avec index B-Tree (O(log N)) :
  -> log₂(1 000 000) ≈ 20 comparaisons seulement
  -> 20 × 0.001ms = 0.02ms
  -> 25 000 fois plus rapide !

  C'est le même principe que chercher dans un dictionnaire :
  vous ne lisez pas toutes les pages, vous ouvrez au bon endroit.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 2 — TYPES DE BASES DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Qu'est-ce que c'est ?
  Il existe plusieurs familles de bases de données, chacune optimisée
  pour un type de problème différent. SQL est le langage des bases
  relationnelles, mais il faut connaître les alternatives pour choisir
  la bonne technologie.

Pourquoi ça existe ?
  Parce qu'il n'y a pas de solution universelle :
  -> Stocker des documents JSON flexibles ≠ stocker des données tabulaires
  -> Des relations complexes ≠ des clés-valeurs simples
  -> Du temps réel ≠ de l'analytique historique

2.2 LES GRANDES FAMILLES DE BASES DE DONNÉES
─────────────────────────────────────────────

FAMILLE 1 : BASES RELATIONNELLES (SQL / RDBMS)
════════════════════════════════════════════════

  Principe : données organisées en tables avec des relations entre elles.
             Langage : SQL (Structured Query Language)

  Caractéristiques :
  -> Schéma rigide et défini à l'avance
  -> Relations entre tables via clés étrangères
  -> Transactions ACID garanties
  -> Requêtes complexes avec JOIN, GROUP BY, sous-requêtes

  Exemples et cas d'usage :

  PostgreSQL (le meilleur SGBD open source, recommandé pour ce guide)
  -> Utilisé par : Heroku, Instagram, Spotify, Reddit
  -> Avantages : conforme aux standards, extensions puissantes (PostGIS, JSONB)
  -> Idéal pour : applications web, analytics, données géographiques

  MySQL / MariaDB
  -> Utilisé par : WordPress (60% du web), Facebook (historiquement)
  -> Avantages : très répandu, nombreux hébergeurs, facile à démarrer
  -> Idéal pour : sites web, CMS, petits projets

  Microsoft SQL Server
  -> Utilisé par : entreprises utilisant l'écosystème Microsoft
  -> Avantages : intégration parfaite avec .NET, SSIS, Power BI
  -> Idéal pour : entreprises avec environnement Windows

  Oracle Database
  -> Utilisé par : grandes banques, assurances, télécoms
  -> Avantages : performances extrêmes, support enterprise payant
  -> Idéal pour : systèmes critiques à très haute disponibilité

  SQLite
  -> Utilisé par : applications mobile (iOS/Android), navigateurs web
  -> Avantages : sans serveur (fichier unique), zéro configuration
  -> Idéal pour : applications locales, prototypes, tests

  Comparatif rapide :

  ┌────────────────┬───────────┬──────────┬───────────┬──────────┐
  │ SGBD           │ Licence   │ Perf     │ Scalab.   │ Coût     │
  ├────────────────┼───────────┼──────────┼───────────┼──────────┤
  │ PostgreSQL     │ Open src  │ *****  │ *****   │ Gratuit  │
  │ MySQL          │ Open src  │ *****  │ *****   │ Gratuit  │
  │ SQL Server     │ Propriét. │ *****  │ *****   │ Payant   │
  │ Oracle         │ Propriét. │ *****  │ *****   │ Très cher│
  │ SQLite         │ Domaine   │ *****  │ *****   │ Gratuit  │
  └────────────────┴───────────┴──────────┴───────────┴──────────┘

FAMILLE 2 : BASES NOSQL (Not Only SQL)
═══════════════════════════════════════

  Principe : données non structurées ou semi-structurées.
             Schéma flexible, pas de SQL standard, pas forcément ACID.

  SOUS-TYPE A : Bases document (JSON/BSON)

    MongoDB :
    -> Stocke des documents JSON imbriqués
    -> Schéma flexible (pas besoin de définir les colonnes à l'avance)
    -> Parfait pour : catalogues produits avec attributs variables
                     APIs REST qui manipulent du JSON
                     Prototypage rapide

    Exemple de document MongoDB :
    {
      "_id": "63f2a1b...",
      "nom": "iPhone 15",
      "prix": 1199.99,
      "specs": {
        "couleur": "Noir",
        "stockage": "256GB",
        "camera": "48MP"
      },
      "tags": ["apple", "smartphone", "premium"]
    }

    -> VS SQL : en SQL, vous auriez 3 tables séparées (produits, specs, tags)
               avec des JOIN. MongoDB met tout dans un seul document.

  SOUS-TYPE B : Bases clé-valeur

    Redis :
    -> Stocke des paires clé->valeur en mémoire
    -> Ultra-rapide (nanosecondes)
    -> Parfait pour : cache, sessions utilisateur, files de messages
    -> Exemple : stocker le panier d'un utilisateur pendant sa session

    DynamoDB (Amazon) :
    -> Clé-valeur managé sur AWS
    -> Scalabilité infinie automatique
    -> Utilisé par : Amazon lui-même pour son catalogue

  SOUS-TYPE C : Bases colonne (Column-family)

    Apache Cassandra :
    -> Données organisées en colonnes plutôt qu'en lignes
    -> Idéal pour les séries temporelles (IoT, logs, métriques)
    -> Exemple : stocker des millions de capteurs IoT enregistrant
                toutes les secondes

  SOUS-TYPE D : Bases graphe

    Neo4j :
    -> Données organisées en noeuds et relations
    -> Parfait pour : réseaux sociaux, recommandations, détection fraude
    -> Exemple : "Trouver tous les amis d'amis de Paul qui aiment le jazz"
                -> En SQL : 5 JOIN complexes
                -> En Neo4j : 1 requête Cypher simple

2.3 COMPARAISON SQL vs NoSQL
──────────────────────────────

  Utilisez SQL (relationnel) quand :
  [OK] Vos données ont une structure fixe et prévisible
  [OK] Vous avez besoin de transactions ACID strictes (banque, e-commerce)
  [OK] Les relations entre entités sont complexes et nombreuses
  [OK] Vous avez besoin de requêtes ad-hoc complexes
  [OK] La cohérence des données est prioritaire

  Utilisez NoSQL quand :
  [OK] Vos données ont un schéma variable ou inconnu à l'avance
  [OK] Vous avez besoin d'une scalabilité horizontale massive
  [OK] Vos données sont déjà en JSON (APIs)
  [OK] La vitesse d'écriture prime sur la cohérence
  [OK] Données temporelles, graphes, ou cache

  ATTENTION : Ce n'est pas SQL vs NoSQL, mais "choisir le bon outil".
  Netflix utilise SIMULTANÉMENT :
  -> MySQL pour les données de facturation
  -> Cassandra pour l'historique de visionnage (séries temporelles)
  -> Redis pour le cache des sessions
  -> Elasticsearch pour la recherche de contenus

2.4 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Pour chaque cas d'usage, choisissez le type de BD et justifiez :
        a) Comptes bancaires et transactions
        b) Profils d'utilisateurs avec préférences variables
        c) Cache de pages web (expiration en secondes)

NIVEAU INTERMÉDIAIRE :
  Ex2 : Une startup crée une app de recettes de cuisine. Les recettes ont
        des ingrédients variables, des instructions, des photos, des tags.
        -> Proposez une architecture (peut être mixte SQL + NoSQL)
        -> Justifiez chaque choix

  Ex3 : Quels sont les risques de choisir une base NoSQL pour un système
        bancaire ? Expliquez avec des exemples concrets.

NIVEAU AVANCÉ :
  Ex4 : Le théorème CAP dit qu'un système distribué ne peut garantir que
        2 des 3 propriétés (Cohérence, Disponibilité, Tolérance partition).
        -> Quels choix font PostgreSQL, MongoDB, Cassandra ?
        -> Quelles sont les implications pratiques pour chaque ?

2.5 CORRIGÉS
─────────────

CORRIGÉ Ex1 :
  a) Comptes bancaires -> SQL (PostgreSQL ou Oracle)
     Raison : ACID obligatoire pour les transactions financières.
              Les virements impliquent des relations strictes.

  b) Profils variables -> MongoDB (NoSQL document)
     Raison : Chaque utilisateur peut avoir des attributs différents
              (préférences, abonnements, historique). Schéma flexible.

  c) Cache web -> Redis (clé-valeur)
     Raison : Accès en nanosecondes, TTL automatique, données simples.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 3 — INTRODUCTION À SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

3.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Qu'est-ce que SQL ?
  SQL = Structured Query Language (Langage de Requête Structuré)
  Prononciation : "S-Q-L" ou "sequel" (les deux sont acceptées)
  Créé en 1970 par IBM, standardisé par ANSI/ISO en 1986.
  C'est le langage universel pour communiquer avec les bases relationnelles.

Pourquoi il existe ?
  Avant SQL, chaque BD avait son propre langage de programmation propriétaire.
  SQL a été créé pour avoir UN langage standard compréhensible par tous les SGBD.

3.2 LES 5 SOUS-LANGAGES SQL
─────────────────────────────

SQL est divisé en 5 catégories de commandes :

  DDL — Data Definition Language (Définir la structure)
  ─────────────────────────────────────────────────────
  Commandes : CREATE, ALTER, DROP, TRUNCATE, RENAME
  Rôle : créer et modifier la structure (tables, index, vues)

  Exemples :
    CREATE TABLE clients (...)      -> créer une table
    ALTER TABLE clients ADD (...)   -> ajouter une colonne
    DROP TABLE clients              -> supprimer une table
    TRUNCATE TABLE clients          -> vider une table (plus rapide que DELETE)

  DML — Data Manipulation Language (Manipuler les données)
  ─────────────────────────────────────────────────────────
  Commandes : SELECT, INSERT, UPDATE, DELETE
  Rôle : lire, ajouter, modifier, supprimer des données

  Exemples :
    SELECT * FROM clients           -> lire les données
    INSERT INTO clients VALUES (...) -> ajouter
    UPDATE clients SET nom = 'X'    -> modifier
    DELETE FROM clients WHERE ...   -> supprimer

  DCL — Data Control Language (Contrôler les accès)
  ──────────────────────────────────────────────────
  Commandes : GRANT, REVOKE
  Rôle : gérer les permissions des utilisateurs

  Exemples :
    GRANT SELECT ON clients TO 'jean' -> donner accès
    REVOKE INSERT ON clients FROM 'jean' -> retirer accès

  TCL — Transaction Control Language (Gérer les transactions)
  ─────────────────────────────────────────────────────────────
  Commandes : BEGIN, COMMIT, ROLLBACK, SAVEPOINT
  Rôle : contrôler les transactions ACID

  Exemples :
    BEGIN;                          -> démarrer une transaction
    UPDATE comptes SET solde = ...
    COMMIT;                         -> valider
    ROLLBACK;                       -> annuler en cas d'erreur

  DQL — Data Query Language (Interroger les données)
  ────────────────────────────────────────────────────
  Commande principale : SELECT
  Note : SELECT est souvent inclus dans DML, mais techniquement c'est DQL.
  C'est la commande la plus utilisée (80% de votre temps en SQL).

3.3 STRUCTURE GÉNÉRALE D'UNE REQUÊTE SQL
─────────────────────────────────────────

Ordre d'écriture d'un SELECT complet :

  SELECT   [colonnes ou expressions]      <- quoi afficher
  FROM     [table(s)]                     <- d'où lire
  JOIN     [autre table] ON [condition]   <- joindre d'autres tables
  WHERE    [condition]                    <- filtrer les lignes
  GROUP BY [colonnes]                     <- regrouper
  HAVING   [condition sur groupe]         <- filtrer les groupes
  ORDER BY [colonnes]                     <- trier le résultat
  LIMIT    [n] OFFSET [m]                 <- paginer

IMPORTANT : l'ordre d'EXÉCUTION est différent de l'ordre d'ÉCRITURE !

  Ordre d'exécution (ce que le SGBD fait en interne) :
    1. FROM        -> identifier les tables source
    2. JOIN        -> joindre les tables
    3. WHERE       -> filtrer les lignes
    4. GROUP BY    -> grouper les lignes restantes
    5. HAVING      -> filtrer les groupes
    6. SELECT      -> calculer les expressions
    7. DISTINCT    -> éliminer les doublons
    8. ORDER BY    -> trier
    9. LIMIT/OFFSET-> paginer

  Pourquoi c'est important ?
  -> Vous ne pouvez pas utiliser un alias défini dans SELECT dans le WHERE !
    -- ERREUR : WHERE price_ttc > 100
    SELECT prix * 1.2 AS price_ttc FROM produits WHERE price_ttc > 100;

    -- CORRECT : recalculer l'expression dans WHERE
    SELECT prix * 1.2 AS price_ttc FROM produits WHERE prix * 1.2 > 100;
    -- OU utiliser HAVING (s'exécute après SELECT) :
    SELECT prix * 1.2 AS price_ttc FROM produits HAVING price_ttc > 100;

3.4 LES STANDARDS SQL
──────────────────────

  SQL-86 : première version standardisée
  SQL-89 : corrections mineures
  SQL-92 : grande révision (JOIN standardisés, types de données enrichis)
  SQL:1999 : fonctions fenêtres, récursivité, types utilisateurs
  SQL:2003 : XML, séquences, fonctions fenêtres enrichies
  SQL:2008 : TRUNCATE, FETCH FIRST
  SQL:2011 : tables temporelles (données historisées)
  SQL:2016 : support JSON natif
  SQL:2023 : dernière version officielle

  Dialectes SQL (différences entre SGBD) :
  ┌─────────────────────────┬──────────────┬─────────────┬──────────────┐
  │ Fonctionnalité          │ PostgreSQL   │ MySQL       │ SQL Server   │
  ├─────────────────────────┼──────────────┼─────────────┼──────────────┤
  │ Limiter résultats       │ LIMIT N      │ LIMIT N     │ TOP N        │
  │ Concaténation string    │ || ou CONCAT │ CONCAT      │ + ou CONCAT  │
  │ Date actuelle           │ NOW()        │ NOW()       │ GETDATE()    │
  │ Auto-incrémentation     │ SERIAL/GEN.  │ AUTO_INCR.  │ IDENTITY     │
  │ Fonctions fenêtres      │ Complet      │ Depuis v8.0 │ Complet      │
  └─────────────────────────┴──────────────┴─────────────┴──────────────┘

  Dans ce guide, nous utilisons PostgreSQL (le plus proche du standard SQL).
  Les différences avec MySQL seront signalées quand nécessaire.

3.5 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Classifiez chaque commande dans son sous-langage (DDL/DML/DCL/TCL) :
        a) CREATE INDEX
        b) UPDATE produits SET prix = 99
        c) GRANT SELECT TO 'alice'
        d) ROLLBACK
        e) DELETE FROM commandes

  Ex2 : Dans quel ordre le SGBD exécute-t-il les clauses de cette requête ?
        SELECT categorie, COUNT(*) as nb
        FROM produits
        WHERE prix > 50
        GROUP BY categorie
        HAVING COUNT(*) > 3
        ORDER BY nb DESC;

NIVEAU INTERMÉDIAIRE :
  Ex3 : Pourquoi ce code génère-t-il une erreur et comment le corriger ?
        SELECT prix * 1.2 AS prix_ttc
        FROM produits
        WHERE prix_ttc > 100;

  Ex4 : Écrivez une phrase expliquant la différence entre WHERE et HAVING.

NIVEAU AVANCÉ :
  Ex5 : Expliquez pourquoi SQL est un langage "déclaratif" et non "impératif",
        et quelles sont les implications sur comment vous écrivez des requêtes.

3.6 CORRIGÉS
─────────────

CORRIGÉ Ex1 :
  a) CREATE INDEX -> DDL (définit la structure)
  b) UPDATE produits -> DML (manipule les données)
  c) GRANT SELECT -> DCL (contrôle les accès)
  d) ROLLBACK -> TCL (contrôle les transactions)
  e) DELETE FROM commandes -> DML (manipule les données)

CORRIGÉ Ex2 (ordre d'exécution) :
  1. FROM produits       -> identifier la table source
  2. WHERE prix > 50     -> filtrer les produits chers
  3. GROUP BY categorie  -> regrouper par catégorie
  4. HAVING COUNT(*) > 3 -> garder groupes avec > 3 produits
  5. SELECT categorie, COUNT(*) AS nb -> calculer les valeurs
  6. ORDER BY nb DESC    -> trier par nombre décroissant

CORRIGÉ Ex3 (erreur alias) :
  L'erreur : le moteur SQL exécute WHERE avant SELECT.
  Au moment du WHERE, l'alias "prix_ttc" n'existe pas encore !

  Correction 1 — répéter l'expression :
    SELECT prix * 1.2 AS prix_ttc
    FROM produits
    WHERE prix * 1.2 > 100;

  Correction 2 — utiliser une sous-requête :
    SELECT prix_ttc FROM (
      SELECT prix * 1.2 AS prix_ttc FROM produits
    ) AS sous_requete
    WHERE prix_ttc > 100;

  Correction 3 — utiliser HAVING (pas idéal sans GROUP BY mais fonctionne) :
    SELECT prix * 1.2 AS prix_ttc
    FROM produits
    HAVING prix_ttc > 100;

CORRIGÉ Ex5 (déclaratif vs impératif) :
  Langage impératif (ex: Python) : vous dites COMMENT faire :
    result = []
    for product in products:
        if product.price > 100:
            result.append(product.name)
    result.sort()

  Langage déclaratif (SQL) : vous dites QUOI vous voulez :
    SELECT name FROM products WHERE price > 100 ORDER BY name;

  Implication : le SGBD choisit lui-même le chemin optimal pour arriver
  au résultat que vous avez décrit. Il peut choisir un index, paralléliser,
  réordonner les opérations — vous n'avez pas à vous en préoccuper.
  C'est l'optimiseur qui fait ce travail à votre place.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 4 — SGBD : MySQL, PostgreSQL ET LES AUTRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

4.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Un SGBD (Système de Gestion de Base de Données) est le logiciel qui :
  -> Interprète vos requêtes SQL
  -> Stocke physiquement les données
  -> Gère les transactions et la concurrence
  -> Contrôle la sécurité et les accès

Analogie : SQL est le langage, le SGBD est le moteur qui l'interprète.
  -> SQL = anglais (langage)
  -> PostgreSQL = traducteur humain anglophone (interpréteur)
  -> MySQL = autre traducteur humain anglophone
  Les deux comprennent l'anglais mais ont des accents différents !

4.2 POSTGRESQL EN PROFONDEUR
──────────────────────────────

Histoire :
  -> Né en 1986 à l'Université de Berkeley (Californie)
  -> Successeur de INGRES, ancêtre de tout le SQL relationnel
  -> Géré par une communauté mondiale (PostgreSQL Global Development Group)
  -> Version actuelle : PostgreSQL 16 (2023)

Architecture interne PostgreSQL :

  ┌─────────────────────────────────────────────────────────┐
  │                   PROCESSUS CLIENTS                     │
  │         (psql, pgAdmin, votre application)              │
  └────────────────────┬────────────────────────────────────┘
                       │ via socket TCP ou Unix
                       [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────────────────────┐
  │                  POSTMASTER (PID principal)             │
  │   -> Accepte les connexions                              │
  │   -> Fork un processus postgres par connexion            │
  └────────────────────┬────────────────────────────────────┘
                       │
          ┌────────────┼─────────────┐
          [BLACK_DOWN-POINTING_TRIANGLE]            [BLACK_DOWN-POINTING_TRIANGLE]             [BLACK_DOWN-POINTING_TRIANGLE]
  ┌───────────┐ ┌──────────┐ ┌────────────┐
  │ postgres  │ │ postgres │ │   WAL      │
  │ worker 1  │ │ worker 2 │ │  writer    │
  │ (client A)│ │(client B)│ │(journalisa)│
  └───────────┘ └──────────┘ └────────────┘
          │
          [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────────────────────┐
  │                  SHARED MEMORY                          │
  │   -> Shared Buffer Pool (cache des pages)                │
  │   -> WAL Buffers (journal des transactions)              │
  │   -> Lock Table (gestion des verrous)                    │
  └─────────────────────────────────────────────────────────┘
          │
          [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────────────────────┐
  │                  STOCKAGE DISQUE                        │
  │   -> Base directory : /var/lib/postgresql/data/          │
  │   -> pg_data/ -> fichiers de données (pages de 8KB)       │
  │   -> pg_wal/  -> journaux WAL (Write-Ahead Log)           │
  │   -> pg_log/  -> fichiers de logs                         │
  └─────────────────────────────────────────────────────────┘

Points forts de PostgreSQL :
  -> Conformité SQL la plus complète
  -> Support natif JSON et JSONB (hybride SQL/NoSQL)
  -> Extensions : PostGIS (données géographiques), pg_trgm (recherche floue)
  -> Types de données avancés : tableau, hstore, UUID, RANGE
  -> Fonctions fenêtres complètes
  -> Index avancés : GIN, GiST, BRIN en plus des B-Tree classiques
  -> Réplication intégrée (streaming replication)
  -> Transactions DDL (ALTER TABLE dans une transaction!)

4.3 MYSQL EN PROFONDEUR
────────────────────────

Histoire :
  -> Créé en 1995 par la société suédoise MySQL AB
  -> Racheté par Sun Microsystems (2008), puis Oracle (2010)
  -> Fork communautaire : MariaDB (créé par les fondateurs originaux)
  -> Propulsé le web : 60% des sites WordPress utilisent MySQL

Architecture MySQL (moteurs de stockage) :
  MySQL a la particularité de séparer le serveur SQL des moteurs de stockage :

  ┌─────────────────────────────────────────────┐
  │              MySQL Server                   │
  │   SQL Parser, Optimizer, Cache de requêtes  │
  └──────────────────┬──────────────────────────┘
                     │
        ┌────────────┼────────────┐
        [BLACK_DOWN-POINTING_TRIANGLE]            [BLACK_DOWN-POINTING_TRIANGLE]            [BLACK_DOWN-POINTING_TRIANGLE]
  ┌──────────┐ ┌──────────┐ ┌──────────┐
  │  InnoDB  │ │  MyISAM  │ │  Memory  │
  │(défaut)  │ │(legacy)  │ │(RAM seul)│
  │ ACID [OK]   │ │ ACID [X]   │ │ volatile │
  │ FK [OK]     │ │ FK [X]     │ │          │
  │ Perf *** │ │ Perf **  │ │ Perf ****│
  └──────────┘ └──────────┘ └──────────┘

  -> Toujours utiliser InnoDB (moteur par défaut depuis MySQL 5.5)
  -> MyISAM est obsolète : pas de transactions, pas de FK !

Différences MySQL vs PostgreSQL importantes pour ce guide :

  AUTO_INCREMENT vs SERIAL :
  -- MySQL
  CREATE TABLE clients (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nom VARCHAR(100)
  );
  -- PostgreSQL
  CREATE TABLE clients (
    id SERIAL PRIMARY KEY,
    nom VARCHAR(100)
  );
  -- PostgreSQL moderne (SQL standard)
  CREATE TABLE clients (
    id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nom VARCHAR(100)
  );

  LIMIT syntax :
  -- MySQL et PostgreSQL
  SELECT * FROM produits LIMIT 10 OFFSET 20;
  -- SQL Server
  SELECT TOP 10 * FROM produits;
  -- Oracle
  SELECT * FROM produits WHERE ROWNUM <= 10;

4.4 COMMENT CHOISIR SON SGBD ?
────────────────────────────────

  Critères de choix :

  Pour une application web standard -> PostgreSQL ou MySQL
  Pour Microsoft Stack (.NET) -> SQL Server
  Pour une app mobile -> SQLite
  Pour un data warehouse -> Snowflake, BigQuery, Redshift
  Pour une startup avec peu de ressources -> PostgreSQL (gratuit, puissant)
  Pour une grande entreprise -> Oracle ou SQL Server

  Notre recommandation dans ce guide : PostgreSQL
  Raisons :
  [OK] Gratuit et open source
  [OK] Le plus conforme au standard SQL
  [OK] Fonctionnalités avancées (JSON, window functions, CTE...)
  [OK] Excellent pour apprendre et production
  [OK] Employeurs valorisent PostgreSQL

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 5 — INSTALLATION DE L'ENVIRONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

5.1 INSTALLATION POSTGRESQL
─────────────────────────────

OPTION A : Installation locale (recommandée pour apprendre)

  Sur Ubuntu/Debian :
  ─────────────────
  # Ajouter le dépôt officiel PostgreSQL
  sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
  wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
  sudo apt-get update
  sudo apt-get -y install postgresql postgresql-contrib

  # Démarrer le service
  sudo systemctl start postgresql
  sudo systemctl enable postgresql  # démarrage automatique

  # Vérifier l'installation
  sudo systemctl status postgresql

  # Se connecter en tant qu'utilisateur postgres
  sudo -u postgres psql
  -- Vous êtes maintenant dans le client psql !
  -- Tapez \q pour quitter

  Sur macOS :
  ──────────
  # Avec Homebrew (méthode recommandée)
  brew install postgresql@16
  brew services start postgresql@16

  # Connexion
  psql postgres

  Sur Windows :
  ────────────
  -> Télécharger l'installateur depuis postgresql.org/download/windows
  -> Suivre l'assistant (choisir port 5432, mot de passe pour postgres)
  -> pgAdmin 4 est installé automatiquement (interface graphique)

OPTION B : Docker (meilleure option pour les développeurs)

  # Installer Docker Desktop depuis docker.com
  
  # Lancer PostgreSQL dans un conteneur
  docker run --name shopflow-db \
    -e POSTGRES_PASSWORD=motdepasse123 \
    -e POSTGRES_DB=shopflow \
    -p 5432:5432 \
    -d postgres:16

  # Se connecter au conteneur
  docker exec -it shopflow-db psql -U postgres -d shopflow

  # Arrêter / redémarrer
  docker stop shopflow-db
  docker start shopflow-db

OPTION C : En ligne (pour tester sans installation)

  -> https://sqliteonline.com (SQLite en ligne)
  -> https://www.db-fiddle.com (PostgreSQL, MySQL, SQLite)
  -> https://neon.tech (PostgreSQL managé, gratuit pour débuter)
  -> https://supabase.com (PostgreSQL + API, gratuit)

5.2 PREMIERS PAS AVEC PSQL (CLIENT EN LIGNE DE COMMANDE)
───────────────────────────────────────────────────────────

  Commandes psql essentielles (méta-commandes) :

  \l ou \list          -> lister les bases de données
  \c shopflow          -> se connecter à la base "shopflow"
  \dt                  -> lister les tables
  \d clients           -> décrire la structure de la table clients
  \di                  -> lister les index
  \dv                  -> lister les vues
  \df                  -> lister les fonctions
  \du                  -> lister les utilisateurs/rôles
  \timing              -> afficher le temps d'exécution des requêtes
  \e                   -> ouvrir un éditeur externe
  \i fichier.sql       -> exécuter un fichier SQL
  \o fichier.txt       -> sauvegarder la sortie dans un fichier
  \q                   -> quitter psql

  Exemple de session psql :

  $ sudo -u postgres psql

  postgres=# CREATE DATABASE shopflow;
  CREATE DATABASE

  postgres=# \c shopflow
  Vous êtes maintenant connecté à la base de données « shopflow »
  en tant qu'utilisateur « postgres ».

  shopflow=# \dt
  Aucune relation trouvée.

  shopflow=# CREATE TABLE test (id SERIAL PRIMARY KEY, nom TEXT);
  CREATE TABLE

  shopflow=# \dt
                Liste des relations
   Schéma | Nom  | Type  | Propriétaire
  --------+------+-------+--------------
   public | test | table | postgres

  shopflow=# \d test
                             Table « public.test »
   Colonne |  Type   | Collationnement | NULL possible |    Par défaut
  ---------+---------+-----------------+---------------+-------------------
   id      | integer |                 | not null      | nextval('test_id_seq')
   nom     | text    |                 |               |
  Index : "test_pkey" PRIMARY KEY, btree (id)

5.3 INSTALLATION DE PGADMIN 4 (INTERFACE GRAPHIQUE)
────────────────────────────────────────────────────

pgAdmin 4 est l'outil graphique officiel pour PostgreSQL.

  Installation :
  -> https://www.pgadmin.org/download/
  -> Disponible pour Windows, macOS, Linux, ou via Docker

  Connexion au serveur :
  1. Ouvrir pgAdmin 4
  2. Clic droit sur "Servers" -> "Register" -> "Server"
  3. Onglet "General" : Name = "ShopFlow Local"
  4. Onglet "Connection" :
     Host = localhost
     Port = 5432
     Database = shopflow
     Username = postgres
     Password = [votre mot de passe]
  5. Cliquer "Save"

5.4 CRÉATION DE LA BASE DE DONNÉES SHOPFLOW
─────────────────────────────────────────────

Voici le script SQL complet pour créer la base de données que nous
utiliserons dans tout ce guide. Exécutez-le une seule fois :

-- ═══════════════════════════════════════════════════
-- CRÉATION DE LA BASE SHOPFLOW — SCRIPT COMPLET
-- E-commerce multi-catégories avec analytics
-- ═══════════════════════════════════════════════════

-- Créer et sélectionner la base
CREATE DATABASE shopflow
  WITH ENCODING = 'UTF8'
       LC_COLLATE = 'fr_FR.UTF-8'
       LC_CTYPE = 'fr_FR.UTF-8'
       TEMPLATE = template0;

\c shopflow;

-- ─────────────────────────────────────────────────
-- TABLE : categories
-- ─────────────────────────────────────────────────
CREATE TABLE categories (
    id_categorie   SERIAL PRIMARY KEY,
    nom            VARCHAR(100) NOT NULL,
    description    TEXT,
    id_parent      INTEGER REFERENCES categories(id_categorie),
    date_creation  TIMESTAMP DEFAULT NOW()
);

COMMENT ON TABLE categories IS 'Arborescence des catégories de produits';
COMMENT ON COLUMN categories.id_parent IS 'NULL = catégorie racine';

-- ─────────────────────────────────────────────────
-- TABLE : fournisseurs
-- ─────────────────────────────────────────────────
CREATE TABLE fournisseurs (
    id_fournisseur  SERIAL PRIMARY KEY,
    raison_sociale  VARCHAR(200) NOT NULL,
    contact_nom     VARCHAR(100),
    contact_email   VARCHAR(150) UNIQUE,
    telephone       VARCHAR(20),
    pays            VARCHAR(100) DEFAULT 'France',
    actif           BOOLEAN DEFAULT TRUE,
    date_creation   TIMESTAMP DEFAULT NOW()
);

-- ─────────────────────────────────────────────────
-- TABLE : produits
-- ─────────────────────────────────────────────────
CREATE TABLE produits (
    id_produit      SERIAL PRIMARY KEY,
    reference       VARCHAR(50) UNIQUE NOT NULL,
    nom             VARCHAR(200) NOT NULL,
    description     TEXT,
    prix_ht         DECIMAL(10, 2) NOT NULL CHECK (prix_ht >= 0),
    taux_tva        DECIMAL(4, 2) DEFAULT 20.00,
    stock           INTEGER DEFAULT 0 CHECK (stock >= 0),
    stock_min       INTEGER DEFAULT 5,
    id_categorie    INTEGER REFERENCES categories(id_categorie),
    id_fournisseur  INTEGER REFERENCES fournisseurs(id_fournisseur),
    actif           BOOLEAN DEFAULT TRUE,
    date_creation   TIMESTAMP DEFAULT NOW(),
    date_maj        TIMESTAMP DEFAULT NOW()
);

-- Colonne calculée virtuelle (PostgreSQL 12+)
-- prix_ttc = prix_ht * (1 + taux_tva/100)
-- Note : en PostgreSQL, on peut créer une vue ou colonne générée

-- ─────────────────────────────────────────────────
-- TABLE : clients
-- ─────────────────────────────────────────────────
CREATE TABLE clients (
    id_client         SERIAL PRIMARY KEY,
    code_client       VARCHAR(20) UNIQUE NOT NULL,
    prenom            VARCHAR(100) NOT NULL,
    nom               VARCHAR(100) NOT NULL,
    email             VARCHAR(200) UNIQUE NOT NULL,
    telephone         VARCHAR(20),
    date_naissance    DATE,
    adresse_rue       VARCHAR(200),
    adresse_ville     VARCHAR(100),
    adresse_cp        VARCHAR(10),
    adresse_pays      VARCHAR(100) DEFAULT 'France',
    segment           VARCHAR(50) DEFAULT 'STANDARD'
                      CHECK (segment IN ('VIP', 'PREMIUM', 'STANDARD', 'NOUVEAU')),
    actif             BOOLEAN DEFAULT TRUE,
    newsletter        BOOLEAN DEFAULT FALSE,
    date_inscription  TIMESTAMP DEFAULT NOW(),
    derniere_connexion TIMESTAMP
);

-- ─────────────────────────────────────────────────
-- TABLE : employes
-- ─────────────────────────────────────────────────
CREATE TABLE employes (
    id_employe     SERIAL PRIMARY KEY,
    prenom         VARCHAR(100) NOT NULL,
    nom            VARCHAR(100) NOT NULL,
    email          VARCHAR(200) UNIQUE NOT NULL,
    poste          VARCHAR(100),
    departement    VARCHAR(100),
    salaire        DECIMAL(10, 2),
    id_manager     INTEGER REFERENCES employes(id_employe),
    date_embauche  DATE NOT NULL,
    actif          BOOLEAN DEFAULT TRUE
);

-- ─────────────────────────────────────────────────
-- TABLE : commandes
-- ─────────────────────────────────────────────────
CREATE TABLE commandes (
    id_commande      SERIAL PRIMARY KEY,
    numero_commande  VARCHAR(30) UNIQUE NOT NULL,
    id_client        INTEGER NOT NULL REFERENCES clients(id_client),
    id_employe       INTEGER REFERENCES employes(id_employe),
    date_commande    TIMESTAMP DEFAULT NOW(),
    date_livraison   TIMESTAMP,
    statut           VARCHAR(50) DEFAULT 'EN_ATTENTE'
                     CHECK (statut IN (
                       'EN_ATTENTE', 'CONFIRMEE', 'EN_PREPARATION',
                       'EXPEDIEE', 'LIVREE', 'ANNULEE', 'REMBOURSEE'
                     )),
    adresse_livraison_rue    VARCHAR(200),
    adresse_livraison_ville  VARCHAR(100),
    adresse_livraison_cp     VARCHAR(10),
    adresse_livraison_pays   VARCHAR(100) DEFAULT 'France',
    montant_ht        DECIMAL(12, 2) DEFAULT 0,
    montant_tva       DECIMAL(12, 2) DEFAULT 0,
    montant_ttc       DECIMAL(12, 2) DEFAULT 0,
    frais_livraison   DECIMAL(8, 2) DEFAULT 0,
    note_client       TEXT,
    note_interne      TEXT
);

-- ─────────────────────────────────────────────────
-- TABLE : lignes_commande
-- ─────────────────────────────────────────────────
CREATE TABLE lignes_commande (
    id_ligne       SERIAL PRIMARY KEY,
    id_commande    INTEGER NOT NULL REFERENCES commandes(id_commande),
    id_produit     INTEGER NOT NULL REFERENCES produits(id_produit),
    quantite       INTEGER NOT NULL CHECK (quantite > 0),
    prix_unitaire_ht  DECIMAL(10, 2) NOT NULL,
    taux_tva       DECIMAL(4, 2) DEFAULT 20.00,
    remise_pct     DECIMAL(5, 2) DEFAULT 0 CHECK (remise_pct BETWEEN 0 AND 100),
    montant_ligne_ht  DECIMAL(12, 2) GENERATED ALWAYS AS
                     (quantite * prix_unitaire_ht * (1 - remise_pct/100)) STORED
);

-- ─────────────────────────────────────────────────
-- TABLE : paiements
-- ─────────────────────────────────────────────────
CREATE TABLE paiements (
    id_paiement    SERIAL PRIMARY KEY,
    id_commande    INTEGER NOT NULL REFERENCES commandes(id_commande),
    date_paiement  TIMESTAMP DEFAULT NOW(),
    montant        DECIMAL(12, 2) NOT NULL,
    mode_paiement  VARCHAR(50) CHECK (mode_paiement IN (
                   'CARTE_CREDIT', 'VIREMENT', 'PAYPAL',
                   'CHEQUE', 'ESPECES', 'CRYPTO'
                   )),
    statut         VARCHAR(30) DEFAULT 'EN_ATTENTE'
                   CHECK (statut IN ('EN_ATTENTE', 'VALIDE', 'REFUSE', 'REMBOURSE')),
    reference_transaction VARCHAR(100)
);

-- ─────────────────────────────────────────────────
-- TABLE : avis
-- ─────────────────────────────────────────────────
CREATE TABLE avis (
    id_avis       SERIAL PRIMARY KEY,
    id_produit    INTEGER NOT NULL REFERENCES produits(id_produit),
    id_client     INTEGER NOT NULL REFERENCES clients(id_client),
    note          INTEGER NOT NULL CHECK (note BETWEEN 1 AND 5),
    titre         VARCHAR(200),
    commentaire   TEXT,
    date_avis     TIMESTAMP DEFAULT NOW(),
    verifie       BOOLEAN DEFAULT FALSE,
    UNIQUE (id_produit, id_client)  -- Un seul avis par client par produit
);

-- ─────────────────────────────────────────────────
-- TABLE : entrepots & stocks
-- ─────────────────────────────────────────────────
CREATE TABLE entrepots (
    id_entrepot   SERIAL PRIMARY KEY,
    nom           VARCHAR(100) NOT NULL,
    adresse       VARCHAR(200),
    ville         VARCHAR(100),
    pays          VARCHAR(100) DEFAULT 'France',
    capacite_max  INTEGER
);

CREATE TABLE stocks_entrepot (
    id_entrepot   INTEGER REFERENCES entrepots(id_entrepot),
    id_produit    INTEGER REFERENCES produits(id_produit),
    quantite      INTEGER DEFAULT 0 CHECK (quantite >= 0),
    emplacement   VARCHAR(50),
    PRIMARY KEY (id_entrepot, id_produit)
);

-- ─────────────────────────────────────────────────
-- INDEX PRINCIPAUX
-- ─────────────────────────────────────────────────
CREATE INDEX idx_produits_categorie ON produits(id_categorie);
CREATE INDEX idx_produits_fournisseur ON produits(id_fournisseur);
CREATE INDEX idx_commandes_client ON commandes(id_client);
CREATE INDEX idx_commandes_date ON commandes(date_commande);
CREATE INDEX idx_commandes_statut ON commandes(statut);
CREATE INDEX idx_lignes_commande_commande ON lignes_commande(id_commande);
CREATE INDEX idx_lignes_commande_produit ON lignes_commande(id_produit);
CREATE INDEX idx_clients_email ON clients(email);
CREATE INDEX idx_clients_segment ON clients(segment);
CREATE INDEX idx_avis_produit ON avis(id_produit);
CREATE INDEX idx_paiements_commande ON paiements(id_commande);

5.5 JEUX DE DONNÉES DE DÉMONSTRATION
──────────────────────────────────────

-- Insertion des catégories
INSERT INTO categories (nom, description, id_parent) VALUES
  ('Électronique', 'Appareils électroniques et high-tech', NULL),
  ('Informatique', 'Ordinateurs et accessoires', 1),
  ('Smartphones', 'Téléphones intelligents et tablettes', 1),
  ('Audio', 'Casques, enceintes et audio', 1),
  ('Vêtements', 'Mode homme, femme et enfant', NULL),
  ('Homme', 'Vêtements pour hommes', 5),
  ('Femme', 'Vêtements pour femmes', 5),
  ('Maison', 'Décoration et mobilier', NULL),
  ('Cuisine', 'Ustensiles et électroménager de cuisine', 8),
  ('Livres', 'Romans, BD, manuels', NULL);

-- Insertion des fournisseurs
INSERT INTO fournisseurs (raison_sociale, contact_nom, contact_email, pays) VALUES
  ('TechDistrib SARL', 'Marie Fontaine', 'marie@techdistrib.fr', 'France'),
  ('Samsung Distribution', 'Kim Lee', 'kim.lee@samsung-dist.com', 'Corée du Sud'),
  ('Apple Premium Reseller', 'John Smith', 'j.smith@apple-pr.com', 'USA'),
  ('ModaItalia SpA', 'Giuseppe Rossi', 'g.rossi@modaitalia.it', 'Italie'),
  ('ChinaElec Ltd', 'Wang Wei', 'wang@chinaelec.cn', 'Chine'),
  ('LibraisonPro', 'Élise Martin', 'elise@libraison.fr', 'France');

-- Insertion des produits
INSERT INTO produits (reference, nom, prix_ht, stock, stock_min, id_categorie, id_fournisseur) VALUES
  ('LAPTOP-001', 'MacBook Pro 14" M3', 1999.17, 50, 5, 2, 3),
  ('LAPTOP-002', 'Dell XPS 15 OLED', 1499.17, 35, 5, 2, 1),
  ('LAPTOP-003', 'Lenovo ThinkPad X1', 1249.17, 42, 5, 2, 5),
  ('PHONE-001', 'iPhone 15 Pro 256GB', 999.17, 120, 10, 3, 3),
  ('PHONE-002', 'Samsung Galaxy S24', 849.17, 95, 10, 3, 2),
  ('PHONE-003', 'Google Pixel 8 Pro', 799.17, 60, 10, 3, 1),
  ('AUDIO-001', 'Sony WH-1000XM5', 291.67, 80, 10, 4, 1),
  ('AUDIO-002', 'AirPods Pro 2', 207.50, 150, 20, 4, 3),
  ('AUDIO-003', 'Bose QuietComfort 45', 249.17, 45, 10, 4, 1),
  ('VESTE-001', 'Veste en cuir premium', 166.67, 30, 5, 6, 4),
  ('ROBE-001', 'Robe de soirée elegante', 124.17, 25, 5, 7, 4),
  ('LIVRE-001', 'Clean Code - R.C. Martin', 35.83, 200, 20, 10, 6),
  ('LIVRE-002', 'The Pragmatic Programmer', 37.50, 180, 20, 10, 6),
  ('CAFET-001', 'Machine à café Nespresso', 124.17, 40, 5, 9, 1),
  ('CAFET-002', 'Cafetière à piston Bodum', 33.33, 75, 10, 9, 5);

-- Insertion des clients
INSERT INTO clients (code_client, prenom, nom, email, telephone, date_naissance,
                     adresse_ville, adresse_cp, segment) VALUES
  ('CLI-0001', 'Alice', 'Dupont', 'alice.dupont@email.fr', '0612345678',
   '1990-05-15', 'Paris', '75001', 'VIP'),
  ('CLI-0002', 'Bob', 'Martin', 'bob.martin@email.fr', '0623456789',
   '1985-08-22', 'Lyon', '69001', 'PREMIUM'),
  ('CLI-0003', 'Claire', 'Bernard', 'claire.bernard@email.fr', '0634567890',
   '1995-03-10', 'Marseille', '13001', 'STANDARD'),
  ('CLI-0004', 'David', 'Petit', 'david.petit@email.fr', '0645678901',
   '1988-11-30', 'Bordeaux', '33000', 'STANDARD'),
  ('CLI-0005', 'Emma', 'Robert', 'emma.robert@email.fr', '0656789012',
   '2000-07-04', 'Toulouse', '31000', 'NOUVEAU'),
  ('CLI-0006', 'François', 'Leroy', 'francois.leroy@email.fr', '0667890123',
   '1975-12-25', 'Nice', '06000', 'VIP'),
  ('CLI-0007', 'Gabrielle', 'Moreau', 'gabrielle.moreau@email.fr', '0678901234',
   '1992-02-14', 'Nantes', '44000', 'PREMIUM'),
  ('CLI-0008', 'Hugo', 'Simon', 'hugo.simon@email.fr', '0689012345',
   '1998-09-01', 'Strasbourg', '67000', 'STANDARD'),
  ('CLI-0009', 'Isabelle', 'Laurent', 'isabelle.laurent@email.fr', '0690123456',
   '1983-04-18', 'Montpellier', '34000', 'PREMIUM'),
  ('CLI-0010', 'Julien', 'Thomas', 'julien.thomas@email.fr', '0601234567',
   '1991-06-30', 'Rennes', '35000', 'STANDARD');

-- Insertion des employés
INSERT INTO employes (prenom, nom, email, poste, departement, salaire, date_embauche) VALUES
  ('Sophie', 'Girard', 'sophie.girard@shopflow.fr', 'Directrice Commerciale', 'Ventes', 5500, '2019-03-01'),
  ('Pierre', 'Dubois', 'pierre.dubois@shopflow.fr', 'Commercial Senior', 'Ventes', 3800, '2020-06-15'),
  ('Marie', 'Lefebvre', 'marie.lefebvre@shopflow.fr', 'Commercial Junior', 'Ventes', 2800, '2022-01-10'),
  ('Antoine', 'Rousseau', 'antoine.rousseau@shopflow.fr', 'Responsable Logistique', 'Logistique', 4200, '2018-09-01');

-- Mise à jour des managers
UPDATE employes SET id_manager = 1 WHERE id_employe IN (2, 3);

-- Insertion des commandes
INSERT INTO commandes (numero_commande, id_client, id_employe, date_commande,
                       date_livraison, statut, montant_ttc) VALUES
  ('CMD-2024-001', 1, 2, '2024-01-15 10:30:00', '2024-01-20 14:00:00', 'LIVREE', 2398.99),
  ('CMD-2024-002', 2, 2, '2024-01-18 14:15:00', '2024-01-23 10:00:00', 'LIVREE', 1019.00),
  ('CMD-2024-003', 3, 3, '2024-02-01 09:00:00', NULL, 'EN_ATTENTE', 350.00),
  ('CMD-2024-004', 1, 2, '2024-02-10 16:45:00', '2024-02-15 11:00:00', 'LIVREE', 249.00),
  ('CMD-2024-005', 5, 3, '2024-02-20 11:20:00', NULL, 'EN_PREPARATION', 45.99),
  ('CMD-2024-006', 4, 2, '2024-03-01 08:30:00', '2024-03-06 15:00:00', 'LIVREE', 1799.00),
  ('CMD-2024-007', 6, 2, '2024-03-05 13:00:00', '2024-03-10 09:00:00', 'LIVREE', 1019.00),
  ('CMD-2024-008', 2, 3, '2024-03-12 10:00:00', NULL, 'EXPEDIEE', 299.00),
  ('CMD-2024-009', 7, 2, '2024-03-15 15:30:00', NULL, 'CONFIRMEE', 599.00),
  ('CMD-2024-010', 9, 2, '2024-03-20 09:15:00', NULL, 'EN_ATTENTE', 75.33);

-- Insertion des lignes de commande (sans colonne générée, on la calcule)
INSERT INTO lignes_commande (id_commande, id_produit, quantite, prix_unitaire_ht, taux_tva) VALUES
  (1, 1, 1, 1999.17, 20),   -- MacBook Pro pour commande 1
  (2, 4, 1, 999.17, 20),    -- iPhone 15 Pro pour commande 2
  (3, 7, 1, 291.67, 20),    -- Sony WH-1000XM5 pour commande 3
  (4, 9, 1, 249.17, 20),    -- Bose QC45 pour commande 4
  (5, 12, 1, 35.83, 5.5),   -- Clean Code pour commande 5 (TVA réduite livres)
  (6, 2, 1, 1499.17, 20),   -- Dell XPS pour commande 6
  (7, 4, 1, 999.17, 20),    -- iPhone 15 Pro pour commande 7
  (8, 8, 1, 207.50, 20),    -- AirPods Pro pour commande 8
  (9, 5, 1, 849.17, 20),    -- Samsung Galaxy pour commande 9
  (10, 13, 2, 37.50, 5.5);  -- 2x Pragmatic Programmer pour commande 10

-- Avis clients
INSERT INTO avis (id_produit, id_client, note, titre, commentaire, verifie) VALUES
  (1, 1, 5, 'Excellent MacBook !', 'Performances incroyables, batterie longue durée. Indispensable pour mon travail.', TRUE),
  (4, 2, 4, 'Très bon iPhone', 'Excellente qualité photo, mais prix élevé. Appareil Camera Pro très utile.', TRUE),
  (7, 3, 5, 'Meilleur casque du marché', 'Réduction de bruit parfaite, son exceptionnel. Indispensable en open space.', TRUE),
  (5, 7, 4, 'Bon téléphone Android', 'Interface fluide, bonnes performances. Batterie correcte.', FALSE),
  (12, 5, 5, 'Livre incontournable', 'Un must-read pour tout développeur. Très pédagogique.', TRUE);

-- ═══════════════════════════════════════
-- VÉRIFICATION : compter les enregistrements
-- ═══════════════════════════════════════
SELECT
  'categories'    AS table_name, COUNT(*) AS nb_lignes FROM categories
UNION ALL SELECT 'fournisseurs',    COUNT(*) FROM fournisseurs
UNION ALL SELECT 'produits',        COUNT(*) FROM produits
UNION ALL SELECT 'clients',         COUNT(*) FROM clients
UNION ALL SELECT 'employes',        COUNT(*) FROM employes
UNION ALL SELECT 'commandes',       COUNT(*) FROM commandes
UNION ALL SELECT 'lignes_commande', COUNT(*) FROM lignes_commande
UNION ALL SELECT 'avis',            COUNT(*) FROM avis;

-- Résultat attendu :
--   categories    | 10
--   fournisseurs  |  6
--   produits      | 15
--   clients       | 10
--   employes      |  4
--   commandes     | 10
--   lignes_cmde   | 10
--   avis          |  5

5.6 BONNES PRATIQUES D'ENVIRONNEMENT
──────────────────────────────────────

1. Toujours avoir un environnement de développement séparé de la production
   -> Dev : votre machine locale ou Docker
   -> Staging : copie de la prod avec données anonymisées
   -> Production : serveur dédié avec sauvegardes automatiques

2. Utiliser des migrations versionnées
   -> Outils : Flyway, Liquibase, Alembic (Python), ActiveRecord (Rails)
   -> Chaque modification de schéma = un fichier versionné
   -> Permet de reconstruire la BD de zéro à tout moment

3. Sauvegardes régulières
   -- Backup complet PostgreSQL
   pg_dump -U postgres shopflow > backup_shopflow_$(date +%Y%m%d).sql

   -- Backup compressé
   pg_dump -U postgres -Fc shopflow > backup_shopflow_$(date +%Y%m%d).dump

   -- Restauration
   pg_restore -U postgres -d shopflow backup_shopflow_20240315.dump

4. Ne jamais développer directement sur la production
   -> Utilisez des branches Git pour le code ET les migrations
   -> Testez sur dev -> staging -> prod

5.7 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Installez PostgreSQL et créez la base ShopFlow.
        Vérifiez que toutes les tables sont créées avec \dt

  Ex2 : Exécutez le script d'insertion et vérifiez les comptages
        avec la requête UNION ALL de vérification.

  Ex3 : Utilisez les méta-commandes psql pour :
        a) lister toutes les tables
        b) décrire la structure de la table clients
        c) lister tous les index

NIVEAU INTERMÉDIAIRE :
  Ex4 : Connectez pgAdmin à votre serveur local.
        Utilisez l'interface graphique pour :
        a) visualiser le schéma de la BD
        b) exécuter une requête SELECT * FROM clients

  Ex5 : Créez un utilisateur PostgreSQL "etudiant" avec un mot de passe.
        Donnez-lui uniquement le droit SELECT sur toutes les tables.
        Connectez-vous avec cet utilisateur et vérifiez qu'il peut
        lire mais pas modifier les données.

NIVEAU AVANCÉ :
  Ex6 : Faites un backup de la base shopflow.
        Supprimez la table clients.
        Restaurez depuis le backup.
        Vérifiez que clients est revenue.

5.8 CORRIGÉS
─────────────

CORRIGÉ Ex5 (créer un utilisateur limité) :

  -- En tant que superuser postgres
  CREATE USER etudiant WITH PASSWORD 'formation2024';

  -- Donner accès à la base
  GRANT CONNECT ON DATABASE shopflow TO etudiant;

  -- Donner accès au schéma public
  GRANT USAGE ON SCHEMA public TO etudiant;

  -- Donner SELECT sur toutes les tables existantes
  GRANT SELECT ON ALL TABLES IN SCHEMA public TO etudiant;

  -- Pour les futures tables créées
  ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO etudiant;

  -- Vérification : se connecter en tant qu'étudiant
  \c shopflow etudiant

  -- Ceci doit fonctionner :
  SELECT COUNT(*) FROM clients;

  -- Ceci doit échouer (permission denied) :
  INSERT INTO clients (code_client, prenom, nom, email)
  VALUES ('TEST', 'Test', 'Test', 'test@test.fr');

  -- Message attendu :
  -- ERROR: permission denied for table clients

CORRIGÉ Ex6 (backup et restauration) :

  -- Dans le terminal (pas dans psql)
  pg_dump -U postgres shopflow > /tmp/shopflow_backup.sql

  -- Dans psql, supprimer la table
  DROP TABLE clients CASCADE;
  -- CASCADE supprime aussi les tables dépendantes (commandes)

  -- Vérifier que clients a disparu
  \dt

  -- Restaurer depuis le backup
  psql -U postgres shopflow < /tmp/shopflow_backup.sql

  -- Vérifier que clients est revenue
  SELECT COUNT(*) FROM clients;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 1 : Ce qu'est une base de données, les propriétés ACID,
                  l'architecture interne d'un SGBD et le Buffer Pool.

  [OK] Chapitre 2 : Les familles de BD (relationnelles, NoSQL document,
                  clé-valeur, graphe, colonne) et quand choisir chaque type.

  [OK] Chapitre 3 : Les 5 sous-langages SQL (DDL, DML, DCL, TCL, DQL),
                  la structure d'une requête SELECT et l'ordre d'exécution.

  [OK] Chapitre 4 : PostgreSQL et MySQL en profondeur, leurs architectures
                  internes et comment choisir le bon SGBD.

  [OK] Chapitre 5 : Installation complète de PostgreSQL, création de la base
                  ShopFlow avec 10 tables relationnelles, insertion des
                  données de démonstration.

POINTS CLÉS À RETENIR :
  -> L'ordre d'ÉCRITURE SQL ≠ ordre d'EXÉCUTION (SQL exécute FROM avant SELECT)
  -> ACID est fondamental : garantit que vos données restent cohérentes
  -> PostgreSQL est le meilleur choix pour apprendre et travailler en production
  -> La base ShopFlow est votre terrain d'entraînement pour tout le guide
  -> Un index B-Tree réduit la complexité de O(N) à O(log N)

PROCHAINE PARTIE :
  Partie 2 — Modélisation des données : comprendre les Primary Keys,
  Foreign Keys, relations 1:1, 1:N, N:N et la normalisation des données.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 1 — FONDATIONS DES BASES DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS           ║
║                      PARTIE 2 — MODÉLISATION DES DONNÉES                         ║
║                              Chapitres 6 à 12                                    ║
╚══════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 6 — LES TABLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

6.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Qu'est-ce qu'une table ?
  Une table est la structure de base d'une base de données relationnelle.
  Elle représente une ENTITÉ du monde réel (un client, un produit, une commande).
  Techniquement, c'est un tableau bidimensionnel composé de lignes et de colonnes.

Pourquoi ça existe ?
  Pour organiser des données homogènes ensemble. Tout ce qui concerne
  les clients est dans la table clients, tout ce qui concerne les produits
  est dans la table produits.

6.2 ANATOMIE D'UNE TABLE
──────────────────────────

Vocabulaire fondamental :

  TABLE = RELATION (terme mathématique de l'algèbre relationnelle)
  LIGNE = TUPLE = ENREGISTREMENT = ROW (une instance de l'entité)
  COLONNE = ATTRIBUT = CHAMP = FIELD (une propriété de l'entité)
  VALEUR = CELLULE (intersection ligne × colonne)

  Table clients (exemple simplifié) :

  ┌────────────┬────────────┬──────────────────────────┬──────────────┐
  │ id_client  │    nom     │          email           │   segment    │
  ├────────────┼────────────┼──────────────────────────┼──────────────┤
  │     1      │ Alice D.   │ alice.dupont@email.fr    │ VIP          │
  │     2      │ Bob M.     │ bob.martin@email.fr      │ PREMIUM      │
  │     3      │ Claire B.  │ claire.bernard@email.fr  │ STANDARD     │
  └────────────┴────────────┴──────────────────────────┴──────────────┘
    ^ colonnes (attributs)
    <- -> lignes (tuples)

  Cardinalité : nombre de lignes dans une table
  Degré (ou arité) : nombre de colonnes dans une table

6.3 CRÉER UNE TABLE — CREATE TABLE
────────────────────────────────────

Syntaxe générale :

  CREATE TABLE nom_table (
      nom_colonne1  TYPE_DONNEE  [CONTRAINTES],
      nom_colonne2  TYPE_DONNEE  [CONTRAINTES],
      ...
      [CONTRAINTES DE TABLE]
  );

Exemple basique — table produits simplifiée :

  CREATE TABLE produits (
      -- Identifiant unique auto-incrémenté
      id_produit   SERIAL PRIMARY KEY,

      -- Référence unique (SKU)
      reference    VARCHAR(50) NOT NULL UNIQUE,

      -- Nom du produit (obligatoire)
      nom          VARCHAR(200) NOT NULL,

      -- Prix (positif obligatoire)
      prix_ht      DECIMAL(10,2) NOT NULL CHECK (prix_ht >= 0),

      -- Stock avec valeur par défaut
      stock        INTEGER DEFAULT 0,

      -- Catégorie (nullable = peut être NULL)
      id_categorie INTEGER REFERENCES categories(id_categorie),

      -- Timestamps automatiques
      date_creation  TIMESTAMP DEFAULT NOW(),
      date_maj       TIMESTAMP DEFAULT NOW()
  );

Explication ligne par ligne :

  id_produit SERIAL PRIMARY KEY
  -> SERIAL : auto-incrémente (1, 2, 3, 4...)
  -> PRIMARY KEY : clé primaire (unicité garantie, non-NULL, index automatique)

  reference VARCHAR(50) NOT NULL UNIQUE
  -> VARCHAR(50) : chaîne de max 50 caractères
  -> NOT NULL : valeur obligatoire (ne peut pas être vide)
  -> UNIQUE : chaque valeur doit être différente dans toute la table

  prix_ht DECIMAL(10,2) NOT NULL CHECK (prix_ht >= 0)
  -> DECIMAL(10,2) : nombre avec 10 chiffres dont 2 après la virgule
  -> CHECK : contrainte sur la valeur (validation des données)

  stock INTEGER DEFAULT 0
  -> DEFAULT 0 : si non spécifié, la valeur sera 0

  id_categorie INTEGER REFERENCES categories(id_categorie)
  -> REFERENCES : clé étrangère vers la table categories
  -> Garantit qu'on ne peut pas mettre une catégorie inexistante

6.4 MODIFIER UNE TABLE — ALTER TABLE
──────────────────────────────────────

-- Ajouter une colonne
ALTER TABLE produits ADD COLUMN poids_kg DECIMAL(8,3);

-- Ajouter une colonne avec valeur par défaut
ALTER TABLE produits ADD COLUMN actif BOOLEAN DEFAULT TRUE;

-- Renommer une colonne
ALTER TABLE produits RENAME COLUMN prix_ht TO prix_hors_taxe;

-- Modifier le type d'une colonne (attention : peut perdre des données !)
ALTER TABLE produits ALTER COLUMN description TYPE TEXT;

-- Ajouter une contrainte NOT NULL
ALTER TABLE produits ALTER COLUMN nom SET NOT NULL;

-- Supprimer NOT NULL
ALTER TABLE produits ALTER COLUMN telephone DROP NOT NULL;

-- Ajouter une contrainte CHECK
ALTER TABLE produits ADD CONSTRAINT chk_stock_positif
  CHECK (stock >= 0);

-- Supprimer une colonne (DESTRUCTIF - données perdues !)
ALTER TABLE produits DROP COLUMN poids_kg;

-- Renommer une table
ALTER TABLE produits RENAME TO catalogue_produits;

6.5 SUPPRIMER UNE TABLE — DROP TABLE
──────────────────────────────────────

-- Supprime la table et toutes ses données (IRRÉVERSIBLE !)
DROP TABLE produits;

-- Supprime uniquement si la table existe (évite l'erreur)
DROP TABLE IF EXISTS produits;

-- Supprime en cascade (supprime aussi les tables dépendantes)
DROP TABLE produits CASCADE;
-- [ATTENTION] DANGER : supprime aussi lignes_commande qui référence produits !

-- Vider une table sans la supprimer (plus rapide que DELETE)
TRUNCATE TABLE avis;
-- Avec remise à zéro des séquences SERIAL
TRUNCATE TABLE avis RESTART IDENTITY;

6.6 BONNES PRATIQUES DE NOMMAGE DES TABLES
────────────────────────────────────────────

[OK] À FAIRE :
  -> Noms en minuscules avec underscores : clients, lignes_commande
  -> Noms au pluriel pour les tables : clients (pas "client")
  -> Noms descriptifs : commandes_archivees (pas "ca")
  -> Préfixe pour regrouper : logs_connexion, logs_erreur

[X] À ÉVITER :
  -> Majuscules : Clients (problèmes de portabilité)
  -> Espaces : "ma table" (nécessite des guillemets partout)
  -> Accents ou caractères spéciaux : données, référence
  -> Mots réservés SQL : order, select, table, user (problèmes)
  -> Noms trop courts ou cryptiques : t1, x, tbl

Convention de ce guide :
  -> snake_case (mots séparés par underscores)
  -> Noms en français (pour la clarté pédagogique)
  -> En production anglophone : clients -> customers, commandes -> orders

6.7 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Créez une table "employes_formation" pour suivre les formations
        avec : id, id_employe, nom_formation, date_debut, date_fin, score.

  Ex2 : Ajoutez une colonne "budget" (DECIMAL 12,2) à la table "employes_formation".

  Ex3 : Quelle est la différence entre TRUNCATE et DELETE sans WHERE ?
        Dans quels cas utiliseriez-vous l'un ou l'autre ?

NIVEAU INTERMÉDIAIRE :
  Ex4 : Créez une table "promotions" pour ShopFlow avec :
        - id auto-incrémenté
        - code_promo unique (max 20 caractères)
        - description
        - remise_pct entre 0 et 90
        - date_debut et date_fin (date_fin > date_debut)
        - nombre_max_utilisations (positif)
        - actif booléen

  Ex5 : Quelle contrainte empêche d'insérer une promotion dont
        date_fin est antérieure à date_debut ?
        Écrivez la contrainte CHECK correspondante.

NIVEAU AVANCÉ :
  Ex6 : Créez une table "prix_historique" qui enregistre chaque
        changement de prix d'un produit (audit trail) :
        - Quelles colonnes inclure ?
        - Comment garantir qu'on sait QUI a modifié le prix ?
        - Comment garantir qu'une ligne d'historique est immuable ?

6.8 CORRIGÉS
─────────────

CORRIGÉ Ex4 (table promotions) :

  CREATE TABLE promotions (
      id_promo          SERIAL PRIMARY KEY,
      code_promo        VARCHAR(20) NOT NULL UNIQUE,
      description       TEXT,
      remise_pct        DECIMAL(5,2) NOT NULL
                        CHECK (remise_pct BETWEEN 0 AND 90),
      date_debut        DATE NOT NULL,
      date_fin          DATE NOT NULL,
      nombre_max_util   INTEGER CHECK (nombre_max_util > 0),
      nombre_util_actuel INTEGER DEFAULT 0,
      actif             BOOLEAN DEFAULT TRUE,
      date_creation     TIMESTAMP DEFAULT NOW(),
      CONSTRAINT chk_dates_promo CHECK (date_fin > date_debut)
  );

CORRIGÉ Ex3 (TRUNCATE vs DELETE) :

  TRUNCATE TABLE ma_table;
  -> Supprime TOUTES les lignes très rapidement (réalloue les pages)
  -> Remet les séquences SERIAL à zéro si RESTART IDENTITY
  -> Non filtrable (toujours toute la table)
  -> DDL (pas toujours annulable dans certains SGBD)

  DELETE FROM ma_table;
  -> Supprime ligne par ligne (log de chaque suppression)
  -> Peut avoir une clause WHERE pour supprimer partiellement
  -> DML -> transactionnel, annulable avec ROLLBACK
  -> Déclenche les triggers DELETE

  Utilisez TRUNCATE : table de logs ou staging à vider complètement
  Utilisez DELETE : suppression partielle avec conditions

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 7 — LES COLONNES ET CHAPITRE 8 — LES TYPES DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

7.1 LES CONTRAINTES DE COLONNE
────────────────────────────────

Une contrainte est une règle que la base de données fait respecter automatiquement.

Contraintes de niveau colonne (déclarées après le type) :

  NOT NULL
  -> La colonne ne peut pas avoir la valeur NULL (absence de valeur)
  -> Exemple : nom VARCHAR(100) NOT NULL

  NULL (défaut)
  -> La colonne peut être vide / inconnue
  -> Exemple : telephone VARCHAR(20) NULL (ou simplement VARCHAR(20))

  DEFAULT valeur
  -> Valeur utilisée si non spécifiée lors de l'INSERT
  -> Exemple : actif BOOLEAN DEFAULT TRUE

  UNIQUE
  -> Chaque valeur doit être unique dans la colonne (sauf NULL !)
  -> Note : plusieurs NULL sont autorisés même avec UNIQUE
  -> Exemple : email VARCHAR(200) UNIQUE

  PRIMARY KEY
  -> Raccourci pour NOT NULL + UNIQUE
  -> Identifie de manière unique chaque ligne de la table
  -> Une seule clé primaire par table (mais peut être composite)
  -> Exemple : id SERIAL PRIMARY KEY

  CHECK (expression)
  -> Valide la valeur avec une expression booléenne
  -> Exemple : note INTEGER CHECK (note BETWEEN 1 AND 5)

  REFERENCES table(colonne)
  -> Clé étrangère : référence une ligne dans une autre table
  -> Exemple : id_client INTEGER REFERENCES clients(id_client)

Contraintes de niveau table (déclarées après toutes les colonnes) :

  -- Clé primaire composite (sur plusieurs colonnes)
  CREATE TABLE stocks_entrepot (
      id_entrepot  INTEGER,
      id_produit   INTEGER,
      quantite     INTEGER DEFAULT 0,
      PRIMARY KEY (id_entrepot, id_produit)  -- <- contrainte de table
  );

  -- Unicité composite
  CREATE TABLE avis (
      id_avis    SERIAL PRIMARY KEY,
      id_produit INTEGER,
      id_client  INTEGER,
      note       INTEGER,
      UNIQUE (id_produit, id_client),  -- un seul avis par client/produit
      CHECK (note BETWEEN 1 AND 5)
  );

  -- Foreign key avec actions
  CREATE TABLE commandes (
      id_commande SERIAL PRIMARY KEY,
      id_client   INTEGER,
      FOREIGN KEY (id_client) REFERENCES clients(id_client)
          ON DELETE RESTRICT    -- interdit supprimer un client avec commandes
          ON UPDATE CASCADE     -- si id_client change, propage le changement
  );

Actions ON DELETE / ON UPDATE :

  RESTRICT (défaut) :
  -> Interdit la suppression/modification si des lignes dépendantes existent
  -> Exemple : impossible de supprimer client 1 s'il a des commandes

  CASCADE :
  -> Propage l'opération aux lignes dépendantes
  -> ON DELETE CASCADE : supprimer client supprime aussi ses commandes (dangereux !)
  -> ON UPDATE CASCADE : changer id_client propage le changement dans commandes

  SET NULL :
  -> Met NULL dans la clé étrangère quand la référence est supprimée
  -> Exemple : supprimer employé -> id_employe dans commandes devient NULL

  SET DEFAULT :
  -> Met la valeur DEFAULT dans la clé étrangère
  -> Exemple : supprimer catégorie -> produit va dans catégorie "Divers"

  NO ACTION :
  -> Identique à RESTRICT mais vérifié en fin de transaction (pas immédiatement)
  -> Permet des suppressions en cascade manuelle dans une transaction

8.1 LES TYPES DE DONNÉES EN DÉTAIL
────────────────────────────────────

Choisir le bon type est crucial pour :
  -> La performance (moins d'espace = plus de données en cache)
  -> La validation automatique (le SGBD rejette les valeurs invalides)
  -> La sémantique (un DATE est bien plus qu'un VARCHAR)

TYPES NUMÉRIQUES ENTIERS
─────────────────────────

  SMALLINT (ou INT2)
  -> Plage : -32 768 à +32 767
  -> Taille : 2 octets
  -> Cas d'usage : note (1-5), âge, mois, quantité petite
  -> Exemple : note SMALLINT CHECK (note BETWEEN 1 AND 5)

  INTEGER (ou INT ou INT4)  <- le plus courant
  -> Plage : -2 147 483 648 à +2 147 483 647 (environ ±2 milliards)
  -> Taille : 4 octets
  -> Cas d'usage : identifiants, quantités, compteurs
  -> Exemple : id_client INTEGER, stock INTEGER

  BIGINT (ou INT8)
  -> Plage : ±9 223 372 036 854 775 807 (environ ±9 quintillions)
  -> Taille : 8 octets
  -> Cas d'usage : très grands identifiants, compteurs de clics, IoT
  -> Exemple : nb_vues BIGINT (un YouTube avec des milliards de vues)

  SERIAL, BIGSERIAL  (pseudo-types PostgreSQL)
  -> SERIAL = INTEGER + séquence auto-incrémentée (commence à 1)
  -> BIGSERIAL = BIGINT + séquence auto-incrémentée
  -> Utilisés pour les clés primaires
  -> Exemple :
    id SERIAL PRIMARY KEY
    -- équivalent à :
    id INTEGER NOT NULL DEFAULT nextval('table_id_seq') PRIMARY KEY

  GENERATED ALWAYS AS IDENTITY  (SQL standard, PostgreSQL 10+)
  -> Version standardisée de SERIAL
  -> Exemple :
    id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY
    -- ou avec paramètres :
    id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY

TYPES NUMÉRIQUES DÉCIMAUX
──────────────────────────

  DECIMAL(precision, scale) ou NUMERIC(precision, scale)
  -> precision : nombre total de chiffres significatifs
  -> scale : nombre de chiffres après la virgule
  -> Stockage exact (pas d'erreur d'arrondi) <- CRUCIAL pour finances !
  -> Taille : variable selon la valeur

  Exemples :
    DECIMAL(10, 2)  -> jusqu'à 99 999 999.99 (prix, salaires)
    DECIMAL(5, 2)   -> jusqu'à 999.99 (pourcentages comme 100.00)
    DECIMAL(15, 4)  -> très grande précision (taux de change, crypto)

  Règle d'or : TOUJOURS utiliser DECIMAL pour l'argent !
  Jamais FLOAT ou REAL pour les montants financiers !

  REAL (ou FLOAT4)
  -> Précision simple (environ 6 chiffres significatifs)
  -> Taille : 4 octets
  -> Erreurs d'arrondi inévitables !
  -> Cas d'usage : coordonnées GPS approximatives, scores de machine learning

  DOUBLE PRECISION (ou FLOAT8)
  -> Précision double (environ 15 chiffres significatifs)
  -> Taille : 8 octets
  -> Encore des erreurs d'arrondi, juste moins importantes
  -> Cas d'usage : calculs scientifiques, statistiques

  [ATTENTION] PIÈGE CLASSIQUE avec FLOAT :
    SELECT 0.1 + 0.2;
    -- Résultat : 0.30000000000000004  <- erreur d'arrondi !
    -- En DECIMAL : 0.1::DECIMAL + 0.2::DECIMAL = 0.3 <- correct

TYPES CHAÎNES DE CARACTÈRES
─────────────────────────────

  CHAR(n) ou CHARACTER(n)
  -> Longueur fixe : TOUJOURS n caractères (complété par des espaces)
  -> Taille : n octets (toujours)
  -> Cas d'usage : codes normalisés (code ISO pays 'FR', 'US')
  -> Exemple : code_pays CHAR(2) DEFAULT 'FR'

  VARCHAR(n) ou CHARACTER VARYING(n)  <- le plus courant
  -> Longueur variable : jusqu'à n caractères
  -> Taille : longueur réelle + 1-4 octets de métadonnée
  -> Cas d'usage : noms, emails, titres, descriptions courtes
  -> Exemple : email VARCHAR(200) NOT NULL

  TEXT  (PostgreSQL, MySQL)
  -> Longueur illimitée (jusqu'à 1 GB en PostgreSQL !)
  -> Même performance que VARCHAR en PostgreSQL
  -> Cas d'usage : descriptions longues, articles, commentaires, JSON
  -> Exemple : description TEXT, commentaire TEXT

  Note PostgreSQL importante :
  VARCHAR sans (n) = TEXT (longueur illimitée)
  En PostgreSQL, TEXT est légèrement préférable à VARCHAR pour les longs textes.

  Comparaison VARCHAR vs TEXT en PostgreSQL :
  -> Performance : identique (même stockage interne TOAST)
  -> VARCHAR(n) : validation automatique de longueur max
  -> TEXT : aucune limite
  -> Recommandation : TEXT pour description/commentaire, VARCHAR(n) pour email/nom

TYPES DATE ET HEURE
─────────────────────

  DATE
  -> Stocke uniquement la date : '2024-03-15'
  -> Format ISO 8601 : YYYY-MM-DD
  -> Taille : 4 octets
  -> Cas d'usage : date de naissance, date de livraison, date de facture
  -> Exemple : date_naissance DATE, date_livraison DATE

  TIME [WITHOUT TIME ZONE]
  -> Heure sans date : '14:30:00'
  -> Taille : 8 octets
  -> Cas d'usage : horaires d'ouverture, créneaux
  -> Exemple : heure_ouverture TIME DEFAULT '09:00:00'

  TIMESTAMP [WITHOUT TIME ZONE]
  -> Date + heure : '2024-03-15 14:30:00.123456'
  -> Taille : 8 octets
  -> Cas d'usage : date de création, date de modification, événements
  -> Exemple : date_creation TIMESTAMP DEFAULT NOW()

  TIMESTAMPTZ ou TIMESTAMP WITH TIME ZONE  <- recommandé !
  -> Date + heure + fuseau horaire
  -> Convertit et stocke en UTC, affiche dans le fuseau local
  -> Cas d'usage : tout système international, logs
  -> Exemple : date_commande TIMESTAMPTZ DEFAULT NOW()

  [ATTENTION] BONNE PRATIQUE :
    -> Utilisez ALWAYS TIMESTAMPTZ pour les timestamps de production !
    -> Convertit automatiquement selon le fuseau du serveur/client
    -> Sans TZ : '2024-03-15 14:30:00' est ambigu (Paris? New York? Tokyo?)

  INTERVAL
  -> Durée (pas un instant) : '2 years 3 months 1 day 4 hours'
  -> Cas d'usage : délais, durées, TTL
  -> Exemple :
    SELECT date_commande + INTERVAL '3 days' AS date_livraison_prevue
    FROM commandes;

  Fonctions de date essentielles :

    NOW()          -> timestamp courant avec fuseau
    CURRENT_DATE   -> date d'aujourd'hui
    CURRENT_TIME   -> heure actuelle
    CURRENT_TIMESTAMP -> équivalent à NOW()

    EXTRACT(YEAR FROM date_commande)   -> extraire l'année
    EXTRACT(MONTH FROM date_commande)  -> extraire le mois
    EXTRACT(DOW FROM date_commande)    -> jour de la semaine (0=dimanche)

    DATE_TRUNC('month', date_commande) -> tronquer au début du mois
    AGE(date_naissance)                -> calculer l'âge
    date1 - date2                      -> différence en jours

TYPES BOOLÉENS
───────────────

  BOOLEAN (ou BOOL)
  -> Valeurs : TRUE, FALSE, NULL
  -> Taille : 1 octet
  -> Alias : TRUE/FALSE, 't'/'f', 'yes'/'no', 'on'/'off', 1/0

  CREATE TABLE produits (
      actif     BOOLEAN DEFAULT TRUE,
      en_stock  BOOLEAN GENERATED ALWAYS AS (stock > 0) STORED
  );

  [ATTENTION] LOGIQUE TERNAIRE : NULL est différent de FALSE !
    WHERE actif = TRUE    -> seulement les actifs
    WHERE actif = FALSE   -> seulement les inactifs
    WHERE actif IS NULL   -> seulement les non-définis
    WHERE NOT actif       -> FALSE et NULL (attention !)

TYPES UUID
───────────

  UUID (Universally Unique Identifier)
  -> 128 bits : '550e8400-e29b-41d4-a716-446655440000'
  -> Taille : 16 octets
  -> Probabilité de collision : quasi nulle (1/2^122)

  Cas d'usage :
  -> Systèmes distribués (plusieurs serveurs génèrent des IDs sans collision)
  -> Exposer des IDs dans des URLs (plus sûr que "id=1, id=2")
  -> APIs REST (/api/users/550e8400-e29b-41d4-a716-446655440000)

  CREATE EXTENSION IF NOT EXISTS "uuid-ossp";  -- activer l'extension

  CREATE TABLE clients (
      id_client UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
      -- ou avec PostgreSQL 13+
      id_client UUID DEFAULT gen_random_uuid() PRIMARY KEY,
      nom VARCHAR(100) NOT NULL
  );

TYPES JSON
───────────

  JSON
  -> Stocke du texte JSON validé
  -> Taille : texte brut

  JSONB (JSON Binary)  <- recommandé pour PostgreSQL
  -> Stocke du JSON en binaire (décompressé)
  -> Plus rapide pour les requêtes
  -> Supporte les index GIN pour chercher dans le JSON

  CREATE TABLE produits_specs (
      id_produit  INTEGER PRIMARY KEY,
      specs       JSONB
  );

  -- Insérer
  INSERT INTO produits_specs VALUES (1, '{
    "couleur": "noir",
    "poids_kg": 1.4,
    "dimensions": {"L": 31, "l": 22, "h": 1.6},
    "certifications": ["CE", "FCC", "RoHS"]
  }');

  -- Requêtes JSON
  SELECT specs->>'couleur' AS couleur               -- valeur texte
  FROM produits_specs;

  SELECT specs->'dimensions'->>'L' AS longueur       -- JSON imbriqué
  FROM produits_specs;

  SELECT * FROM produits_specs
  WHERE specs @> '{"couleur": "noir"}';              -- contient ce JSON

TYPES TABLEAU (ARRAY)
──────────────────────

  PostgreSQL supporte les tableaux natifs :

  CREATE TABLE articles (
      id         SERIAL PRIMARY KEY,
      titre      VARCHAR(200),
      tags       TEXT[],        -- tableau de textes
      scores     INTEGER[]      -- tableau d'entiers
  );

  INSERT INTO articles (titre, tags) VALUES
  ('Guide SQL', ARRAY['sql', 'database', 'tutorial']),
  ('Python ML', '{"python", "machine-learning", "ai"}');

  -- Chercher dans un tableau
  SELECT * FROM articles WHERE 'sql' = ANY(tags);
  SELECT * FROM articles WHERE tags @> ARRAY['sql'];

8.2 CHOISIR LE BON TYPE — GUIDE PRATIQUE
──────────────────────────────────────────

  ┌──────────────────────────────────┬─────────────────────────────────┐
  │ Cas d'usage                      │ Type recommandé                 │
  ├──────────────────────────────────┼─────────────────────────────────┤
  │ Clé primaire petite table        │ SERIAL (INTEGER auto-incr)      │
  │ Clé primaire grande table        │ BIGSERIAL                       │
  │ Clé primaire distribuée          │ UUID                            │
  │ Prix, montants financiers        │ DECIMAL(12,2)                   │
  │ Pourcentage (0-100)              │ DECIMAL(5,2)                    │
  │ Note (1-5)                       │ SMALLINT                        │
  │ Quantité, compteur               │ INTEGER                         │
  │ Très grand compteur              │ BIGINT                          │
  │ Coordonnées GPS                  │ DECIMAL(10,7) ou POINT          │
  │ Nom, prénom, ville               │ VARCHAR(100)                    │
  │ Email                            │ VARCHAR(254) (standard RFC)     │
  │ URL                              │ VARCHAR(2000) ou TEXT           │
  │ Description, commentaire         │ TEXT                            │
  │ Code ISO pays                    │ CHAR(2)                         │
  │ Code postal France               │ VARCHAR(5)                      │
  │ Date (sans heure)                │ DATE                            │
  │ Timestamp (local ou interne)     │ TIMESTAMP                       │
  │ Timestamp (international)        │ TIMESTAMPTZ                     │
  │ Durée                            │ INTERVAL                        │
  │ Actif/inactif, oui/non           │ BOOLEAN                         │
  │ Attributs variables (catalogue)  │ JSONB                           │
  │ Tags, liste de valeurs           │ TEXT[]                          │
  └──────────────────────────────────┴─────────────────────────────────┘

8.3 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Pour chaque colonne, choisissez le meilleur type :
        a) Numéro de sécurité sociale (15 chiffres, format fixe)
        b) Prix d'une crypto-monnaie (beaucoup de décimales)
        c) Texte d'un article de blog (plusieurs milliers de mots)
        d) Nombre de likes sur un post viral (peut dépasser 1 milliard)
        e) Heure d'ouverture d'un magasin (ex: 09:00)

  Ex2 : Pourquoi DECIMAL est-il obligatoire pour les montants financiers
        et non FLOAT ? Démontrez avec un exemple SQL.

NIVEAU INTERMÉDIAIRE :
  Ex3 : Créez une table "articles_blog" avec des colonnes de types variés :
        UUID, TIMESTAMPTZ, JSONB, TEXT[], SMALLINT, TEXT
        Justifiez chaque choix de type.

NIVEAU AVANCÉ :
  Ex4 : Comparez CHAR(10) vs VARCHAR(10) vs TEXT en termes de :
        a) Stockage disque
        b) Performance des comparaisons
        c) Quand utiliser chacun ?

8.4 CORRIGÉS
─────────────

CORRIGÉ Ex1 :
  a) Numéro SS -> CHAR(15) (longueur TOUJOURS 15, format fixe)
  b) Prix crypto -> DECIMAL(30,18) (18 décimales pour des crypto comme ETH)
  c) Article blog -> TEXT (illimité, contenu variable)
  d) Likes viraux -> BIGINT (peut dépasser 2 milliards -> dépasse INTEGER)
  e) Heure d'ouverture -> TIME (ou VARCHAR(5) '09:00' si plus simple)

CORRIGÉ Ex2 (FLOAT vs DECIMAL) :

  -- Démonstration du problème FLOAT
  SELECT
    0.1::FLOAT + 0.2::FLOAT AS resultat_float,
    0.1::DECIMAL + 0.2::DECIMAL AS resultat_decimal;

  -- Résultat :
  -- resultat_float   | 0.30000000000000004  <- ERREUR !
  -- resultat_decimal | 0.3                  <- CORRECT

  -- Impact en finance :
  -- Calculer la TVA sur 100 articles à 29.99€ chacun :
  SELECT
    100 * 29.99::FLOAT * 0.20 AS tva_float,
    100 * 29.99::DECIMAL * 0.20 AS tva_decimal;
  -- tva_float   : peut donner 599.8000000000001 (erreur d'arrondi !)
  -- tva_decimal : 599.80 (correct)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 9 — PRIMARY KEY (CLÉ PRIMAIRE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

9.1 INTRODUCTION PÉDAGOGIQUE
─────────────────────────────

Qu'est-ce qu'une clé primaire ?
  Une clé primaire (PRIMARY KEY) est la colonne (ou combinaison de colonnes)
  qui identifie de manière UNIQUE et NON-NULLE chaque ligne d'une table.

Pourquoi ça existe ?
  -> Chaque ligne doit être identifiable de façon non ambiguë
  -> C'est le "numéro de passeport" de chaque enregistrement
  -> Permet de créer des liens (relations) entre les tables

Analogie du monde réel :
  -> Chaque personne a un numéro de sécurité sociale unique
  -> Chaque voiture a un numéro de châssis unique
  -> Chaque colis a un numéro de suivi unique

9.2 PROPRIÉTÉS D'UNE CLÉ PRIMAIRE
────────────────────────────────────

Trois propriétés OBLIGATOIRES :

  1. UNICITÉ : chaque valeur doit être unique dans la table
     -> Deux clients ne peuvent pas avoir le même id_client

  2. NON-NULLITÉ : la valeur ne peut jamais être NULL
     -> Un enregistrement sans identifiant n'a pas de sens

  3. STABILITÉ : la valeur ne devrait jamais changer
     -> L'email change -> mauvaise clé primaire
     -> Le numéro SS change (erreur) -> problème en cascade

9.3 TYPES DE CLÉS PRIMAIRES
─────────────────────────────

TYPE 1 : CLÉ SYNTHÉTIQUE (SURROGATE KEY) <- recommandée
  -> Identifiant artificiel généré automatiquement
  -> N'a pas de sens métier

  -- INTEGER auto-incrémenté (SERIAL)
  CREATE TABLE clients (
      id_client SERIAL PRIMARY KEY,  -- 1, 2, 3, 4, ...
      ...
  );

  -- UUID aléatoire
  CREATE TABLE clients (
      id_client UUID DEFAULT gen_random_uuid() PRIMARY KEY,
      ...
  );

  Avantages :
  [OK] Toujours unique (géré par le SGBD)
  [OK] Compact (4 ou 8 octets pour INTEGER)
  [OK] Stable (jamais besoin de modifier)
  [OK] Jointures efficaces

  Inconvénients :
  [X] Pas de sens métier (1234 ne dit rien sur le client)

TYPE 2 : CLÉ NATURELLE (NATURAL KEY)
  -> Attribut naturel du monde réel
  -> A un sens métier

  -- Email comme clé primaire
  CREATE TABLE clients (
      email VARCHAR(200) PRIMARY KEY,  -- alice@example.com
      ...
  );

  -- Numéro ISBN pour les livres
  CREATE TABLE livres (
      isbn VARCHAR(13) PRIMARY KEY,
      ...
  );

  Avantages :
  [OK] Sens métier immédiat
  [OK] Pas besoin de colonne supplémentaire

  Inconvénients :
  [X] Peut changer (email change -> mise à jour en cascade !)
  [X] Peut être long (URL, email = coûteux en jointure)
  [X] Parfois non unique en pratique (exception humaine)

TYPE 3 : CLÉ PRIMAIRE COMPOSITE
  -> Combinaison de plusieurs colonnes

  CREATE TABLE stocks_entrepot (
      id_entrepot  INTEGER,
      id_produit   INTEGER,
      quantite     INTEGER,
      PRIMARY KEY (id_entrepot, id_produit)  -- combo unique
  );

  -- Cela autorise :
  -- (1, 101, 50) <- entrepôt 1, produit 101 : 50 unités
  -- (1, 102, 30) <- entrepôt 1, produit 102 : 30 unités
  -- (2, 101, 20) <- entrepôt 2, produit 101 : 20 unités
  -- INTERDIT :
  -- (1, 101, 15) <- doublon ! même entrepôt ET même produit

9.4 FONCTIONNEMENT INTERNE
───────────────────────────

Quand vous définissez PRIMARY KEY, le SGBD crée automatiquement :

  1. Une contrainte d'unicité (comme UNIQUE)
  2. Une contrainte NOT NULL
  3. Un INDEX B-Tree unique sur la/les colonne(s)

Visualisation de l'index B-Tree :

  Table clients (sans index) :
  Pages disque -> scan séquentiel pour trouver id_client = 5 :
  [id=1][id=2][id=3][id=4][id=5]<- lu ici

  Avec index B-Tree sur id_client :
               [ 5 ]           <- racine
              /     \
          [ 2 ]     [ 8 ]
         /    \    /    \
       [1]   [3] [6]   [9,10]
  -> Trouver id=5 : 5 > 2 -> droite, 5 < 8 -> gauche -> trouvé en 2 étapes !

9.5 BONNES PRATIQUES CLÉS PRIMAIRES
─────────────────────────────────────

[OK] RECOMMANDATION 1 : Utiliser des entiers auto-incrémentés pour la plupart des tables

  -- Meilleure pratique PostgreSQL moderne
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
  -- ou pour des tables de taille raisonnable :
  id SERIAL PRIMARY KEY

[OK] RECOMMANDATION 2 : Utiliser UUID pour les systèmes distribués ou les APIs publiques

  -- Avantage sécurité : l'utilisateur ne peut pas deviner les IDs
  -- /api/users/1 -> facile à deviner
  -- /api/users/f47ac10b-58cc-4372-a567-0e02b2c3d479 -> impossible à deviner

[OK] RECOMMANDATION 3 : JAMAIS l'email comme clé primaire

  Raison 1 : les emails changent (si l'email est clé primaire,
             changer l'email implique de mettre à jour TOUTES les tables
             qui y font référence)
  Raison 2 : les VARCHAR longs sont coûteux en jointure (vs INTEGER)
  Raison 3 : les emails peuvent ne pas être uniques en pratique
             (famille partageant un email, erreur de saisie)

  Solution : avoir un id_client (INTEGER PRIMARY KEY) ET une contrainte UNIQUE sur email
  CREATE TABLE clients (
      id_client  SERIAL PRIMARY KEY,     -- identifiant interne
      email      VARCHAR(200) UNIQUE     -- unique mais pas PK
  );

9.6 EXERCICES PRATIQUES
────────────────────────

NIVEAU FACILE :
  Ex1 : Pour chaque table, proposez la meilleure clé primaire et justifiez :
        a) Table "pays" avec colonnes : code_iso, nom, population
        b) Table "employes" avec : numéro_ss, nom, prenom, email, telephone
        c) Table "sessions_web" (des millions par jour) : token, id_user, expiration

NIVEAU INTERMÉDIAIRE :
  Ex2 : Quelle est la différence entre PRIMARY KEY et UNIQUE + NOT NULL ?
        Quand utiliser l'un vs l'autre ?

  Ex3 : Créez une table "inscrits_formation" avec une clé primaire composite
        qui garantit qu'un employé ne peut s'inscrire qu'une seule fois
        à une formation donnée.

NIVEAU AVANCÉ :
  Ex4 : Débat : BIGSERIAL vs UUID pour une table de commandes à très fort
        volume. Analysez les compromis en termes de :
        - Performance des jointures
        - Sécurité des APIs
        - Scalabilité multi-serveurs
        - Taille de stockage des index

9.7 CORRIGÉS
─────────────

CORRIGÉ Ex1 :
  a) Table pays -> code_iso CHAR(2) PRIMARY KEY ('FR', 'US', 'DE'...)
     Justification : le code ISO 3166-1 alpha-2 est international,
     standardisé, stable, court (2 chars), et réellement unique.
     C'est un cas rare de bonne clé naturelle.

  b) Table employes -> id_employe SERIAL PRIMARY KEY
     ET numéro_ss avec contrainte UNIQUE séparée.
     Justification : le numéro SS peut avoir des corrections (erreur admin).
     L'email change. L'id synthétique est plus sûr.

  c) Table sessions_web -> token VARCHAR(64) PRIMARY KEY
     (le token JWT ou hash est déjà unique par nature) OU
     id BIGSERIAL PRIMARY KEY avec UNIQUE sur token.

CORRIGÉ Ex4 (BIGSERIAL vs UUID) :

  BIGSERIAL (8 octets) :
  [OK] Jointures ultra-rapides (INT comparaison)
  [OK] Index compact (moins de pages = plus en cache)
  [OK] Tri naturel par date d'insertion
  [X] IDs prévisibles (sécurité)
  [X] Générés centralement (multi-serveur = collision)
  [X] "Hotspot" d'insertion (toujours à la fin de l'arbre B)

  UUID v4 (16 octets, aléatoire) :
  [OK] Aucun risque de collision multi-serveur
  [OK] Opaque (sécurité API)
  [OK] Peut être généré côté client
  [X] 2x plus grand -> index 2x plus grand -> moins en cache
  [X] Aléatoire -> fragmentation de l'index B-Tree (page splits)
  [X] Jointures légèrement plus lentes

  UUID v7 (16 octets, ordonné dans le temps) :
  -> Le meilleur des deux mondes !
  [OK] Opaque et unique (comme v4)
  [OK] Trié chronologiquement -> pas de fragmentation
  [OK] Multi-serveur sans collision
  -> Disponible dans PostgreSQL 17 via gen_random_uuid() v7

  Recommandation professionnelle :
  -> Petites/moyennes tables : BIGSERIAL (simple et efficace)
  -> APIs publiques : UUID (sécurité)
  -> Systèmes distribués modernes : UUID v7

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 10 — FOREIGN KEY (CLÉ ÉTRANGÈRE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

10.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce qu'une clé étrangère ?
  Une clé étrangère (FOREIGN KEY, FK) est une colonne (ou groupe de colonnes)
  dans une table qui RÉFÉRENCE la clé primaire d'une autre table.
  Elle crée un LIEN entre deux tables et garantit l'INTÉGRITÉ RÉFÉRENTIELLE.

Pourquoi ça existe ?
  Sans clé étrangère, rien n'empêche d'avoir une commande
  pour un client qui n'existe pas (id_client = 99999 alors
  qu'il n'y a que 10 clients). Cela crée des données orphelines.

Analogie :
  -> La commande est une lettre
  -> Le champ "destinataire" est la FK
  -> Les clients sont l'annuaire
  -> La FK garantit que le destinataire existe dans l'annuaire

10.2 DÉFINIR UNE CLÉ ÉTRANGÈRE
─────────────────────────────────

Syntaxe inline (colonne unique) :
  CREATE TABLE commandes (
      id_commande  SERIAL PRIMARY KEY,
      id_client    INTEGER NOT NULL
                   REFERENCES clients(id_client)  -- FK!
                   ON DELETE RESTRICT
                   ON UPDATE CASCADE,
      date_commande TIMESTAMP DEFAULT NOW()
  );

Syntaxe séparée (niveau table, recommandée) :
  CREATE TABLE commandes (
      id_commande  SERIAL PRIMARY KEY,
      id_client    INTEGER NOT NULL,
      date_commande TIMESTAMP DEFAULT NOW(),
      -- Contraintes en bas
      CONSTRAINT fk_commande_client
          FOREIGN KEY (id_client)
          REFERENCES clients(id_client)
          ON DELETE RESTRICT
          ON UPDATE CASCADE
  );

Nommage recommandé : fk_[table_source]_[table_cible]
  -> fk_commande_client -> FK dans commandes vers clients
  -> fk_ligne_commande  -> FK dans lignes_commande vers commandes
  -> fk_produit_categorie -> FK dans produits vers categories

10.3 INTÉGRITÉ RÉFÉRENTIELLE EN DÉTAIL
─────────────────────────────────────────

L'intégrité référentielle garantit que les références sont toujours valides.

Que se passe-t-il sans FK ?

  -- Sans FK :
  INSERT INTO commandes (id_client, ...) VALUES (99999, ...);
  -- Insère une commande pour un client inexistant !
  -- La BD est maintenant dans un état incohérent.

  -- Avec FK :
  INSERT INTO commandes (id_client, ...) VALUES (99999, ...);
  -- ERREUR : Key (id_client)=(99999) is not present in table "clients".
  -- La BD refuse et reste cohérente.

Les actions ON DELETE / ON UPDATE en détail :

  RESTRICT (comportement par défaut recommandé) :
  ─────────────────────────────────────────────
  -> Interdit toute opération qui violerait la contrainte
  -> Vérifie IMMÉDIATEMENT lors de l'opération

  -- Exemple : supprimer client 1 qui a des commandes
  DELETE FROM clients WHERE id_client = 1;
  -- ERREUR: update or delete on table "clients" violates
  -- foreign key constraint "fk_commande_client" on table "commandes"
  -- DETAIL: Key (id_client)=(1) is still referenced from table "commandes".

  CASCADE :
  ─────────
  -> Propage l'opération aux lignes référençantes

  -- Si commandes a ON DELETE CASCADE vers clients :
  DELETE FROM clients WHERE id_client = 1;
  -- Supprime client 1 ET toutes ses commandes automatiquement !
  -- Dangereux ! Utilisez seulement pour des tables de détail/log.

  SET NULL :
  ──────────
  -> Met NULL dans la FK quand la référence est supprimée

  -- Table commandes avec id_employe SET NULL :
  DELETE FROM employes WHERE id_employe = 2;
  -- Les commandes traitées par l'employé 2 auront id_employe = NULL
  -- (l'employé est parti mais les commandes restent)

  SET DEFAULT :
  ─────────────
  -> Remet la valeur par défaut de la FK

  -- Table produits avec id_categorie DEFAULT 1 (catégorie "Divers") :
  DELETE FROM categories WHERE id_categorie = 5;
  -- Tous les produits en catégorie 5 passent en catégorie 1

  NO ACTION :
  ───────────
  -> Comme RESTRICT mais vérifié en fin de transaction
  -> Permet des mises à jour intermédiaires dans la même transaction

10.4 FK SUR PLUSIEURS COLONNES
─────────────────────────────────

  CREATE TABLE stocks_details (
      id_entrepot   INTEGER,
      id_produit    INTEGER,
      date_mouvement DATE,
      quantite_entree INTEGER DEFAULT 0,
      -- FK composite vers stocks_entrepot
      FOREIGN KEY (id_entrepot, id_produit)
          REFERENCES stocks_entrepot(id_entrepot, id_produit)
  );

10.5 AUTO-RÉFÉRENCE (SELF-REFERENCING FK)
──────────────────────────────────────────

  Une table peut se référencer elle-même :

  -- Table employes : un employé a un manager (qui est aussi un employé)
  CREATE TABLE employes (
      id_employe  SERIAL PRIMARY KEY,
      nom         VARCHAR(100),
      id_manager  INTEGER REFERENCES employes(id_employe)
      -- NULL = pas de manager (PDG)
  );

  -- Données :
  -- id=1, nom='Alice', id_manager=NULL  -> PDG
  -- id=2, nom='Bob', id_manager=1       -> Bob reporte à Alice
  -- id=3, nom='Claire', id_manager=1    -> Claire reporte à Alice
  -- id=4, nom='David', id_manager=2     -> David reporte à Bob

  -- Table categories : sous-catégories
  CREATE TABLE categories (
      id_categorie  SERIAL PRIMARY KEY,
      nom           VARCHAR(100),
      id_parent     INTEGER REFERENCES categories(id_categorie)
      -- NULL = catégorie racine
  );

10.6 DÉSACTIVER TEMPORAIREMENT LES FK
───────────────────────────────────────

  Utile lors de migrations ou imports en masse :

  -- PostgreSQL : désactiver les triggers (dont FK) pour la session
  SET session_replication_role = 'replica';

  -- Vos imports...
  INSERT INTO commandes ...
  INSERT INTO clients ...

  -- Réactiver
  SET session_replication_role = 'origin';

  -- Vérifier la cohérence après
  -- (toujours vérifier manuellement !)

10.7 EXERCICES PRATIQUES
─────────────────────────

NIVEAU FACILE :
  Ex1 : Dans la base ShopFlow, identifiez toutes les Foreign Keys
        en listant : table source, colonne FK, table cible.

  Ex2 : Que se passe-t-il si vous tentez ces opérations sur ShopFlow ?
        a) INSERT INTO commandes (id_client) VALUES (9999);
        b) DELETE FROM clients WHERE id_client = 1;
        c) INSERT INTO lignes_commande (id_commande, id_produit) VALUES (1, 9999);

NIVEAU INTERMÉDIAIRE :
  Ex3 : Ajoutez à ShopFlow une table "coupons_utilises" qui enregistre
        quelle promotion a été utilisée sur quelle commande.
        Définissez les FK appropriées.

NIVEAU AVANCÉ :
  Ex4 : Proposez la bonne action ON DELETE pour chaque FK de ShopFlow :
        - commandes.id_client
        - lignes_commande.id_commande
        - lignes_commande.id_produit
        - avis.id_client
        - avis.id_produit
        Justifiez chaque choix.

10.8 CORRIGÉS
──────────────

CORRIGÉ Ex1 (Foreign Keys dans ShopFlow) :

  ┌─────────────────────┬──────────────────────┬──────────────────┐
  │ Table source        │ Colonne FK           │ Table cible      │
  ├─────────────────────┼──────────────────────┼──────────────────┤
  │ categories          │ id_parent            │ categories       │
  │ produits            │ id_categorie         │ categories       │
  │ produits            │ id_fournisseur       │ fournisseurs     │
  │ employes            │ id_manager           │ employes         │
  │ commandes           │ id_client            │ clients          │
  │ commandes           │ id_employe           │ employes         │
  │ lignes_commande     │ id_commande          │ commandes        │
  │ lignes_commande     │ id_produit           │ produits         │
  │ paiements           │ id_commande          │ commandes        │
  │ avis                │ id_produit           │ produits         │
  │ avis                │ id_client            │ clients          │
  │ stocks_entrepot     │ id_entrepot          │ entrepots        │
  │ stocks_entrepot     │ id_produit           │ produits         │
  └─────────────────────┴──────────────────────┴──────────────────┘

CORRIGÉ Ex4 (actions ON DELETE recommandées) :

  commandes.id_client -> RESTRICT
  Raison : ne jamais supprimer automatiquement des commandes.
  La suppression d'un client actif avec des commandes doit être bloquée.
  Solution business : désactiver le client (actif=FALSE) plutôt que supprimer.

  lignes_commande.id_commande -> CASCADE
  Raison : les lignes sont le "détail" de la commande. Si on supprime
  une commande (annulation totale), ses lignes doivent disparaître aussi.
  C'est une relation "composition" (les lignes n'existent pas sans commande).

  lignes_commande.id_produit -> RESTRICT
  Raison : si un produit est référencé dans des commandes historiques,
  on ne doit pas le supprimer. On le désactive (actif=FALSE) à la place.

  avis.id_client -> SET NULL (si la colonne est nullable)
  Raison : les avis publics ont de la valeur même si le client supprime
  son compte (droit à l'oubli RGPD séparé). L'avis reste, auteur anonyme.

  avis.id_produit -> RESTRICT
  Raison : on ne supprime pas des produits (on les désactive).
  Si on voulait supprimer : CASCADE pour nettoyer les avis orphelins.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 11 — LES RELATIONS ENTRE TABLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

11.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Les relations définissent comment les tables sont connectées entre elles.
Il existe 3 types de cardinalités :

  1:1  -> Un vers Un
  1:N  -> Un vers Plusieurs (la plus courante)
  N:N  -> Plusieurs vers Plusieurs

11.2 RELATION UN-À-UN (1:1)
─────────────────────────────

Définition : Un enregistrement de la table A correspond à AU PLUS
             un enregistrement de la table B, et vice versa.

Exemple : clients <-> profils_detailles
  -> Chaque client a exactement un profil détaillé
  -> Chaque profil appartient à exactement un client

Cas d'usage :
  -> Séparer des données rarement utilisées (performance)
  -> Données confidentielles (permissions séparées)
  -> Extension de table existante sans la modifier

Implémentation :

  CREATE TABLE clients (
      id_client   SERIAL PRIMARY KEY,
      email       VARCHAR(200) UNIQUE NOT NULL,
      prenom      VARCHAR(100),
      nom         VARCHAR(100)
  );

  CREATE TABLE clients_profil_complet (
      id_client        INTEGER PRIMARY KEY  -- PK ET FK simultanément !
                       REFERENCES clients(id_client) ON DELETE CASCADE,
      date_naissance   DATE,
      numero_ss        VARCHAR(15),         -- données sensibles séparées
      adresse_complete TEXT,
      preferences      JSONB,
      notes_internes   TEXT
  );

  Visualisation :
  clients                        clients_profil_complet
  ┌───────────────┐             ┌──────────────────────────┐
  │ id_client (PK)│──────────── │ id_client (PK + FK)      │
  │ email         │    1:1      │ date_naissance           │
  │ prenom        │             │ numero_ss                │
  │ nom           │             │ adresse_complete         │
  └───────────────┘             └──────────────────────────┘

  Règle d'implémentation 1:1 :
  -> La FK est dans la table "secondaire"
  -> La FK est aussi PRIMARY KEY (garantit le 1:1)
  -> Ou : FK avec contrainte UNIQUE

11.3 RELATION UN-À-PLUSIEURS (1:N)
────────────────────────────────────

Définition : Un enregistrement de la table A correspond à
             PLUSIEURS enregistrements de la table B.
             Un enregistrement de B correspond à AU PLUS un de A.

Exemple : clients -> commandes
  -> Un client peut avoir plusieurs commandes (1:N)
  -> Une commande appartient à exactement un client (N:1 vu de commandes)

C'est la relation la PLUS COURANTE dans les bases de données.

Autres exemples dans ShopFlow :
  -> categories -> produits (une catégorie contient plusieurs produits)
  -> commandes -> lignes_commande (une commande contient plusieurs lignes)
  -> fournisseurs -> produits (un fournisseur fournit plusieurs produits)
  -> employes -> employes (un manager a plusieurs subordonnés)

Implémentation :

  -- La FK est TOUJOURS du côté "Plusieurs"
  -- (la table qui peut avoir plusieurs fois le même parent)

  CREATE TABLE clients (
      id_client  SERIAL PRIMARY KEY  -- côté "1"
  );

  CREATE TABLE commandes (
      id_commande  SERIAL PRIMARY KEY,
      id_client    INTEGER NOT NULL    -- côté "N" : la FK est ICI
                   REFERENCES clients(id_client)
  );

  Visualisation :
  clients (1)              commandes (N)
  ┌──────────────┐         ┌──────────────────┐
  │ id_client=1  │────────<│ id_commande=101  │
  │ Alice        │         │ id_client=1      │
  └──────────────┘    ┌───<│ id_commande=102  │
                      │    │ id_client=1      │
  ┌──────────────┐    │    │ id_commande=103  │
  │ id_client=2  │────┘    │ id_client=2      │
  │ Bob          │         └──────────────────┘
  └──────────────┘

  Règle d'implémentation 1:N :
  -> La FK est dans la table côté "N" (côté "plusieurs")
  -> La FK peut se répéter (plusieurs commandes pour le même client)
  -> La PK référencée ne se répète pas (chaque client est unique)

11.4 RELATION PLUSIEURS-À-PLUSIEURS (N:N)
───────────────────────────────────────────

Définition : Un enregistrement de A peut correspondre à plusieurs de B,
             ET un enregistrement de B peut correspondre à plusieurs de A.

Exemple : commandes <-> produits
  -> Une commande contient plusieurs produits
  -> Un produit peut apparaître dans plusieurs commandes

Problème : on ne peut pas implémenter N:N directement !
  -> On ne peut pas stocker plusieurs ids dans une seule cellule
  -> (En tout cas, ce serait une très mauvaise pratique)

Solution : TABLE DE JOINTURE (ou table d'association, table pivot)
  -> Créer une table intermédiaire avec deux FK

  commandes (N)            lignes_commande              produits (N)
  ┌────────────┐     ┌───────────────────────────┐    ┌─────────────┐
  │ id_cmd=1   │──── │ id_commande=1, id_prod=10 │────│ id_prod=10  │
  │            │     │ id_commande=1, id_prod=20 │────│ id_prod=20  │
  │ id_cmd=2   │──── │ id_commande=2, id_prod=10 │────│             │
  └────────────┘     └───────────────────────────┘    └─────────────┘

  La même commande peut référencer plusieurs produits
  ET le même produit peut apparaître dans plusieurs commandes.

  La table de jointure lignes_commande contient :
  -> FK vers commandes (id_commande)
  -> FK vers produits (id_produit)
  -> Des attributs propres à la relation (quantite, prix_unitaire)
  -> Clé primaire composite (id_commande, id_produit) OU SERIAL séparé

  Implémentation :
  CREATE TABLE lignes_commande (
      id_ligne          SERIAL PRIMARY KEY,  -- PK propre (plus flexible)
      id_commande       INTEGER NOT NULL REFERENCES commandes(id_commande),
      id_produit        INTEGER NOT NULL REFERENCES produits(id_produit),
      quantite          INTEGER NOT NULL CHECK (quantite > 0),
      prix_unitaire_ht  DECIMAL(10,2) NOT NULL,
      -- Optionnel : contrainte unique sur la paire
      UNIQUE (id_commande, id_produit)  -- pas deux fois le même produit
                                         -- dans la même commande
  );

Autres exemples N:N dans ShopFlow :
  -> employes <-> formations (via employes_formations)
  -> produits <-> fournisseurs alternatifs (via produits_fournisseurs)
  -> clients <-> promotions utilisées (via coupons_utilises)

11.5 DIAGRAMME ENTITÉ-RELATION (ERD) DE SHOPFLOW
──────────────────────────────────────────────────

Représentation graphique des relations :

  fournisseurs ──<  produits  >── categories
       │               │
       │               │ (N:N via lignes_commande)
       │               │
                  commandes  >── clients
                      │
                  lignes_commande
                      │
                  paiements
                      │
                  commandes ── employes >── employes (self)

  Notation :
  ─── -> relation
  >── -> côté "plusieurs"
  ──< -> côté "un"

11.6 EXERCICES PRATIQUES
─────────────────────────

NIVEAU FACILE :
  Ex1 : Identifiez le type de relation (1:1, 1:N, N:N) :
        a) Un département contient plusieurs employés
        b) Un passeport appartient à une personne
        c) Un étudiant s'inscrit à plusieurs cours
        d) Une commande contient plusieurs produits

NIVEAU INTERMÉDIAIRE :
  Ex2 : Créez le schéma SQL pour un système de tags :
        - Plusieurs articles peuvent avoir plusieurs tags
        - Créez les tables et la table de jointure

NIVEAU AVANCÉ :
  Ex3 : Modélisez un système de permissions pour ShopFlow :
        - Les employés ont des rôles (admin, commercial, logisticien)
        - Les rôles ont des permissions (lire_clients, créer_commandes...)
        - Un employé peut avoir plusieurs rôles
        - Un rôle peut avoir plusieurs permissions
        Dessinez le schéma et écrivez le SQL.

11.7 CORRIGÉS
──────────────

CORRIGÉ Ex2 (système de tags) :

  CREATE TABLE articles (
      id_article  SERIAL PRIMARY KEY,
      titre       VARCHAR(300) NOT NULL,
      contenu     TEXT,
      date_publication TIMESTAMPTZ DEFAULT NOW()
  );

  CREATE TABLE tags (
      id_tag   SERIAL PRIMARY KEY,
      nom      VARCHAR(100) NOT NULL UNIQUE,
      couleur  CHAR(6) DEFAULT 'CCCCCC'  -- couleur hex
  );

  -- Table de jointure N:N
  CREATE TABLE articles_tags (
      id_article  INTEGER REFERENCES articles(id_article) ON DELETE CASCADE,
      id_tag      INTEGER REFERENCES tags(id_tag) ON DELETE CASCADE,
      PRIMARY KEY (id_article, id_tag)  -- clé composite
  );

  -- Utilisation :
  INSERT INTO articles (titre) VALUES ('Guide SQL complet');
  INSERT INTO tags (nom) VALUES ('sql'), ('database'), ('tutorial');

  INSERT INTO articles_tags VALUES
    (1, 1),  -- Guide SQL -> tag sql
    (1, 2),  -- Guide SQL -> tag database
    (1, 3);  -- Guide SQL -> tag tutorial

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 12 — LA NORMALISATION DES DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

12.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que la normalisation ?
  La normalisation est le processus qui consiste à organiser les données
  d'une base de données pour réduire la REDONDANCE et améliorer
  l'INTÉGRITÉ des données.

Pourquoi ça existe ?
  Sans normalisation, on a des problèmes :
  -> Redondance : même information répétée en plusieurs endroits
  -> Anomalies de mise à jour : modifier la ville d'un client dans 500 commandes
  -> Anomalies d'insertion : impossible d'ajouter une catégorie sans produit
  -> Anomalies de suppression : supprimer la dernière commande efface le client

Les formes normales sont des niveaux de normalisation numérotés :
  1NF -> 2NF -> 3NF -> BCNF -> 4NF -> 5NF
  Dans la pratique, on vise 3NF (BCNF pour les plus rigoureux).

12.2 PREMIÈRE FORME NORMALE (1NF)
───────────────────────────────────

Règles 1NF :
  1. Chaque colonne contient des valeurs atomiques (indivisibles)
  2. Pas de groupes répétés
  3. Chaque ligne est unique (clé primaire)

Exemple de table NON normalisée :

  ┌────────────┬──────────┬──────────────────────────────────────┐
  │ id_client  │ nom      │ produits_achetes                     │
  ├────────────┼──────────┼──────────────────────────────────────┤
  │     1      │ Alice    │ iPhone, MacBook, AirPods             │ <- VIOLATION !
  │     2      │ Bob      │ Samsung Galaxy                       │
  └────────────┴──────────┴──────────────────────────────────────┘

  Problèmes :
  -> "iPhone, MacBook, AirPods" n'est pas atomique
  -> Impossible de filtrer les acheteurs d'iPhone sans LIKE '%iPhone%'
  -> Impossible de compter les achats d'un produit précis

  Solution 1NF : décomposer en lignes séparées (table de jointure)

  ┌────────────┬──────────┐     ┌────────────┬──────────────┐
  │ id_client  │ nom      │     │ id_client  │ produit      │
  ├────────────┼──────────┤     ├────────────┼──────────────┤
  │     1      │ Alice    │     │     1      │ iPhone       │
  │     2      │ Bob      │     │     1      │ MacBook      │
  └────────────┴──────────┘     │     1      │ AirPods      │
                                │     2      │ Samsung      │
                                └────────────┴──────────────┘

12.3 DEUXIÈME FORME NORMALE (2NF)
───────────────────────────────────

Règles 2NF : doit être en 1NF + pas de dépendance partielle
  -> Chaque colonne non-clé doit dépendre de TOUTE la clé primaire
  -> (Pertinent uniquement si la clé primaire est COMPOSITE)

Exemple NON en 2NF :

  Table commandes_produits avec PK composite (id_commande, id_produit) :

  ┌─────────────┬────────────┬──────────────┬──────────────────┬──────────────────┐
  │ id_commande │ id_produit │ quantite     │ nom_produit      │ categorie_prod   │
  ├─────────────┼────────────┼──────────────┼──────────────────┼──────────────────┤
  │      1      │    101     │      2       │ iPhone 15        │ Smartphones      │
  │      1      │    102     │      1       │ MacBook Pro      │ Informatique     │
  │      2      │    101     │      1       │ iPhone 15        │ Smartphones      │ <- doublon !
  └─────────────┴────────────┴──────────────┴──────────────────┴──────────────────┘

  Problème :
  -> nom_produit et categorie_prod dépendent UNIQUEMENT de id_produit
  -> Pas de toute la clé (id_commande, id_produit)
  -> Si le nom du produit change -> mettre à jour des centaines de lignes !

  Solution 2NF : sortir nom_produit dans la table produits

  Table produits :        Table lignes_commande :
  ┌────────────┬────────┐  ┌──────┬──────┬──────┐
  │ id_produit │ nom    │  │ cmd  │ prod │ qty  │
  ├────────────┼────────┤  ├──────┼──────┼──────┤
  │    101     │ iPhone │  │  1   │  101 │  2   │
  │    102     │ MacBook│  │  1   │  102 │  1   │
  └────────────┴────────┘  │  2   │  101 │  1   │
                           └──────┴──────┴──────┘

12.4 TROISIÈME FORME NORMALE (3NF)
────────────────────────────────────

Règles 3NF : doit être en 2NF + pas de dépendance transitive
  -> Une colonne non-clé ne doit pas dépendre d'une autre colonne non-clé

Exemple NON en 3NF :

  Table clients :
  ┌──────────┬───────┬────────────────┬───────────────┐
  │id_client │ cp    │ ville          │ region        │
  ├──────────┼───────┼────────────────┼───────────────┤
  │    1     │ 75001 │ Paris          │ Île-de-France │
  │    2     │ 69001 │ Lyon           │ Auvergne-RhA  │
  │    3     │ 75001 │ Paris          │ Île-de-France │ <- doublon !
  └──────────┴───────┴────────────────┴───────────────┘

  Dépendance transitive :
  id_client -> cp -> ville -> region

  Problème :
  -> La ville et la région dépendent du code postal, pas directement du client
  -> Si Paris change de région -> mettre à jour des milliers de lignes !

  Solution 3NF : créer une table codes_postaux

  Table codes_postaux :   Table clients :
  ┌───────┬────────┬─────┐  ┌──────────┬──────┐
  │ cp    │ ville  │ reg │  │id_client │ cp   │
  ├───────┼────────┼─────┤  ├──────────┼──────┤
  │ 75001 │ Paris  │ IdF │  │    1     │75001 │
  │ 69001 │ Lyon   │ ARa │  │    2     │69001 │
  └───────┴────────┴─────┘  │    3     │75001 │
                            └──────────┴──────┘

12.5 BCNF (BOYCE-CODD NORMAL FORM)
────────────────────────────────────

BCNF = 3NF avec des garanties supplémentaires sur les clés candidates.
La plupart du temps, si vous êtes en 3NF, vous êtes en BCNF.

Règle : pour chaque dépendance fonctionnelle X -> Y,
        X doit être une superclé (clé candidate ou superensemble de clé).

En pratique : viser 3NF est suffisant pour la majorité des projets.

12.6 DÉNORMALISATION — QUAND CASSER LES RÈGLES
────────────────────────────────────────────────

La dénormalisation est délibérée et réfléchie, uniquement pour la performance.

Exemples légitimes de dénormalisation :

  1. Stocker le montant_total dans commandes
     -> Calculable via SUM(lignes_commande) mais trop lent à recalculer
     -> On le stocke et on le met à jour via trigger ou applicatif

  2. Stocker le nb_commandes dans clients
     -> Comptable mais coûteux : SELECT COUNT(*) FROM commandes WHERE id_client=1
     -> Pour un dashboard avec millions de clients, on pré-calcule

  3. Tables de données agrégées pour les rapports
     -> Résumés quotidiens précalculés plutôt que de recalculer sur toutes les données

  Règle d'or : normalisez d'abord, dénormalisez seulement si vous avez
               un problème de performance MESURÉ avec des PREUVES.

12.7 VÉRIFICATION NORMALISATION SUR SHOPFLOW
─────────────────────────────────────────────

ShopFlow est-elle normalisée ?

  1NF [OK] : toutes les valeurs sont atomiques
  -> Pas de champ "produits achetés" dans une seule cellule

  2NF [OK] : pas de dépendances partielles
  -> lignes_commande a un SERIAL id_ligne ou PK composite (id_commande, id_produit)
  -> nom_produit est dans produits, pas dans lignes_commande

  3NF [OK] : pas de dépendances transitives
  -> La ville n'est pas dans clients (juste code_postal + ville séparés)
  -> Les infos catégorie ne sont pas dupliquées dans produits

  Légères dénormalisations intentionnelles :
  -> montant_ttc dans commandes (calculable mais pré-calculé pour performance)
  -> montant_ligne_ht comme colonne générée (calculé automatiquement par BD)

12.8 EXERCICES PRATIQUES
─────────────────────────

NIVEAU FACILE :
  Ex1 : Cette table respecte-t-elle la 1NF ? Sinon, corrigez-la.
        commandes(id, id_client, produits_commandes, adresse_livraison)
        où produits_commandes contient "prod1,prod2,prod3"

NIVEAU INTERMÉDIAIRE :
  Ex2 : Cette table est en 1NF mais pas en 2NF. Identifiez le problème et corrigez.
        inscriptions(id_etudiant, id_cours, nom_etudiant, titre_cours, note)
        PK : (id_etudiant, id_cours)

NIVEAU AVANCÉ :
  Ex3 : Normalisez cette table jusqu'en 3NF et écrivez le SQL final :
        ventes(id_vente, date, id_vendeur, nom_vendeur, equipe_vendeur,
               id_produit, nom_produit, id_categorie, nom_categorie, prix, quantite)

12.9 CORRIGÉS
──────────────

CORRIGÉ Ex2 (2NF) :

  Problème identifié :
  -> nom_etudiant dépend UNIQUEMENT de id_etudiant (pas de id_cours)
  -> titre_cours dépend UNIQUEMENT de id_cours (pas de id_etudiant)
  -> Ces dépendances partielles violent la 2NF

  Solution :

  CREATE TABLE etudiants (
      id_etudiant  SERIAL PRIMARY KEY,
      nom_etudiant VARCHAR(100) NOT NULL
  );

  CREATE TABLE cours (
      id_cours     SERIAL PRIMARY KEY,
      titre_cours  VARCHAR(200) NOT NULL
  );

  CREATE TABLE inscriptions (
      id_etudiant  INTEGER REFERENCES etudiants(id_etudiant),
      id_cours     INTEGER REFERENCES cours(id_cours),
      note         DECIMAL(4,2) CHECK (note BETWEEN 0 AND 20),
      date_inscription DATE DEFAULT CURRENT_DATE,
      PRIMARY KEY (id_etudiant, id_cours)
  );

CORRIGÉ Ex3 (normalisation 3NF) :

  Table ventes(id_vente, date, id_vendeur, nom_vendeur, equipe_vendeur,
               id_produit, nom_produit, id_categorie, nom_categorie, prix, quantite)

  Dépendances identifiées :
  -> id_vendeur -> nom_vendeur, equipe_vendeur (dépendance partielle + transitive)
  -> id_produit -> nom_produit, id_categorie, prix (dépendance partielle)
  -> id_categorie -> nom_categorie (dépendance transitive via produit)

  Tables normalisées :

  CREATE TABLE equipes (
      id_equipe  SERIAL PRIMARY KEY,
      nom_equipe VARCHAR(100)
  );

  CREATE TABLE vendeurs (
      id_vendeur   SERIAL PRIMARY KEY,
      nom_vendeur  VARCHAR(100) NOT NULL,
      id_equipe    INTEGER REFERENCES equipes(id_equipe)
  );

  CREATE TABLE categories (
      id_categorie  SERIAL PRIMARY KEY,
      nom_categorie VARCHAR(100) NOT NULL
  );

  CREATE TABLE produits (
      id_produit   SERIAL PRIMARY KEY,
      nom_produit  VARCHAR(200) NOT NULL,
      prix         DECIMAL(10,2) NOT NULL,
      id_categorie INTEGER REFERENCES categories(id_categorie)
  );

  CREATE TABLE ventes (
      id_vente    SERIAL PRIMARY KEY,
      date_vente  DATE NOT NULL,
      id_vendeur  INTEGER REFERENCES vendeurs(id_vendeur),
      id_produit  INTEGER REFERENCES produits(id_produit),
      quantite    INTEGER NOT NULL CHECK (quantite > 0)
  );

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 6 : Créer, modifier et supprimer des tables avec CREATE/ALTER/DROP.
                  Les contraintes de colonne et de table.

  [OK] Chapitres 7-8 : Tous les types de données PostgreSQL en détail :
                     INTEGER, DECIMAL, VARCHAR, TEXT, DATE, TIMESTAMPTZ,
                     BOOLEAN, UUID, JSONB, ARRAY. Quand choisir quoi.

  [OK] Chapitre 9 : Les clés primaires (synthétiques vs naturelles vs composites).
                  SERIAL, BIGSERIAL, UUID. L'index B-Tree automatique.

  [OK] Chapitre 10 : Les clés étrangères et l'intégrité référentielle.
                   RESTRICT, CASCADE, SET NULL, SET DEFAULT. Self-referencing FK.

  [OK] Chapitre 11 : Les 3 types de relations : 1:1, 1:N, N:N.
                   La table de jointure pour les N:N.

  [OK] Chapitre 12 : La normalisation 1NF, 2NF, 3NF, BCNF.
                   Les anomalies évitées. La dénormalisation intentionnelle.

POINTS CLÉS À RETENIR :
  -> Toujours utiliser DECIMAL pour l'argent, JAMAIS FLOAT
  -> Utiliser TIMESTAMPTZ pour les timestamps internationaux
  -> La FK est TOUJOURS du côté "N" dans une relation 1:N
  -> Une relation N:N nécessite TOUJOURS une table de jointure
  -> Normaliser jusqu'en 3NF, dénormaliser seulement si nécessaire et mesuré
  -> Jamais l'email comme clé primaire

PROCHAINE PARTIE :
  Partie 3 — Requêtes SQL de base : SELECT, WHERE, ORDER BY, LIMIT.
  Vous allez enfin interroger la base ShopFlow !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 2 — MODÉLISATION DES DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS           ║
║                      PARTIE 3 — REQUÊTES SQL DE BASE                             ║
║                              Chapitres 13 à 16                                   ║
╚══════════════════════════════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 13 — SELECT : L'ART D'INTERROGER LES DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

13.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que SELECT ?
  SELECT est la commande fondamentale de SQL. Elle permet de LIRE
  des données depuis une ou plusieurs tables. C'est la commande la plus
  utilisée (environ 70-80% de toutes les requêtes SQL en production).

Pourquoi il existe ?
  Pour répondre à des questions sur vos données :
  "Quels sont nos clients VIP ?"
  "Quels produits sont en rupture de stock ?"
  "Quel est le chiffre d'affaires du mois dernier ?"

Dans quels cas on l'utilise ?
  -> Toujours : chaque affichage de données, chaque rapport, chaque API

13.2 ANATOMIE COMPLÈTE DU SELECT
──────────────────────────────────

Syntaxe complète :

  SELECT   [DISTINCT] expression1 [AS alias1], expression2 [AS alias2], ...
  FROM     table1 [AS alias_table]
  [JOIN    table2 ON condition]
  [WHERE   condition_filtre]
  [GROUP BY expression_groupe]
  [HAVING  condition_groupe]
  [ORDER BY expression_tri [ASC|DESC]]
  [LIMIT   n [OFFSET m]];

Ordre d'exécution du moteur SQL :

  1. FROM + JOIN    -> identifier et combiner les tables sources
  2. WHERE          -> filtrer les lignes
  3. GROUP BY       -> grouper les lignes restantes
  4. HAVING         -> filtrer les groupes
  5. SELECT         -> calculer les colonnes à afficher
  6. DISTINCT       -> éliminer les doublons
  7. ORDER BY       -> trier
  8. LIMIT/OFFSET   -> paginer

13.3 SELECT * — LE JOKER (ET SES DANGERS)
──────────────────────────────────────────

-- Sélectionner TOUTES les colonnes
SELECT * FROM clients;

-- Résultat (toutes les colonnes dans l'ordre de définition) :
-- id_client | code_client | prenom | nom | email | telephone | ...

  [ATTENTION] AVERTISSEMENT — Problèmes de SELECT * en production :

  1. Performance : transfère des données inutiles (si vous voulez seulement
     le nom et l'email, pourquoi récupérer toutes les colonnes ?)

  2. Fragilité : si vous ajoutez une colonne à la table, votre requête
     retourne maintenant cette nouvelle colonne -> peut casser votre application

  3. Illisibilité : dans une jointure, SELECT * retourne les colonnes des
     deux tables -> noms ambigus, colonnes dupliquées

  4. Données sensibles : * peut inclure mot_de_passe, numero_ss, salaire

  [OK] BONNE PRATIQUE : toujours lister les colonnes explicitement en production

  -- Mauvais :
  SELECT * FROM clients;

  -- Bon :
  SELECT id_client, prenom, nom, email, segment FROM clients;

  Acceptable avec * :
  -> Exploration rapide en développement : SELECT * FROM ma_table LIMIT 10;
  -> Sous-requêtes dans EXISTS : WHERE EXISTS (SELECT * FROM ...)
  -> COUNT(*) : sémantiquement correct pour compter toutes les lignes

13.4 SÉLECTIONNER DES COLONNES SPÉCIFIQUES
────────────────────────────────────────────

-- Colonnes simples (noms exacts)
SELECT prenom, nom, email
FROM clients;

-- Résultat :
-- prenom  | nom      | email
-- --------+----------+-------------------------------
-- Alice   | Dupont   | alice.dupont@email.fr
-- Bob     | Martin   | bob.martin@email.fr
-- ...

-- Colonnes avec alias (renommer pour l'affichage)
SELECT
    prenom            AS "Prénom",
    nom               AS "Nom de famille",
    email             AS "Adresse email"
FROM clients;

-- Note : guillemets doubles pour les alias avec espaces/accents
-- Guillemets simples = chaîne de caractères
-- Guillemets doubles = identifiant (nom de colonne/table/alias)

-- Alias sans guillemets (si nom simple)
SELECT prenom AS p, nom AS n FROM clients;

13.5 EXPRESSIONS ET CALCULS DANS SELECT
─────────────────────────────────────────

-- Concaténation de chaînes
SELECT
    prenom || ' ' || nom AS nom_complet,
    -- OU avec CONCAT :
    CONCAT(prenom, ' ', nom) AS nom_complet2
FROM clients;

-- Calculs arithmétiques
SELECT
    nom,
    prix_ht,
    prix_ht * 1.20                    AS prix_ttc,
    prix_ht * 1.20 - prix_ht          AS montant_tva,
    ROUND(prix_ht * 1.20, 2)          AS prix_ttc_arrondi
FROM produits;

-- Résultat :
-- nom              | prix_ht  | prix_ttc  | montant_tva | prix_ttc_arrondi
-- MacBook Pro 14"  | 1999.17  | 2399.004  | 399.834     | 2399.00

-- Fonctions sur les chaînes
SELECT
    UPPER(nom)        AS nom_majuscule,
    LOWER(email)      AS email_minuscule,
    LENGTH(nom)       AS longueur_nom,
    SUBSTR(nom, 1, 3) AS initiales
FROM clients;

-- Fonctions sur les dates
SELECT
    prenom,
    date_naissance,
    EXTRACT(YEAR FROM AGE(date_naissance)) AS age,
    DATE_PART('year', NOW()) - DATE_PART('year', date_naissance) AS age_approx,
    TO_CHAR(date_naissance, 'DD/MM/YYYY') AS naissance_formatee
FROM clients
WHERE date_naissance IS NOT NULL;

-- Expressions conditionnelles (CASE WHEN)
SELECT
    prenom,
    nom,
    segment,
    CASE segment
        WHEN 'VIP'      THEN '*** VIP'
        WHEN 'PREMIUM'  THEN '** Premium'
        WHEN 'STANDARD' THEN '* Standard'
        ELSE '🆕 Nouveau'
    END AS segment_libelle
FROM clients;

-- CASE avec conditions complexes
SELECT
    nom,
    prix_ht,
    stock,
    CASE
        WHEN stock = 0                THEN 'RUPTURE'
        WHEN stock < stock_min        THEN 'STOCK FAIBLE'
        WHEN stock < stock_min * 2    THEN 'STOCK MOYEN'
        ELSE                               'STOCK OK'
    END AS alerte_stock
FROM produits;

13.6 DISTINCT — ÉLIMINER LES DOUBLONS
───────────────────────────────────────

-- Sans DISTINCT : retourne toutes les valeurs (avec doublons)
SELECT segment FROM clients;
-- Résultat : VIP, PREMIUM, STANDARD, STANDARD, NOUVEAU, VIP, PREMIUM, ...

-- Avec DISTINCT : chaque valeur unique une seule fois
SELECT DISTINCT segment FROM clients;
-- Résultat : VIP, PREMIUM, STANDARD, NOUVEAU

-- DISTINCT sur plusieurs colonnes
SELECT DISTINCT adresse_pays, segment FROM clients;
-- Chaque combinaison (pays, segment) est unique

-- COUNT DISTINCT : compter les valeurs uniques
SELECT COUNT(DISTINCT id_client) AS nb_clients_ayant_commande
FROM commandes;

-- Attention : DISTINCT est coûteux (tri interne !) -> utiliser avec parcimonie
-- Alternative : GROUP BY souvent plus performant

13.7 GESTION DES VALEURS NULL
───────────────────────────────

NULL = "valeur inconnue ou absente" (pas zéro, pas chaîne vide !)

-- Tester si une valeur est NULL
SELECT prenom, telephone
FROM clients
WHERE telephone IS NULL;     -- clients sans téléphone

-- Tester si une valeur n'est PAS NULL
SELECT prenom, telephone
FROM clients
WHERE telephone IS NOT NULL;

-- COALESCE : retourner la première valeur non-NULL
SELECT
    prenom,
    COALESCE(telephone, 'Non renseigné') AS telephone_affiche
FROM clients;

-- NULLIF : retourner NULL si la valeur égale un autre argument
SELECT
    nom,
    NULLIF(stock, 0) AS stock_reel  -- NULL si stock = 0
FROM produits;

-- NVL (Oracle) / IFNULL (MySQL) -> équivalent COALESCE à 2 arguments
-- En PostgreSQL : COALESCE(col, valeur_defaut)

-- PIÈGES avec NULL :
--   NULL = NULL -> FALSE (pas de résultat !)
--   NULL <> NULL -> FALSE également !
--   Seul IS NULL / IS NOT NULL fonctionne

-- Exemple piège :
SELECT * FROM clients WHERE telephone = NULL;   -- NE FONCTIONNE PAS !
SELECT * FROM clients WHERE telephone IS NULL;  -- CORRECT

-- Arithmétique avec NULL :
-- NULL + 5 = NULL
-- NULL * 0 = NULL (pas 0 !)
-- COALESCE(NULL, 0) + 5 = 5 (solution)

13.8 ALIAS DE TABLE
────────────────────

-- Alias court pour la table (utile surtout avec JOIN)
SELECT c.prenom, c.nom, c.email
FROM clients c;   -- 'c' est l'alias de clients

-- Alias obligatoire avec sous-requête
SELECT sous.prenom
FROM (
    SELECT prenom, nom FROM clients WHERE segment = 'VIP'
) AS sous;   -- la sous-requête DOIT avoir un alias

-- Convention de nommage des alias :
-- Table clients -> c ou cli
-- Table commandes -> co ou cmd
-- Table produits -> p ou prd
-- Table lignes_commande -> lc

13.9 SÉLECTIONNER DEPUIS PLUSIEURS TABLES (CROSS JOIN)
────────────────────────────────────────────────────────

-- SELECT depuis deux tables sans condition = PRODUIT CARTÉSIEN
-- (chaque ligne de A combinée avec chaque ligne de B)
SELECT c.nom, p.nom AS produit
FROM clients c, produits p;
-- 10 clients × 15 produits = 150 lignes !
-- Rarement ce qu'on veut -> utilisez JOIN à la place (Partie 6)

13.10 BONNES PRATIQUES SELECT
───────────────────────────────

1. Lister explicitement les colonnes (jamais SELECT * en production)
2. Utiliser des alias descriptifs et cohérents
3. Indenter les requêtes longues pour la lisibilité :

   -- Mauvais :
   SELECT p.nom,p.prix_ht*1.2 AS prix_ttc,c.nom AS cat FROM produits p JOIN categories c ON p.id_categorie=c.id_categorie;

   -- Bon :
   SELECT
       p.nom              AS produit,
       p.prix_ht * 1.2    AS prix_ttc,
       c.nom              AS categorie
   FROM produits p
   JOIN categories c ON p.id_categorie = c.id_categorie;

4. Toujours terminer par ; (point-virgule) même si optionnel en psql
5. Commenter les requêtes complexes :

   -- Calcul du prix TTC avec TVA à 20%
   SELECT
       nom,
       prix_ht,
       ROUND(prix_ht * 1.20, 2) AS prix_ttc  -- arrondi à 2 décimales
   FROM produits;

13.11 ERREURS FRÉQUENTES
──────────────────────────

[X] ERREUR 1 : Utiliser = pour comparer avec NULL
  SELECT * FROM clients WHERE telephone = NULL;  -- toujours vide !
  [OK] Solution : WHERE telephone IS NULL

[X] ERREUR 2 : Alias dans WHERE (ordre d'exécution)
  SELECT prix_ht * 1.2 AS prix_ttc FROM produits WHERE prix_ttc > 100;
  -- ERROR: column "prix_ttc" does not exist
  [OK] Solution : WHERE prix_ht * 1.2 > 100

[X] ERREUR 3 : SELECT * en production
  SELECT * FROM clients;  -- retourne les données sensibles
  [OK] Solution : lister les colonnes nécessaires

[X] ERREUR 4 : Oublier le point-virgule en script (pas en psql interactif)
  SELECT * FROM clients  -- manque le ;
  FROM commandes         -- confusionne le parser
  [OK] Solution : toujours ajouter ; à la fin

13.12 EXERCICES PRATIQUES — BASE SHOPFLOW
──────────────────────────────────────────

NIVEAU FACILE :

  Ex1 : Affichez le nom complet (prénom + nom), l'email et le segment
        de tous les clients. Renommez les colonnes de manière lisible.

  Ex2 : Affichez la référence, le nom, le prix HT et le prix TTC (HT × 1.2)
        de tous les produits. Arrondissez le prix TTC à 2 décimales.

  Ex3 : Listez les segments distincts de clients (sans doublons).

NIVEAU INTERMÉDIAIRE :

  Ex4 : Affichez tous les produits avec une colonne "ALERTE" :
        - 'RUPTURE' si stock = 0
        - 'FAIBLE' si stock < stock_min
        - 'OK' sinon

  Ex5 : Affichez les clients en remplaçant les téléphones manquants
        par 'Non renseigné'.

  Ex6 : Calculez pour chaque client :
        - Son nom complet
        - L'année d'inscription
        - Le nombre d'années depuis l'inscription

NIVEAU AVANCÉ :

  Ex7 : Créez une requête qui affiche pour chaque produit :
        - La référence
        - Le nom
        - Prix HT, TVA, Prix TTC
        - Une colonne "CATEGORIE_PRIX" :
          'ENTRÉE DE GAMME' si TTC < 100€
          'MID-RANGE' si TTC entre 100 et 500€
          'HAUT DE GAMME' si TTC > 500€

  Ex8 : Affichez les produits dont le nom contient le mot 'Pro'
        ou 'Premium' et qui sont actifs, avec leur prix TTC arrondi.

  Ex9 : Créez une colonne calculée qui affiche l'état du stock :
        "XX unités (X jours de stock)" où le nombre de jours estimé
        est stock / 5 (hypothèse : on vend 5 unités par jour).

13.13 CORRIGÉS DÉTAILLÉS
──────────────────────────

CORRIGÉ Ex1 :

  SELECT
      prenom || ' ' || nom    AS "Nom complet",
      email                   AS "Adresse email",
      segment                 AS "Segment client"
  FROM clients
  ORDER BY nom;

  -- Explication :
  -- prenom || ' ' || nom -> concaténation avec espace entre
  -- AS "Nom complet" -> alias avec guillemets (espace dans l'alias)
  -- ORDER BY nom -> trié par ordre alphabétique du nom

CORRIGÉ Ex2 :

  SELECT
      reference                           AS "Référence",
      nom                                 AS "Produit",
      prix_ht                             AS "Prix HT (€)",
      ROUND(prix_ht * 1.2, 2)            AS "Prix TTC (€)"
  FROM produits;

  -- ROUND(valeur, nb_decimales) : arrondit à n décimales
  -- prix_ht * 1.2 = HT + 20% TVA

CORRIGÉ Ex4 (CASE WHEN stock) :

  SELECT
      reference,
      nom,
      stock,
      stock_min,
      CASE
          WHEN stock = 0              THEN 'RUPTURE'
          WHEN stock < stock_min      THEN 'FAIBLE'
          ELSE                             'OK'
      END AS "ALERTE STOCK"
  FROM produits
  ORDER BY
      CASE WHEN stock = 0 THEN 1
           WHEN stock < stock_min THEN 2
           ELSE 3 END,  -- trier: rupture d'abord, puis faible, puis OK
      stock;

CORRIGÉ Ex7 (catégorie prix) :

  SELECT
      reference                           AS ref,
      nom,
      prix_ht                             AS ht,
      ROUND(prix_ht * 0.20, 2)           AS tva,
      ROUND(prix_ht * 1.20, 2)           AS ttc,
      CASE
          WHEN prix_ht * 1.20 < 100      THEN 'ENTRÉE DE GAMME'
          WHEN prix_ht * 1.20 <= 500     THEN 'MID-RANGE'
          ELSE                                'HAUT DE GAMME'
      END AS "CATÉGORIE PRIX"
  FROM produits
  WHERE actif = TRUE
  ORDER BY prix_ht;

  -- Note : la condition utilise prix_ht * 1.20 car l'alias 'ttc'
  -- n'est pas encore défini au moment de l'exécution du CASE

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 14 — WHERE : FILTRER LES DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

14.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que WHERE ?
  WHERE est la clause de filtrage. Elle permet de sélectionner UNIQUEMENT
  les lignes qui satisfont une condition. Sans WHERE, toutes les lignes
  de la table sont retournées.

Pourquoi ça existe ?
  Imaginez une table avec 10 millions de clients. Vous voulez ceux de Paris.
  WHERE filtre ces 10M de lignes et ne retourne que les Parisiens.

14.2 OPÉRATEURS DE COMPARAISON
────────────────────────────────

  =    -> égalité
  <> ou !=  -> différent de (les deux syntaxes fonctionnent)
  <    -> inférieur à
  >    -> supérieur à
  <=   -> inférieur ou égal
  >=   -> supérieur ou égal

Exemples :

-- Produits actifs
SELECT nom, prix_ht FROM produits WHERE actif = TRUE;

-- Clients qui ne sont pas VIP
SELECT prenom, nom FROM clients WHERE segment <> 'VIP';
-- Équivalent :
SELECT prenom, nom FROM clients WHERE segment != 'VIP';

-- Produits coûtant plus de 500€ HT
SELECT nom, prix_ht FROM produits WHERE prix_ht > 500;

-- Commandes passées après le 1er mars 2024
SELECT numero_commande, date_commande
FROM commandes
WHERE date_commande >= '2024-03-01';

-- Produits avec stock inférieur à leur minimum
SELECT nom, stock, stock_min
FROM produits
WHERE stock < stock_min;

14.3 FILTRES SUR LES CHAÎNES DE CARACTÈRES
────────────────────────────────────────────

-- Égalité exacte (sensible à la casse en PostgreSQL !)
SELECT * FROM clients WHERE segment = 'VIP';  -- OK
SELECT * FROM clients WHERE segment = 'vip';  -- Rien ! (mauvaise casse)

-- Insensible à la casse avec LOWER ou ILIKE
SELECT * FROM clients WHERE LOWER(segment) = 'vip';
SELECT * FROM clients WHERE segment ILIKE 'vip';  -- ILIKE = LIKE insensible casse

-- LIKE : pattern matching
-- % = n'importe quelle séquence de caractères
-- _ = exactement un caractère

SELECT nom FROM produits WHERE nom LIKE 'Mac%';       -- commence par Mac
SELECT nom FROM produits WHERE nom LIKE '%Pro%';      -- contient Pro
SELECT nom FROM produits WHERE nom LIKE '%15';        -- finit par 15
SELECT nom FROM produits WHERE nom LIKE 'Mac____';    -- Mac + 4 caractères exactement
SELECT email FROM clients WHERE email LIKE '%.fr';    -- emails .fr

-- ILIKE : LIKE insensible à la casse (PostgreSQL uniquement)
SELECT nom FROM produits WHERE nom ILIKE '%pro%';
-- Trouve : 'MacBook Pro', 'iPhone 15 Pro', 'AirPods Pro'

-- NOT LIKE : inverse
SELECT nom FROM produits WHERE nom NOT LIKE '%Samsung%';

14.4 FILTRES SUR LES DATES
────────────────────────────

-- Date exacte
SELECT * FROM commandes WHERE DATE(date_commande) = '2024-01-15';

-- Date après
SELECT * FROM commandes WHERE date_commande > '2024-02-01';

-- Date dans une plage (voir aussi BETWEEN au chapitre suivant)
SELECT numero_commande, date_commande
FROM commandes
WHERE date_commande >= '2024-01-01'
  AND date_commande < '2024-02-01';  -- janvier 2024

-- Extraire une partie de la date
SELECT * FROM commandes
WHERE EXTRACT(MONTH FROM date_commande) = 3;  -- mars (peu importe l'année)

SELECT * FROM commandes
WHERE EXTRACT(YEAR FROM date_commande) = 2024;  -- année 2024

-- Tronquer au mois
SELECT * FROM commandes
WHERE DATE_TRUNC('month', date_commande) = '2024-03-01';

-- Commandes des 30 derniers jours
SELECT * FROM commandes
WHERE date_commande >= NOW() - INTERVAL '30 days';

-- Commandes de la semaine en cours
SELECT * FROM commandes
WHERE DATE_TRUNC('week', date_commande) = DATE_TRUNC('week', NOW());

14.5 FILTRES SUR LES BOOLÉENS
───────────────────────────────

-- Produits actifs
SELECT * FROM produits WHERE actif = TRUE;
-- Équivalent (PostgreSQL accepte) :
SELECT * FROM produits WHERE actif;

-- Produits inactifs
SELECT * FROM produits WHERE actif = FALSE;
-- Équivalent :
SELECT * FROM produits WHERE NOT actif;

-- Clients avec newsletter
SELECT prenom, nom FROM clients WHERE newsletter = TRUE;

-- [ATTENTION] NE PAS écrire WHERE actif = 1 en PostgreSQL (type BOOLEAN, pas INTEGER)
-- MySQL accepte WHERE actif = 1, PostgreSQL non

14.6 FILTRES SUR NULL
──────────────────────

-- Clients sans date de naissance
SELECT prenom, nom FROM clients WHERE date_naissance IS NULL;

-- Commandes avec un employé assigné
SELECT numero_commande FROM commandes WHERE id_employe IS NOT NULL;

-- Produits sans fournisseur assigné
SELECT nom FROM produits WHERE id_fournisseur IS NULL;

-- COALESCE dans WHERE
SELECT nom FROM clients
WHERE COALESCE(telephone, '') = '';  -- équivalent à IS NULL ou vide

14.7 COMBINAISONS AVEC AND / OR / NOT
───────────────────────────────────────

-- AND : les DEUX conditions doivent être vraies
SELECT nom, prix_ht, stock
FROM produits
WHERE prix_ht > 100
  AND stock > 0;  -- cher ET en stock

-- OR : AU MOINS UNE condition doit être vraie
SELECT prenom, nom, segment
FROM clients
WHERE segment = 'VIP'
   OR segment = 'PREMIUM';  -- VIP ou PREMIUM

-- NOT : inverse la condition
SELECT nom FROM produits WHERE NOT actif;  -- produits inactifs

-- Combinaisons complexes (ATTENTION à la priorité !)
-- AND a la priorité sur OR !

-- Sans parenthèses (peut être trompeur) :
SELECT * FROM clients
WHERE segment = 'VIP'
   OR segment = 'PREMIUM'
  AND adresse_pays = 'France';
-- Interprété comme : VIP (tous pays) OR (PREMIUM ET France)
-- Probablement PAS ce que vous vouliez !

-- Avec parenthèses (explicite et correct) :
SELECT * FROM clients
WHERE (segment = 'VIP' OR segment = 'PREMIUM')
  AND adresse_pays = 'France';
-- (VIP ou PREMIUM) ET France

-- Règle : TOUJOURS utiliser des parenthèses avec AND + OR mixés !

14.8 EXEMPLES BUSINESS COMPLETS
─────────────────────────────────

-- Dashboard : commandes à traiter aujourd'hui
SELECT
    c.numero_commande,
    cl.prenom || ' ' || cl.nom AS client,
    c.montant_ttc,
    c.statut
FROM commandes c
JOIN clients cl ON c.id_client = cl.id_client
WHERE c.statut IN ('EN_ATTENTE', 'CONFIRMEE', 'EN_PREPARATION')
  AND DATE(c.date_commande) = CURRENT_DATE
ORDER BY c.date_commande;

-- Alerte stock : produits à recommander
SELECT
    p.reference,
    p.nom,
    p.stock,
    p.stock_min,
    f.raison_sociale AS fournisseur
FROM produits p
LEFT JOIN fournisseurs f ON p.id_fournisseur = f.id_fournisseur
WHERE p.actif = TRUE
  AND p.stock <= p.stock_min
ORDER BY p.stock ASC;  -- les plus urgents d'abord

-- Clients à relancer (pas commandé depuis 60 jours)
-- (nécessite la Partie 7 sur les sous-requêtes)

14.9 PERFORMANCE DE WHERE
──────────────────────────

Le moteur SQL optimise WHERE en utilisant les INDEX quand possible.

-- INDEX UTILISÉ (très rapide) :
SELECT * FROM clients WHERE id_client = 5;     -- PK = index unique
SELECT * FROM clients WHERE email = 'a@b.fr';  -- UNIQUE = index

-- INDEX PROBABLEMENT UTILISÉ :
SELECT * FROM commandes WHERE date_commande > '2024-01-01';  -- si index sur date

-- INDEX IGNORÉ (scan complet, lent !) :
SELECT * FROM clients WHERE LOWER(nom) = 'dupont';  -- fonction sur colonne indexée
SELECT * FROM clients WHERE nom LIKE '%pont';        -- wildcard au début
SELECT * FROM produits WHERE prix_ht * 1.2 > 500;   -- calcul sur colonne

-- Solution pour les fonctions : index fonctionnel
CREATE INDEX idx_clients_nom_lower ON clients(LOWER(nom));
-- Maintenant cette requête utilisera l'index :
SELECT * FROM clients WHERE LOWER(nom) = 'dupont';

14.10 EXERCICES PRATIQUES — BASE SHOPFLOW
──────────────────────────────────────────

NIVEAU FACILE :

  Ex1 : Listez tous les produits avec un prix HT supérieur à 500€
        qui sont actifs. Affichez nom, prix HT et stock.

  Ex2 : Trouvez tous les clients dont le prénom commence par 'A'
        ou se termine par 'e'.

  Ex3 : Listez les commandes dont le statut est 'LIVREE'.
        Affichez le numéro de commande et la date.

NIVEAU INTERMÉDIAIRE :

  Ex4 : Trouvez les produits actifs dans la catégorie 2 (informatique)
        OU avec un prix HT supérieur à 800€ ET un stock supérieur à 30.
        Attention à la priorité des opérateurs !

  Ex5 : Listez les clients VIP ou PREMIUM qui habitent Paris (CP = 75001)
        et qui ont activé la newsletter.

  Ex6 : Trouvez les commandes passées en mars 2024 (du 01/03 au 31/03)
        avec un montant TTC supérieur à 500€.

NIVEAU AVANCÉ :

  Ex7 : Trouvez les produits qui sont soit en rupture de stock (stock=0)
        soit dont le stock est inférieur au stock minimum ET dont le prix HT
        est supérieur à 200€. Triez par urgence (rupture d'abord).

  Ex8 : Filtrez les commandes qui ont été livrées dans les 5 jours
        suivant leur création (date_livraison - date_commande < 5 jours).

  Ex9 : Trouvez les clients qui se sont inscrits cette année ET
        qui ont renseigné leur téléphone ET leur date de naissance.
        Calculez leur âge et affichez-le.

14.11 CORRIGÉS DÉTAILLÉS
──────────────────────────

CORRIGÉ Ex1 :

  SELECT nom, prix_ht, stock
  FROM produits
  WHERE prix_ht > 500
    AND actif = TRUE
  ORDER BY prix_ht DESC;

  -- Résultats attendus :
  -- MacBook Pro 14" M3    | 1999.17 | 50
  -- Dell XPS 15 OLED      | 1499.17 | 35
  -- Lenovo ThinkPad X1    | 1249.17 | 42
  -- iPhone 15 Pro 256GB   |  999.17 | 120
  -- Samsung Galaxy S24    |  849.17 | 95
  -- Google Pixel 8 Pro    |  799.17 | 60

CORRIGÉ Ex4 (priorité des opérateurs) :

  -- [ATTENTION] Sans parenthèses = mauvaise interprétation
  -- Mauvais :
  SELECT * FROM produits WHERE actif = TRUE AND id_categorie = 2
    OR prix_ht > 800 AND stock > 30;
  -- Interprété : (actif ET cat=2) OR (prix>800 ET stock>30)

  -- Probablement voulu (selon l'énoncé) :
  SELECT nom, prix_ht, stock, id_categorie
  FROM produits
  WHERE actif = TRUE
    AND (
        id_categorie = 2
        OR (prix_ht > 800 AND stock > 30)
    )
  ORDER BY prix_ht;

CORRIGÉ Ex6 (plage de dates mars 2024) :

  -- Méthode 1 : BETWEEN (inclus les deux bornes)
  SELECT numero_commande, date_commande, montant_ttc
  FROM commandes
  WHERE date_commande BETWEEN '2024-03-01' AND '2024-03-31 23:59:59'
    AND montant_ttc > 500
  ORDER BY date_commande;

  -- Méthode 2 : >= ET < (plus précise pour les timestamps)
  SELECT numero_commande, date_commande, montant_ttc
  FROM commandes
  WHERE date_commande >= '2024-03-01'
    AND date_commande  < '2024-04-01'  -- exclusive = tout mars inclus
    AND montant_ttc > 500
  ORDER BY date_commande;

  -- Méthode 3 : DATE_TRUNC (la plus élégante)
  SELECT numero_commande, date_commande, montant_ttc
  FROM commandes
  WHERE DATE_TRUNC('month', date_commande) = '2024-03-01'
    AND montant_ttc > 500
  ORDER BY date_commande;

  -- [ATTENTION] PIÈGE : BETWEEN '2024-03-01' AND '2024-03-31' manque
  -- les commandes du 31 mars après minuit (timestamp !)
  -- Méthode 2 avec < '2024-04-01' est la plus sûre

CORRIGÉ Ex9 (filtre et calcul d'âge) :

  SELECT
      prenom || ' ' || nom            AS nom_complet,
      date_naissance,
      EXTRACT(YEAR FROM AGE(date_naissance))::INTEGER AS age,
      telephone,
      date_inscription
  FROM clients
  WHERE EXTRACT(YEAR FROM date_inscription) = EXTRACT(YEAR FROM NOW())
    AND telephone IS NOT NULL
    AND date_naissance IS NOT NULL
  ORDER BY age;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 15 — ORDER BY : TRIER LES RÉSULTATS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

15.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que ORDER BY ?
  ORDER BY est la clause qui trie les résultats d'une requête.
  Sans ORDER BY, l'ordre des résultats est INDÉFINI (le SGBD peut retourner
  les lignes dans n'importe quel ordre, souvent l'ordre d'insertion ou d'index).

Pourquoi ça existe ?
  -> Afficher les produits du moins cher au plus cher
  -> Classer les clients par date d'inscription
  -> Voir les commandes les plus récentes en premier
  -> Listes alphabétiques

Important : ne JAMAIS supposer un ordre sans ORDER BY.

15.2 SYNTAXE DE BASE
─────────────────────

-- Tri croissant (ASC = ascending, défaut)
SELECT nom, prix_ht FROM produits ORDER BY prix_ht;
-- Équivalent :
SELECT nom, prix_ht FROM produits ORDER BY prix_ht ASC;

-- Tri décroissant (DESC = descending)
SELECT nom, prix_ht FROM produits ORDER BY prix_ht DESC;

-- Tri alphabétique
SELECT prenom, nom FROM clients ORDER BY nom;
SELECT prenom, nom FROM clients ORDER BY nom ASC, prenom ASC;

-- Tri sur plusieurs colonnes
SELECT prenom, nom, segment, adresse_ville
FROM clients
ORDER BY segment ASC, nom ASC;
-- Trie d'abord par segment, puis par nom dans chaque segment

15.3 TRI SUR DES EXPRESSIONS
──────────────────────────────

-- Trier sur une expression calculée
SELECT nom, prix_ht, prix_ht * 1.2 AS prix_ttc
FROM produits
ORDER BY prix_ht * 1.2 DESC;   -- du plus cher TTC au moins cher

-- Trier sur un alias (ORDER BY s'exécute APRÈS SELECT -> les alias sont disponibles !)
SELECT nom, prix_ht * 1.2 AS prix_ttc
FROM produits
ORDER BY prix_ttc DESC;   -- [OK] alias disponible dans ORDER BY !

-- Trier sur numéro de colonne (position dans SELECT) - déconseillé
SELECT nom, prix_ht, stock FROM produits ORDER BY 2;  -- trie sur prix_ht
-- Déconseillé car si vous changez l'ordre des colonnes, le tri change !

15.4 TRI SUR DES CHAÎNES
──────────────────────────

-- Tri alphabétique standard (dépend de la locale/collation)
SELECT nom FROM clients ORDER BY nom;
-- Résultat : Bernard, Dupont, Laurent, Leroy, Martin...

-- Tri insensible à la casse
SELECT nom FROM clients ORDER BY LOWER(nom);

-- Tri sur longueur de chaîne
SELECT nom, LENGTH(nom) AS longueur FROM produits
ORDER BY longueur DESC;

-- Tri naturel (pour "Produit 1", "Produit 2", "Produit 10")
-- Sans tri naturel :
-- Produit 1, Produit 10, Produit 2, Produit 20 (ordre lexicographique)
-- Avec tri naturel (PostgreSQL extension pg_natural_sort_order ou REGEXP) :
-- Produit 1, Produit 2, Produit 10, Produit 20

15.5 GESTION DES NULL DANS LE TRI
───────────────────────────────────

-- Par défaut en PostgreSQL : NULL est considéré > toute valeur
-- -> NULLS LAST en ASC, NULLS FIRST en DESC

-- Trier les produits par prix, NULL en dernier
SELECT nom, prix_ht FROM produits ORDER BY prix_ht ASC NULLS LAST;

-- Trier les clients par date de naissance, NULL en premier
SELECT nom, date_naissance FROM clients ORDER BY date_naissance ASC NULLS FIRST;

-- MySQL : NULL est < toute valeur (opposé de PostgreSQL !)
-- Pour compatibilité : utiliser COALESCE pour contrôler le placement des NULL
SELECT nom, COALESCE(date_naissance, '9999-12-31') AS naissance
FROM clients
ORDER BY COALESCE(date_naissance, '9999-12-31');

15.6 TRI CONDITIONNEL AVEC CASE WHEN
───────────────────────────────────────

-- Trier par priorité custom
SELECT numero_commande, statut, date_commande
FROM commandes
ORDER BY
    CASE statut
        WHEN 'EN_ATTENTE'    THEN 1   -- priorité maximale
        WHEN 'CONFIRMEE'     THEN 2
        WHEN 'EN_PREPARATION'THEN 3
        WHEN 'EXPEDIEE'      THEN 4
        WHEN 'LIVREE'        THEN 5
        WHEN 'ANNULEE'       THEN 6
        ELSE                      7
    END ASC,
    date_commande ASC;   -- à priorité égale, plus ancienne d'abord

-- Tri mixte (certaines colonnes ASC, d'autres DESC)
SELECT
    prenom, nom, segment, date_inscription
FROM clients
ORDER BY
    segment ASC,           -- VIP avant PREMIUM avant STANDARD
    date_inscription DESC; -- dans chaque segment, plus récent d'abord

15.7 PERFORMANCE DU TRI
────────────────────────

  Le tri est coûteux sans index !
  -> Quand il n'y a pas d'index sur la colonne de tri, le SGBD trie en mémoire
  -> Pour de grandes tables, ce tri peut nécessiter de l'espace disque temporaire

  Optimisation :
  -> Créer un index sur les colonnes souvent utilisées dans ORDER BY
  -> Combiner avec LIMIT pour ne pas trier toute la table

  -- SANS LIMIT : trie 1 million de lignes, retourne tout
  SELECT * FROM commandes ORDER BY date_commande DESC;  -- lent !

  -- AVEC LIMIT : le SGBD peut optimiser (top-N sort)
  SELECT * FROM commandes ORDER BY date_commande DESC LIMIT 10;  -- rapide !

  -- Index sur date_commande (déjà créé dans notre ShopFlow) :
  -- CREATE INDEX idx_commandes_date ON commandes(date_commande);
  -- Avec cet index, ORDER BY date_commande est instantané !

15.8 EXERCICES PRATIQUES
─────────────────────────

NIVEAU FACILE :

  Ex1 : Listez tous les produits triés par prix HT décroissant.
        Affichez : référence, nom, prix HT, stock.

  Ex2 : Affichez les clients dans l'ordre alphabétique du nom.
        En cas de même nom, triez par prénom.

  Ex3 : Listez les commandes de la plus récente à la plus ancienne.

NIVEAU INTERMÉDIAIRE :

  Ex4 : Affichez les produits triés par :
        1. Alerte stock (RUPTURE d'abord, puis FAIBLE, puis OK)
        2. Puis par stock croissant dans chaque groupe

  Ex5 : Listez les clients avec leur segment, triés par segment
        dans cet ordre : VIP, PREMIUM, STANDARD, NOUVEAU
        (pas l'ordre alphabétique !).

NIVEAU AVANCÉ :

  Ex6 : Classez les produits selon leur "rentabilité supposée" :
        (prix_ht * stock) DESC — les produits qui représentent
        le plus de valeur en stock en premier.
        Affichez aussi cette "valeur_stock".

15.9 CORRIGÉS
──────────────

CORRIGÉ Ex4 (tri avec CASE priorité stock) :

  SELECT
      nom,
      stock,
      stock_min,
      CASE
          WHEN stock = 0          THEN 'RUPTURE'
          WHEN stock < stock_min  THEN 'FAIBLE'
          ELSE                         'OK'
      END AS alerte
  FROM produits
  ORDER BY
      CASE
          WHEN stock = 0          THEN 1
          WHEN stock < stock_min  THEN 2
          ELSE                         3
      END ASC,
      stock ASC;

CORRIGÉ Ex5 (ordre custom de segment) :

  SELECT prenom, nom, segment
  FROM clients
  ORDER BY
      CASE segment
          WHEN 'VIP'      THEN 1
          WHEN 'PREMIUM'  THEN 2
          WHEN 'STANDARD' THEN 3
          WHEN 'NOUVEAU'  THEN 4
          ELSE                 5
      END ASC,
      nom ASC;  -- alphabétique dans chaque segment

CORRIGÉ Ex6 (valeur en stock) :

  SELECT
      reference,
      nom,
      prix_ht,
      stock,
      ROUND(prix_ht * stock, 2)        AS valeur_stock,
      ROUND(prix_ht * 1.2 * stock, 2) AS valeur_stock_ttc
  FROM produits
  WHERE actif = TRUE
  ORDER BY valeur_stock DESC;

  -- Résultats attendus (approximatifs) :
  -- iPhone 15 Pro  | 999.17 | 120 | 119900.40
  -- MacBook Pro    |1999.17 |  50 |  99958.50
  -- Samsung Galaxy |  849.17|  95 |  80671.15
  -- ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 16 — LIMIT ET OFFSET : PAGINATION DES DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

16.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que LIMIT / OFFSET ?
  LIMIT définit le nombre maximum de lignes à retourner.
  OFFSET définit le nombre de lignes à sauter au début.
  Ensemble, ils permettent la PAGINATION.

Pourquoi ça existe ?
  -> Imaginez un site e-commerce avec 10 000 produits.
  -> Retourner tous les produits en une seule requête = des secondes de chargement
  -> Pagination : page 1 = produits 1-20, page 2 = produits 21-40, etc.

16.2 SYNTAXE
─────────────

-- LIMIT seul : retourner les N premières lignes
SELECT nom, prix_ht FROM produits LIMIT 5;

-- LIMIT + ORDER BY : les 5 produits les moins chers
SELECT nom, prix_ht FROM produits
ORDER BY prix_ht ASC
LIMIT 5;

-- LIMIT + OFFSET : pagination
-- Page 1 (lignes 1-5) :
SELECT nom FROM produits ORDER BY nom LIMIT 5 OFFSET 0;

-- Page 2 (lignes 6-10) :
SELECT nom FROM produits ORDER BY nom LIMIT 5 OFFSET 5;

-- Page 3 (lignes 11-15) :
SELECT nom FROM produits ORDER BY nom LIMIT 5 OFFSET 10;

-- Formule générale : OFFSET = (numero_page - 1) * taille_page

-- Standard SQL (PostgreSQL supporte aussi) :
SELECT nom FROM produits ORDER BY nom
FETCH FIRST 5 ROWS ONLY;
-- Avec offset :
SELECT nom FROM produits ORDER BY nom
OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY;

16.3 DIFFÉRENCES SELON LES SGBD
─────────────────────────────────

  PostgreSQL et MySQL :
  SELECT * FROM produits LIMIT 10 OFFSET 20;

  SQL Server :
  SELECT TOP 10 * FROM produits;
  -- Avec pagination :
  SELECT * FROM produits ORDER BY id
  OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

  Oracle (ancienne version) :
  SELECT * FROM produits WHERE ROWNUM <= 10;

  Oracle 12c+ et SQL standard :
  SELECT * FROM produits ORDER BY id
  FETCH FIRST 10 ROWS ONLY;

16.4 PAGINATION EN PRATIQUE (APPLICATIONS WEB)
────────────────────────────────────────────────

  Exemple : API /produits?page=3&limit=20

  -- Variable : page = 3, taille = 20
  -- OFFSET = (3 - 1) * 20 = 40

  SELECT
      id_produit,
      nom,
      prix_ht,
      ROUND(prix_ht * 1.2, 2) AS prix_ttc
  FROM produits
  WHERE actif = TRUE
  ORDER BY nom ASC
  LIMIT 20
  OFFSET 40;

  -- En PostgreSQL, vous pouvez utiliser des paramètres ($1, $2) :
  -- LIMIT $1 OFFSET $2
  -- (paramétrage côté application)

  -- Pour connaître le total (pour afficher "Page 3 sur 8") :
  SELECT COUNT(*) AS total FROM produits WHERE actif = TRUE;

  -- Optimisation : connaître si "il y a une page suivante" sans compter tout
  SELECT COUNT(*) FROM (
      SELECT 1 FROM produits WHERE actif = TRUE LIMIT 21 OFFSET 40
  ) sub;
  -- Si 21 résultats -> il y a une page suivante (on en affiche 20 + 1 de lookahead)
  -- Si < 21 résultats -> c'est la dernière page

16.5 PROBLÈME DE PERFORMANCE AVEC OFFSET ÉLEVÉ
─────────────────────────────────────────────────

  [ATTENTION] PROBLÈME CRITIQUE : OFFSET élevé = scan complet !

  -- Page 1 (OK, rapide) :
  SELECT * FROM produits ORDER BY id LIMIT 20 OFFSET 0;
  -- -> Lit 20 lignes

  -- Page 500 (LENT !) :
  SELECT * FROM produits ORDER BY id LIMIT 20 OFFSET 9980;
  -- -> Le SGBD lit 10 000 lignes, jette les 9980 premières, retourne 20
  -- Sur une table de millions de lignes = très lent !

  Solution : PAGINATION PAR CURSEUR (Keyset Pagination)

  -- Au lieu de OFFSET, mémoriser le dernier ID vu
  -- Page 1 :
  SELECT * FROM produits
  WHERE actif = TRUE
  ORDER BY id_produit ASC
  LIMIT 20;
  -- Dernière ligne : id_produit = 20

  -- Page 2 : continuer APRÈS le dernier id vu
  SELECT * FROM produits
  WHERE actif = TRUE
    AND id_produit > 20   -- <- curseur : après le dernier vu
  ORDER BY id_produit ASC
  LIMIT 20;
  -- Dernière ligne : id_produit = 40

  -- Page suivante : AND id_produit > 40
  -- Etc.

  Avantages :
  [OK] Toujours O(1) peu importe la page (utilise l'index sur id)
  [OK] Stable même si des lignes sont ajoutées/supprimées

  Inconvénients :
  [X] Ne permet pas de "sauter à la page 50"
  [X] L'ID de curseur doit être mémorisé côté client

16.6 TOP-N QUERIES (LES N MEILLEURS)
──────────────────────────────────────

  -- Top 3 des produits les plus chers
  SELECT nom, prix_ht
  FROM produits
  ORDER BY prix_ht DESC
  LIMIT 3;

  -- Top 5 des clients avec le plus de commandes
  -- (nécessite GROUP BY, vu en Partie 5 — exemple anticipé)
  SELECT id_client, COUNT(*) AS nb_commandes
  FROM commandes
  GROUP BY id_client
  ORDER BY nb_commandes DESC
  LIMIT 5;

  -- Dernier produit ajouté
  SELECT * FROM produits ORDER BY date_creation DESC LIMIT 1;

  -- Produits aléatoires (pour suggestions)
  SELECT nom, prix_ht FROM produits ORDER BY RANDOM() LIMIT 4;
  -- [ATTENTION] RANDOM() est très lent sur de grandes tables (scan complet + tri aléatoire)
  -- Pour de grandes tables : utiliser l'extension TABLESAMPLE

16.7 EXERCICES PRATIQUES
─────────────────────────

NIVEAU FACILE :

  Ex1 : Affichez les 5 produits les plus chers (TTC, arrondi).

  Ex2 : Affichez la page 2 des clients (5 clients par page),
        triés alphabétiquement par nom.

  Ex3 : Affichez le client le plus récemment inscrit (1 seul résultat).

NIVEAU INTERMÉDIAIRE :

  Ex4 : Simulez une pagination d'une API produits :
        - 4 produits par page
        - Affichez la page 2
        - Triez par prix HT croissant

  Ex5 : Affichez les 3 commandes avec le montant TTC le plus élevé.
        Affichez aussi le numéro de commande et la date.

NIVEAU AVANCÉ :

  Ex6 : Implémentez une pagination par curseur sur la table produits.
        - Page 1 : les 3 premiers produits (triés par id_produit)
        - Page 2 : les 3 suivants, en utilisant le dernier id_produit vu
        - Comparez les plans d'exécution avec EXPLAIN

16.8 CORRIGÉS DÉTAILLÉS
─────────────────────────

CORRIGÉ Ex1 (top 5 produits chers) :

  SELECT
      nom,
      prix_ht,
      ROUND(prix_ht * 1.2, 2) AS prix_ttc
  FROM produits
  WHERE actif = TRUE
  ORDER BY prix_ht DESC
  LIMIT 5;

  -- Résultats :
  -- MacBook Pro 14" M3  | 1999.17 | 2399.00
  -- Dell XPS 15 OLED    | 1499.17 | 1799.00
  -- Lenovo ThinkPad X1  | 1249.17 | 1499.00
  -- iPhone 15 Pro 256GB |  999.17 | 1199.00
  -- Samsung Galaxy S24  |  849.17 | 1019.00

CORRIGÉ Ex2 (pagination) :

  -- Page 1 (clients 1 à 5) :
  SELECT prenom, nom, email FROM clients ORDER BY nom ASC LIMIT 5 OFFSET 0;

  -- Page 2 (clients 6 à 10) :
  SELECT prenom, nom, email FROM clients ORDER BY nom ASC LIMIT 5 OFFSET 5;

  -- Résultats page 1 :
  -- Claire   | Bernard | claire.bernard@email.fr
  -- Alice    | Dupont  | alice.dupont@email.fr
  -- Isabelle | Laurent | isabelle.laurent@email.fr
  -- François | Leroy   | francois.leroy@email.fr
  -- Bob      | Martin  | bob.martin@email.fr

CORRIGÉ Ex6 (pagination par curseur) :

  -- Page 1 (les 3 premiers) :
  SELECT id_produit, nom, prix_ht
  FROM produits
  ORDER BY id_produit ASC
  LIMIT 3;
  -- Dernier id retourné : 3

  -- Page 2 (les 3 suivants après id=3) :
  SELECT id_produit, nom, prix_ht
  FROM produits
  WHERE id_produit > 3
  ORDER BY id_produit ASC
  LIMIT 3;
  -- Dernier id retourné : 6

  -- Comparaison avec EXPLAIN ANALYZE :

  EXPLAIN ANALYZE
  SELECT id_produit, nom FROM produits ORDER BY id_produit LIMIT 3 OFFSET 0;
  -- -> Index Scan using produits_pkey on produits (cost=0.14..X rows=3)
  -- Très rapide, utilise l'index

  EXPLAIN ANALYZE
  SELECT id_produit, nom FROM produits WHERE id_produit > 3
  ORDER BY id_produit LIMIT 3;
  -- -> Index Scan using produits_pkey on produits
  --   Filter: (id_produit > 3)
  -- Aussi rapide, utilise l'index avec condition

  -- Les deux sont rapides ici car notre table est petite.
  -- La différence se voit sur des tables de millions de lignes avec OFFSET 1000000

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 13 : SELECT complet — colonnes, expressions, alias, DISTINCT,
                   CASE WHEN, NULL, calculs arithmétiques et de dates.

  [OK] Chapitre 14 : WHERE — tous les opérateurs de comparaison, LIKE/ILIKE,
                   filtres sur dates, NULL, combinaisons AND/OR/NOT.

  [OK] Chapitre 15 : ORDER BY — tri ASC/DESC, multi-colonnes, CASE WHEN pour
                   ordre custom, gestion des NULL, tri sur expressions.

  [OK] Chapitre 16 : LIMIT/OFFSET — pagination classique, pagination par curseur
                   (keyset), TOP-N queries, différences SGBD.

POINTS CLÉS À RETENIR :
  -> Ordre d'exécution : FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
  -> Jamais SELECT * en production (sécurité, performance, fragilité)
  -> NULL ≠ vide ≠ 0 -> toujours tester avec IS NULL / IS NOT NULL
  -> AND a priorité sur OR -> TOUJOURS utiliser des parenthèses
  -> ORDER BY sans LIMIT = risque de performance sur grandes tables
  -> OFFSET élevé = scan complet -> préférer la pagination par curseur

REQUÊTES SQL MAÎTRISÉES :
  SELECT col1, col2, ROUND(expr, 2) AS alias
  FROM table
  WHERE condition1 AND (condition2 OR condition3)
  ORDER BY col1 DESC, col2 ASC NULLS LAST
  LIMIT n OFFSET m;

PROCHAINE PARTIE :
  Partie 4 — Filtrage avancé : AND/OR, BETWEEN, IN, LIKE
  (approfondissement des techniques de filtrage)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 3 — REQUÊTES SQL DE BASE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

================================================================================
GUIDE SQL MASTER — PARTIE 4
FILTRAGE AVANCÉ
Chapitres 17 à 20
================================================================================
Base de données utilisée : ShopFlow (e-commerce)
Niveau : Débutant -> Intermédiaire
================================================================================

TABLE DES MATIÈRES — PARTIE 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chapitre 17 — AND / OR : combinaison de conditions
Chapitre 18 — BETWEEN : plages de valeurs
Chapitre 19 — IN : listes de valeurs
Chapitre 20 — LIKE / ILIKE : recherche par motif


================================================================================
CHAPITRE 17 — AND / OR : COMBINAISON DE CONDITIONS
================================================================================

────────────────────────────────────────────────────────────────────────────────
17.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

Qu'est-ce que AND / OR ?
─────────────────────────
Dans la vie réelle, on filtre rarement sur une seule condition.
On dit : "je veux les commandes PASSÉES EN 2024 ET DONT LE MONTANT DÉPASSE 100€".
Ou : "je veux les clients VIP OU les clients ayant commandé plus de 5 fois".

SQL reproduit cette logique avec deux opérateurs booléens fondamentaux :

  AND -> toutes les conditions doivent être vraies simultanément
  OR  -> au moins une condition doit être vraie

Ces opérateurs permettent de combiner autant de conditions que nécessaire
dans une clause WHERE, créant des filtres complexes et précis.

Pourquoi ils existent ?
────────────────────────
Sans AND/OR, chaque requête ne pourrait filtrer que sur une seule dimension.
Un système réel a des centaines de colonnes et des millions de lignes.
Filtrer sur plusieurs critères simultanément est indispensable pour :
  - segmenter des clients
  - identifier des anomalies
  - générer des rapports ciblés
  - détecter des fraudes

Dans quels cas les utilise-t-on ?
──────────────────────────────────
  - E-commerce : "commandes EN ATTENTE et montant > 200€"
  - Banque : "transactions DÉBITÉES et montant > 10 000€ et pays != 'FR'"
  - RH : "employés du département IT ou Ventes, embauchés après 2020"
  - Analytics : "utilisateurs actifs et âge entre 25-35 ans ou premium"

────────────────────────────────────────────────────────────────────────────────
17.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

La logique booléenne en SQL
────────────────────────────
SQL implémente la logique de George Boole (1854).
Chaque condition dans WHERE retourne une valeur de vérité :

  TRUE   -> la ligne satisfait la condition
  FALSE  -> la ligne ne satisfait pas la condition
  NULL   -> la valeur est inconnue (cas spécial, abordé plus bas)

Table de vérité — AND
─────────────────────
  Condition A | Condition B | A AND B
  ────────────┼─────────────┼────────
  TRUE        | TRUE        | TRUE     <- ligne retournée
  TRUE        | FALSE       | FALSE    <- ligne exclue
  FALSE       | TRUE        | FALSE    <- ligne exclue
  FALSE       | FALSE       | FALSE    <- ligne exclue
  TRUE        | NULL        | NULL     <- ligne exclue (NULL = inconnu)
  NULL        | TRUE        | NULL     <- ligne exclue
  NULL        | NULL        | NULL     <- ligne exclue

Règle AND : TOUTES les conditions doivent être TRUE.
Dès qu'une condition est FALSE ou NULL -> résultat FALSE ou NULL.

Table de vérité — OR
────────────────────
  Condition A | Condition B | A OR B
  ────────────┼─────────────┼────────
  TRUE        | TRUE        | TRUE     <- ligne retournée
  TRUE        | FALSE       | TRUE     <- ligne retournée
  FALSE       | TRUE        | TRUE     <- ligne retournée
  FALSE       | FALSE       | FALSE    <- ligne exclue
  TRUE        | NULL        | TRUE     <- ligne retournée (TRUE domine)
  NULL        | TRUE        | TRUE     <- ligne retournée
  NULL        | NULL        | NULL     <- ligne exclue

Règle OR : AU MOINS UNE condition doit être TRUE.
Si une condition est TRUE, la ligne est toujours retournée.

Priorité des opérateurs booléens
─────────────────────────────────
SQL évalue les opérateurs dans cet ordre (du plus prioritaire au moins) :
  1. NOT       (négation)
  2. AND       (conjonction)
  3. OR        (disjonction)

[ATTENTION] PIÈGE CRITIQUE : AND est prioritaire sur OR !

Exemple qui trompe les débutants :

  WHERE statut = 'payée' OR statut = 'expédiée' AND montant > 100

Ce que le débutant croit lire :
  (statut = 'payée' OR statut = 'expédiée') AND montant > 100

Ce que SQL exécute réellement :
  statut = 'payée' OR (statut = 'expédiée' AND montant > 100)

Résultat : toutes les commandes 'payée' sont retournées, peu importe le montant !
Ce bug silencieux peut fausser tout un rapport.

SOLUTION : toujours utiliser des parenthèses explicites.

────────────────────────────────────────────────────────────────────────────────
17.3 COMMENT ÇA FONCTIONNE INTERNEMENT ?
────────────────────────────────────────────────────────────────────────────────

Traitement par le moteur SQL :

  Client envoie la requête
          v
  SQL Parser — analyse syntaxique
    -> vérifie AND/OR/NOT syntaxiquement corrects
    -> construit un arbre syntaxique (AST)
          v
  Query Optimizer — optimisation logique
    -> réorganise les conditions pour évaluer les plus sélectives en premier
    -> court-circuit : si AND avec condition FALSE -> arrête sans évaluer le reste
    -> court-circuit : si OR avec condition TRUE -> arrête sans évaluer le reste
          v
  Execution Engine — exécution physique
    -> parcourt les lignes via index ou full scan
    -> applique le filtre booléen sur chaque ligne
          v
  Storage Engine — accès données
    -> lit les pages mémoire/disque nécessaires
          v
  Result set retourné

Optimisation AND — short-circuit evaluation
────────────────────────────────────────────
PostgreSQL évalue les conditions AND de gauche à droite.
Si la première condition est FALSE -> la ligne est immédiatement rejetée.
Les conditions suivantes ne sont PAS évaluées pour cette ligne.

Astuce professionnelle : placer la condition la plus sélective EN PREMIER
dans un AND pour éliminer rapidement les lignes non pertinentes.

  -- Bon : condition très sélective d'abord
  WHERE client_id = 42 AND statut = 'payée'

  -- Moins optimal : condition peu sélective d'abord
  WHERE statut = 'payée' AND client_id = 42

(Dans cet exemple précis l'écart est faible, mais sur millions de lignes
avec des conditions coûteuses comme des regex, l'ordre compte)

────────────────────────────────────────────────────────────────────────────────
17.4 POURQUOI UTILISER AND / OR ?
────────────────────────────────────────────────────────────────────────────────

Performance
────────────
  - Filtrer en SQL (côté base) >>> filtrer en application (côté code)
  - La base retourne MOINS de données -> moins de bande passante consommée
  - Le moteur SQL utilise des index optimisés que le code applicatif n'a pas

Scalabilité
────────────
  - Fonctionne identiquement sur 1 000 ou 1 000 000 000 de lignes
  - Le moteur SQL parallélise automatiquement si nécessaire

Clarté
───────
  - Une condition multi-critères dans SQL est plus lisible et maintenable
    qu'une boucle applicative avec 15 if/else

────────────────────────────────────────────────────────────────────────────────
17.5 QUAND UTILISER AND / OR ?
────────────────────────────────────────────────────────────────────────────────

Cas AND — toutes les conditions doivent être remplies :
  - Commandes impayées ET récentes -> relances automatiques
  - Produits en rupture ET actifs -> réapprovisionnement
  - Clients premium ET inactifs 90j -> campagne de rétention
  - Employés en CDI ET ancienneté > 5 ans -> prime

Cas OR — au moins une condition doit être remplie :
  - Statut 'en_attente' OU 'en_transit' -> suivi logistique
  - Catégorie 'électronique' OU 'informatique' -> rapport tech
  - Ville 'Paris' OU 'Lyon' OU 'Marseille' -> campagne France
  - Client VIP OU abonné premium -> accès prioritaire

Cas NOT — exclure une condition :
  - Commandes qui NE SONT PAS annulées
  - Produits qui NE SONT PAS discontinués
  - Clients qui N'ONT PAS fourni leur email

────────────────────────────────────────────────────────────────────────────────
17.6 IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

Rappel schéma ShopFlow pertinent :
────────────────────────────────────
  commandes (
    commande_id      SERIAL PRIMARY KEY,
    client_id        INT REFERENCES clients(client_id),
    employe_id       INT REFERENCES employes(employe_id),
    date_commande    TIMESTAMPTZ,
    statut           VARCHAR(20),    -- 'en_attente', 'confirmée', 'expédiée', 'livrée', 'annulée'
    montant_total    DECIMAL(10,2),
    adresse_livraison TEXT
  )

  produits (
    produit_id       SERIAL PRIMARY KEY,
    nom              VARCHAR(200),
    prix             DECIMAL(10,2),
    stock            INT,
    actif            BOOLEAN,
    categorie_id     INT
  )

  clients (
    client_id        SERIAL PRIMARY KEY,
    nom              VARCHAR(100),
    email            VARCHAR(150),
    segment          VARCHAR(20),    -- 'bronze', 'silver', 'gold', 'vip'
    ville            VARCHAR(100),
    date_inscription TIMESTAMPTZ
  )

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 1 — AND simple : deux conditions simultanées
────────────────────────────────────────────────────────────────────────────────

Objectif : trouver les commandes livrées dont le montant dépasse 200€.

```sql
SELECT
    commande_id,
    client_id,
    date_commande,
    statut,
    montant_total
FROM commandes
WHERE statut = 'livrée'          -- condition 1 : statut doit être 'livrée'
  AND montant_total > 200.00;    -- condition 2 : montant doit dépasser 200€
```

Explication ligne par ligne :
  SELECT commande_id, client_id, date_commande, statut, montant_total
    -> on sélectionne les 5 colonnes utiles (pas de SELECT *)

  FROM commandes
    -> source des données : table commandes

  WHERE statut = 'livrée'
    -> première condition : la colonne statut doit contenir exactement 'livrée'
    -> sensible à la casse en PostgreSQL ('Livrée' ne matcherait pas)

  AND montant_total > 200.00
    -> deuxième condition : montant strictement supérieur à 200€
    -> les deux conditions doivent être vraies simultanément

Résultat : seules les lignes où statut = 'livrée' ET montant > 200 apparaissent.

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 2 — AND multiple : trois conditions
────────────────────────────────────────────────────────────────────────────────

Objectif : commandes confirmées, passées en 2024, montant entre 50 et 500€.

```sql
SELECT
    commande_id,
    date_commande,
    montant_total,
    statut
FROM commandes
WHERE statut = 'confirmée'
  AND date_commande >= '2024-01-01'
  AND date_commande < '2025-01-01'
  AND montant_total BETWEEN 50.00 AND 500.00;
```

Note : BETWEEN est abordé au chapitre 18.
Ici, l'équivalent explicite :
  AND montant_total >= 50.00
  AND montant_total <= 500.00

Chaque AND ajoute un filtre supplémentaire.
Plus il y a de AND, plus le résultat est restrictif.

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 3 — OR simple : l'une ou l'autre condition
────────────────────────────────────────────────────────────────────────────────

Objectif : commandes expédiées OU en attente (toutes les commandes non finalisées).

```sql
SELECT
    commande_id,
    client_id,
    date_commande,
    statut,
    montant_total
FROM commandes
WHERE statut = 'expédiée'
   OR statut = 'en_attente';
```

Explication :
  WHERE statut = 'expédiée'
    -> si cette condition est vraie -> ligne incluse

  OR statut = 'en_attente'
    -> OU si cette condition est vraie -> ligne incluse

  Si les deux sont vraies pour une ligne (impossible ici car statut est unique)
    -> la ligne serait quand même incluse une seule fois

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 4 — Mélange AND + OR AVEC PARENTHÈSES (crucial !)
────────────────────────────────────────────────────────────────────────────────

Objectif : commandes livrées OU expédiées, mais uniquement si montant > 100€.

VERSION INCORRECTE (bug de priorité) :
```sql
-- [ATTENTION] INCORRECT — ne fait PAS ce qu'on croit
SELECT commande_id, statut, montant_total
FROM commandes
WHERE statut = 'livrée' OR statut = 'expédiée' AND montant_total > 100;
```

SQL interprète ceci comme :
  statut = 'livrée'  OR  (statut = 'expédiée' AND montant_total > 100)

Résultat : TOUTES les commandes livrées passent (même celles à 5€) !
ET seulement les commandes expédiées > 100€.

VERSION CORRECTE (avec parenthèses) :
```sql
-- [OK] CORRECT — parenthèses explicites
SELECT commande_id, statut, montant_total
FROM commandes
WHERE (statut = 'livrée' OR statut = 'expédiée')
  AND montant_total > 100.00;
```

SQL interprète maintenant :
  (statut = 'livrée' OR statut = 'expédiée') -> résout le groupe OR en premier
  AND montant_total > 100.00                  -> applique le filtre de montant ensuite

Résultat CORRECT : seulement commandes livrées OU expédiées ET montant > 100.

RÈGLE D'OR : Dès qu'on mélange AND et OR, mettre des parenthèses.
C'est la règle la plus importante de ce chapitre.

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 5 — NOT : inverser une condition
────────────────────────────────────────────────────────────────────────────────

Objectif : toutes les commandes qui NE sont PAS annulées.

```sql
SELECT
    commande_id,
    client_id,
    statut,
    montant_total
FROM commandes
WHERE NOT (statut = 'annulée');
```

Équivalent avec != (différent de) :
```sql
WHERE statut != 'annulée'
-- ou
WHERE statut <> 'annulée'    -- syntaxe SQL standard (ISO)
```

Les trois formes sont équivalentes en PostgreSQL.
Préférence professionnelle : utiliser <> (norme ISO) ou != (plus lisible).

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 6 — Requête complexe : segmentation clients actifs premium
────────────────────────────────────────────────────────────────────────────────

Objectif : clients gold ou VIP, inscrits depuis plus de 6 mois,
           situés à Paris ou Lyon.

```sql
SELECT
    client_id,
    nom,
    email,
    segment,
    ville,
    date_inscription
FROM clients
WHERE (segment = 'gold' OR segment = 'vip')         -- segment premium
  AND date_inscription < NOW() - INTERVAL '6 months' -- inscrits il y a > 6 mois
  AND (ville = 'Paris' OR ville = 'Lyon')            -- dans ces deux villes
ORDER BY segment DESC, nom ASC;
```

Décomposition des groupes de conditions :
  Groupe 1 : (segment = 'gold' OR segment = 'vip')
    -> parenthèses pour grouper le OR

  Groupe 2 : date_inscription < NOW() - INTERVAL '6 months'
    -> NOW() retourne la date/heure actuelle
    -> INTERVAL '6 months' soustrait 6 mois
    -> si date_inscription est avant il y a 6 mois -> client ancien

  Groupe 3 : (ville = 'Paris' OR ville = 'Lyon')
    -> parenthèses pour grouper le deuxième OR

  Liaison : AND relie les 3 groupes
    -> TOUS les groupes doivent être vrais simultanément

  ORDER BY segment DESC, nom ASC
    -> tri par segment décroissant (vip avant gold) puis nom alphabétique

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 7 — Cas avec NULL : comportement à connaître
────────────────────────────────────────────────────────────────────────────────

Supposons que certains clients n'ont pas de ville renseignée (NULL).

```sql
-- [ATTENTION] Ceci NE retourne PAS les clients sans ville
SELECT client_id, nom, ville
FROM clients
WHERE ville = 'Paris' OR ville = NULL;  -- INCORRECT pour NULL !
```

Pour tester NULL, il faut utiliser IS NULL ou IS NOT NULL :
```sql
-- [OK] Correct : Paris OU ville non renseignée
SELECT client_id, nom, ville
FROM clients
WHERE ville = 'Paris'
   OR ville IS NULL;
```

Pourquoi ? Parce que NULL = NULL retourne NULL (pas TRUE).
NULL n'est pas une valeur, c'est l'absence de valeur.
La seule façon de tester NULL est IS NULL / IS NOT NULL.

────────────────────────────────────────────────────────────────────────────────
EXEMPLE 8 — NOT avec NULL : le piège classique
────────────────────────────────────────────────────────────────────────────────

```sql
-- On croit récupérer tout sauf Paris
SELECT client_id, nom, ville
FROM clients
WHERE ville != 'Paris';  -- [ATTENTION] Exclut aussi les NULL !
```

PostgreSQL n'inclut pas les lignes où ville IS NULL dans ce résultat.
La comparaison NULL != 'Paris' retourne NULL (pas TRUE), donc la ligne est exclue.

Pour inclure les lignes sans ville :
```sql
SELECT client_id, nom, ville
FROM clients
WHERE ville != 'Paris'
   OR ville IS NULL;  -- inclure explicitement les NULL
```

────────────────────────────────────────────────────────────────────────────────
17.7 BONNES PRATIQUES PROFESSIONNELLES
────────────────────────────────────────────────────────────────────────────────

1. TOUJOURS utiliser des parenthèses quand on mélange AND et OR
   ──────────────────────────────────────────────────────────────
   WHERE (a OR b) AND (c OR d)
   Même si vous connaissez la priorité par cœur, les parenthèses
   rendent le code lisible pour les autres développeurs.

2. Indentation cohérente des conditions
   ──────────────────────────────────────
   Convention professionnelle :
   ```sql
   WHERE condition_principale
     AND condition_secondaire
     AND (
           condition_or_1
        OR condition_or_2
         )
   ```
   Aligner AND/OR visuellement pour lire la logique d'un coup d'œil.

3. Placer la condition la plus sélective en premier dans un AND
   ─────────────────────────────────────────────────────────────
   Si client_id = 42 filtre à 1 ligne sur 1 million,
   et statut = 'actif' filtre à 50%, mettre client_id en premier :
   WHERE client_id = 42 AND statut = 'actif'
   Le moteur s'arrête dès que client_id = 42 est faux.

4. Indexer les colonnes fréquemment utilisées dans WHERE
   ───────────────────────────────────────────────────────
   Si on filtre souvent sur statut, ville, segment -> créer des index :
   CREATE INDEX idx_commandes_statut ON commandes(statut);
   CREATE INDEX idx_clients_segment ON clients(segment);
   CREATE INDEX idx_clients_ville ON clients(ville);

5. Éviter les conditions redondantes
   ────────────────────────────────────
   Mauvais :
   WHERE montant_total > 0 AND montant_total > 100
   Bon (simplifié) :
   WHERE montant_total > 100

6. Nommer les sous-conditions avec des CTEs pour lisibilité
   ──────────────────────────────────────────────────────────
   Pour des conditions très complexes, utiliser des CTEs (Chapitre 50) :
   ```sql
   WITH clients_premium AS (
     SELECT client_id FROM clients
     WHERE segment IN ('gold', 'vip')
   ),
   commandes_recentes AS (
     SELECT commande_id, client_id FROM commandes
     WHERE date_commande > NOW() - INTERVAL '30 days'
   )
   SELECT ...
   ```

────────────────────────────────────────────────────────────────────────────────
17.8 ERREURS FRÉQUENTES ET PIÈGES
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — Oublier les parenthèses avec OR
───────────────────────────────────────────
```sql
-- Faux
WHERE statut = 'livrée' OR statut = 'expédiée' AND montant > 100

-- Correct
WHERE (statut = 'livrée' OR statut = 'expédiée') AND montant > 100
```

ERREUR 2 — Tester NULL avec = ou !=
────────────────────────────────────
```sql
-- Faux (ne retourne jamais rien)
WHERE email = NULL
WHERE email != NULL

-- Correct
WHERE email IS NULL
WHERE email IS NOT NULL
```

ERREUR 3 — Conditions contradictoires avec AND
───────────────────────────────────────────────
```sql
-- Impossible (statut ne peut pas être les deux à la fois)
WHERE statut = 'livrée' AND statut = 'annulée'
-- Résultat : 0 lignes toujours
```

ERREUR 4 — Conditions redondantes avec OR
──────────────────────────────────────────
```sql
-- montant > 50 englobe déjà montant > 100, le deuxième OR est inutile
WHERE montant > 100 OR montant > 50
-- Équivaut à :
WHERE montant > 50
```

ERREUR 5 — Oublier que OR est inclusif
────────────────────────────────────────
OR en SQL est inclusif : TRUE OR TRUE = TRUE (les deux peuvent être vrais).
Si une ligne satisfait les deux conditions d'un OR, elle apparaît une seule fois.
(Pas de doublon grâce à la sémantique ensembliste de SQL)

ERREUR 6 — Confondre NOT avec !=
──────────────────────────────────
```sql
NOT statut = 'annulée'   -- correct mais inhabituel
statut != 'annulée'      -- plus courant
statut <> 'annulée'      -- norme ISO

-- Attention : NOT (a AND b) ≡ (NOT a) OR (NOT b)  — Loi De Morgan
-- NOT (a OR b)  ≡ (NOT a) AND (NOT b)
```

────────────────────────────────────────────────────────────────────────────────
17.9 EXERCICES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

EXERCICES FACILES
──────────────────

EX17.1 — Commandes confirmées ou livrées
Sélectionner commande_id, statut, montant_total des commandes
dont le statut est 'confirmée' OU 'livrée'.

EX17.2 — Produits actifs et pas chers
Sélectionner nom, prix, stock des produits
dont le prix est inférieur à 50€ ET qui sont actifs (actif = TRUE).

EX17.3 — Clients de Paris
Sélectionner nom, email, ville des clients
qui NE viennent PAS de Paris.

EXERCICES INTERMÉDIAIRES
─────────────────────────

EX17.4 — Produits à risque
Sélectionner nom, prix, stock des produits
actifs ET dont le stock est inférieur à 5 unités OU dont le prix est > 500€.
Trier par stock croissant.

EX17.5 — Commandes récentes importantes
Sélectionner commande_id, client_id, date_commande, montant_total
des commandes passées après le 1er janvier 2024
ET dont le montant dépasse 150€
ET dont le statut n'est pas 'annulée'.

EX17.6 — Clients actifs premium
Sélectionner client_id, nom, segment, ville
des clients de segment 'gold' ou 'vip'
qui habitent à Paris OU à Lyon.

EXERCICES AVANCÉS
──────────────────

EX17.7 — Analyse multi-critères produits
Sélectionner produit_id, nom, prix, stock, actif
des produits qui sont :
  (actifs ET stock > 0 ET prix < 100)
  OU
  (inactifs ET stock = 0)
Trier par actif DESC, prix ASC.

EX17.8 — Commandes à surveiller
Écrire une requête qui retourne les commandes "suspectes" :
soit en attente depuis plus de 7 jours,
soit avec un montant > 1000€ ET statut != 'livrée',
soit avec un montant négatif ou nul (anomalie de données).
Afficher commande_id, client_id, statut, montant_total, date_commande.

EX17.9 — Segmentation comportementale (sous-requête anticipée)
Lister les clients dont l'email contient 'gmail' OU 'yahoo',
inscrits avant le 1er janvier 2023,
ET dont le segment n'est ni 'bronze' ni NULL.
Trier par date_inscription ASC.

────────────────────────────────────────────────────────────────────────────────
17.10 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────────────────────────────────────────────

CORRIGÉ EX17.1
───────────────
```sql
SELECT
    commande_id,
    statut,
    montant_total
FROM commandes
WHERE statut = 'confirmée'
   OR statut = 'livrée';
```
Raisonnement : OR simple entre deux valeurs de statut.
Les deux conditions se rapportent à la même colonne -> candidat parfait pour IN.
Alternative équivalente :
WHERE statut IN ('confirmée', 'livrée')  -- cf. Chapitre 19

CORRIGÉ EX17.2
───────────────
```sql
SELECT
    nom,
    prix,
    stock
FROM produits
WHERE prix < 50.00
  AND actif = TRUE;
```
Raisonnement : deux conditions simultanées avec AND.
La colonne actif est de type BOOLEAN.
En PostgreSQL, on peut aussi écrire :
WHERE prix < 50.00 AND actif   -- 'actif' seul est équivalent à 'actif = TRUE'

CORRIGÉ EX17.3
───────────────
```sql
SELECT
    nom,
    email,
    ville
FROM clients
WHERE ville <> 'Paris'
   OR ville IS NULL;
```
Raisonnement : on veut tout sauf Paris.
  - ville <> 'Paris' exclut Paris mais exclut aussi les NULL (piège !)
  - On ajoute OR ville IS NULL pour inclure les clients sans ville

CORRIGÉ EX17.4
───────────────
```sql
SELECT
    nom,
    prix,
    stock
FROM produits
WHERE actif = TRUE
  AND (stock < 5 OR prix > 500.00)
ORDER BY stock ASC;
```
Raisonnement :
  - actif = TRUE : condition obligatoire pour tous les produits
  - (stock < 5 OR prix > 500.00) : l'une OU l'autre de ces conditions
  - Les parenthèses sont INDISPENSABLES pour grouper le OR
  - Sans parenthèses : actif = TRUE AND stock < 5 -> puis OR prix > 500
    -> inclut les produits inactifs avec prix > 500 (faux !)

CORRIGÉ EX17.5
───────────────
```sql
SELECT
    commande_id,
    client_id,
    date_commande,
    montant_total
FROM commandes
WHERE date_commande >= '2024-01-01'
  AND montant_total > 150.00
  AND statut <> 'annulée'
ORDER BY date_commande DESC;
```
Raisonnement : trois conditions AND, toutes obligatoires.
Pas de mélange AND/OR -> pas besoin de parenthèses complexes.
Le ORDER BY date_commande DESC n'était pas demandé mais améliore la lisibilité.

CORRIGÉ EX17.6
───────────────
```sql
SELECT
    client_id,
    nom,
    segment,
    ville
FROM clients
WHERE (segment = 'gold' OR segment = 'vip')
  AND (ville = 'Paris' OR ville = 'Lyon');
```
Raisonnement :
  Deux groupes de conditions liés par AND :
  - Groupe 1 : (segment = 'gold' OR segment = 'vip') -> segment premium
  - Groupe 2 : (ville = 'Paris' OR ville = 'Lyon') -> l'une des deux villes
  Les parenthèses sont OBLIGATOIRES dans les deux groupes.

CORRIGÉ EX17.7
───────────────
```sql
SELECT
    produit_id,
    nom,
    prix,
    stock,
    actif
FROM produits
WHERE (actif = TRUE AND stock > 0 AND prix < 100.00)
   OR (actif = FALSE AND stock = 0)
ORDER BY actif DESC, prix ASC;
```
Raisonnement :
  Deux grands scénarios séparés par OR :
  Scénario A : produit actif, en stock, prix abordable
  Scénario B : produit inactif et épuisé (logique de nettoyage)
  Chaque scénario est entouré de parenthèses.
  ORDER BY : actif = TRUE (1) avant actif = FALSE (0) en DESC,
             puis prix croissant au sein de chaque groupe.

CORRIGÉ EX17.8
───────────────
```sql
SELECT
    commande_id,
    client_id,
    statut,
    montant_total,
    date_commande
FROM commandes
WHERE (statut = 'en_attente'
       AND date_commande < NOW() - INTERVAL '7 days')
   OR (montant_total > 1000.00 AND statut <> 'livrée')
   OR (montant_total <= 0)
ORDER BY date_commande ASC;
```
Raisonnement :
  Trois scénarios suspects, chacun entre parenthèses, reliés par OR.
  Scénario 1 : en attente depuis + de 7 jours
    -> date_commande < date actuelle - 7 jours (plus vieux = date plus petite)
  Scénario 2 : montant élevé et pas encore livré
    -> potentiel problème logistique ou fraude
  Scénario 3 : montant nul ou négatif
    -> anomalie de saisie

CORRIGÉ EX17.9
───────────────
```sql
SELECT
    client_id,
    nom,
    email,
    segment,
    date_inscription
FROM clients
WHERE (email LIKE '%gmail%' OR email LIKE '%yahoo%')
  AND date_inscription < '2023-01-01'
  AND segment IS NOT NULL
  AND segment <> 'bronze'
ORDER BY date_inscription ASC;
```
Raisonnement :
  - LIKE '%gmail%' : contient 'gmail' (LIKE sera détaillé au Chapitre 20)
  - date_inscription < '2023-01-01' : inscrits AVANT le 1er janvier 2023
  - segment IS NOT NULL : exclut les NULL (ne pas utiliser segment != NULL)
  - segment <> 'bronze' : exclut le segment bronze
  Ordre des conditions : les deux premières utilisent des index potentiels,
  les conditions IS NOT NULL / <> sont peu sélectives et placées en dernier.


================================================================================
CHAPITRE 18 — BETWEEN : PLAGES DE VALEURS
================================================================================

────────────────────────────────────────────────────────────────────────────────
18.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

Qu'est-ce que BETWEEN ?
────────────────────────
BETWEEN est une syntaxe raccourcie pour exprimer qu'une valeur
se trouve dans un intervalle fermé [min, max].

Sans BETWEEN :
  WHERE prix >= 10.00 AND prix <= 100.00

Avec BETWEEN :
  WHERE prix BETWEEN 10.00 AND 100.00

Les deux formes sont strictement équivalentes.
BETWEEN améliore la lisibilité et réduit les risques d'erreur.

Pourquoi il existe ?
────────────────────
Les plages sont omniprésentes dans les données :
  - plage de prix
  - plage de dates
  - plage d'âges
  - plage de quantités
  - plage de notes

BETWEEN permet de les exprimer naturellement.

Dans quels cas l'utilise-t-on ?
────────────────────────────────
  - Rapports mensuels : commandes entre le 1er et le 30 du mois
  - Tranches de prix : produits entre 20€ et 50€
  - Scores : avis entre 3 et 5 étoiles
  - Quantités : stocks entre 10 et 100 unités
  - IDs : commande_id entre 1001 et 2000

────────────────────────────────────────────────────────────────────────────────
18.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

Syntaxe officielle
───────────────────
  expression BETWEEN valeur_min AND valeur_max

C'est strictement équivalent à :
  expression >= valeur_min AND expression <= valeur_max

IMPORTANT : BETWEEN est INCLUSIF des deux bornes.
  BETWEEN 10 AND 20 -> inclut 10 et inclut 20

Variante NOT BETWEEN
─────────────────────
  expression NOT BETWEEN valeur_min AND valeur_max

Équivalent à :
  expression < valeur_min OR expression > valeur_max

Retourne les valeurs EN DEHORS de l'intervalle.

Types supportés
────────────────
BETWEEN fonctionne avec tous les types ordonnables :
  - Numériques : INT, DECIMAL, FLOAT
  - Dates et timestamps : DATE, TIMESTAMP, TIMESTAMPTZ
  - Chaînes de caractères : VARCHAR, TEXT (ordre alphabétique)
  - Tout type avec un ordre défini

BETWEEN avec des dates
───────────────────────
Cas très fréquent :
  WHERE date_commande BETWEEN '2024-01-01' AND '2024-12-31'

[ATTENTION] PIÈGE avec TIMESTAMP :
  '2024-12-31' est interprété comme '2024-12-31 00:00:00'
  Donc les commandes du 31 décembre à 14h00 sont EXCLUES !

Solution pour un mois complet ou une année complète :
  -- Recommandé avec TIMESTAMP
  WHERE date_commande >= '2024-01-01'
    AND date_commande < '2025-01-01'
  -- < 2025-01-01 inclut tout le 31 décembre jusqu'à 23:59:59.999...

BETWEEN avec des chaînes
────────────────────────
  WHERE nom BETWEEN 'A' AND 'M'
  -> retourne les noms dont la première lettre est entre A et M (inclusif)
  -> utilise l'ordre alphabétique (collation de la base)

Moins courant que pour les numériques mais possible.

────────────────────────────────────────────────────────────────────────────────
18.3 COMMENT ÇA FONCTIONNE INTERNEMENT ?
────────────────────────────────────────────────────────────────────────────────

Le moteur PostgreSQL transforme BETWEEN en deux comparaisons :

  Requête SQL :
    WHERE prix BETWEEN 50 AND 200

  Transformée par le parser :
    WHERE prix >= 50 AND prix <= 200

  Optimisation :
    Si un index B-Tree existe sur prix -> range scan (accès rapide)
    Le moteur détermine le bloc de départ (>= 50) et parcourt jusqu'au (>= 200)
    Beaucoup plus rapide qu'un full table scan

  EXPLAIN ANALYZE montre :
    Index Scan using idx_produits_prix on produits
      Index Cond: ((prix >= 50) AND (prix <= 200))

Un index B-Tree sur la colonne utilisée avec BETWEEN est très efficace
car les B-Trees sont optimisés pour les range queries.

────────────────────────────────────────────────────────────────────────────────
18.4 IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

EXEMPLE 1 — BETWEEN numérique : plage de prix
──────────────────────────────────────────────

```sql
SELECT
    produit_id,
    nom,
    prix,
    stock
FROM produits
WHERE prix BETWEEN 20.00 AND 150.00
ORDER BY prix ASC;
```

Explication :
  WHERE prix BETWEEN 20.00 AND 150.00
    -> inclut tous les produits dont le prix est entre 20€ et 150€
    -> 20.00 et 150.00 sont inclus (bornes fermées)
    -> équivalent à : WHERE prix >= 20.00 AND prix <= 150.00

ORDER BY prix ASC
  -> les moins chers en premier, utile pour un catalogue

EXEMPLE 2 — BETWEEN avec des dates : rapport mensuel
──────────────────────────────────────────────────────

```sql
SELECT
    commande_id,
    client_id,
    date_commande,
    montant_total,
    statut
FROM commandes
WHERE date_commande BETWEEN '2024-06-01' AND '2024-06-30 23:59:59'
ORDER BY date_commande ASC;
```

Pourquoi '2024-06-30 23:59:59' et pas '2024-06-30' ?
  - date_commande est de type TIMESTAMPTZ
  - '2024-06-30' équivaut à '2024-06-30 00:00:00'
  - Une commande à 15:30 le 30 juin serait exclue !
  - On spécifie 23:59:59 pour inclure toute la dernière journée

Alternative plus robuste (recommandée) :
```sql
WHERE date_commande >= '2024-06-01'
  AND date_commande < '2024-07-01'
```
Cette forme n'a aucun problème de précision temporelle.

EXEMPLE 3 — BETWEEN sur des notes d'avis
─────────────────────────────────────────

```sql
SELECT
    avis_id,
    produit_id,
    client_id,
    note,
    commentaire
FROM avis
WHERE note BETWEEN 3 AND 5
ORDER BY note DESC, avis_id ASC;
```

Explication :
  note BETWEEN 3 AND 5
    -> notes de 3, 4 et 5 (inclusif des deux bornes)
    -> exclut les notes de 1 et 2 (très mauvais avis)
    -> utile pour afficher les avis positifs sur un produit

EXEMPLE 4 — NOT BETWEEN : exclure une plage
─────────────────────────────────────────────

Objectif : tous les produits sauf ceux dans la gamme de prix 50-100€.

```sql
SELECT
    nom,
    prix,
    stock
FROM produits
WHERE prix NOT BETWEEN 50.00 AND 100.00
ORDER BY prix ASC;
```

Équivalent à :
  WHERE prix < 50.00 OR prix > 100.00

Retourne : produits < 50€ ET produits > 100€.

EXEMPLE 5 — BETWEEN dans une requête complexe
───────────────────────────────────────────────

Objectif : commandes d'un trimestre spécifique, montant moyen,
           pour les clients gold ou vip.

```sql
SELECT
    c.commande_id,
    cl.nom        AS client_nom,
    cl.segment,
    c.date_commande,
    c.montant_total
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
WHERE c.date_commande BETWEEN '2024-01-01' AND '2024-03-31 23:59:59'
  AND c.montant_total BETWEEN 50.00 AND 1000.00
  AND cl.segment IN ('gold', 'vip')
ORDER BY c.montant_total DESC;
```

Note : les JOINs sont étudiés en détail aux Chapitres 26-30.
Ici, la syntaxe JOIN est utilisée pour relier commandes et clients.
Deux BETWEEN distincts, l'un sur les dates, l'autre sur le montant.

────────────────────────────────────────────────────────────────────────────────
18.5 BONNES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

1. Préférer >= AND < pour les timestamps
   ──────────────────────────────────────
   ```sql
   -- Moins risqué
   WHERE date_commande >= '2024-01-01'
     AND date_commande < '2025-01-01'
   -- Plutôt que
   WHERE date_commande BETWEEN '2024-01-01' AND '2024-12-31'
   ```

2. Indexer les colonnes utilisées avec BETWEEN
   ─────────────────────────────────────────────
   ```sql
   CREATE INDEX idx_commandes_date ON commandes(date_commande);
   CREATE INDEX idx_produits_prix ON produits(prix);
   ```
   Les index B-Tree gèrent très bien les range scans.

3. Valeur min < valeur max toujours
   ───────────────────────────────────
   BETWEEN 100 AND 50 -> retourne 0 résultat (logique : rien entre 100 et 50)
   Toujours mettre la valeur minimale en premier.

4. Utiliser BETWEEN pour la lisibilité des plages numériques
   ──────────────────────────────────────────────────────────
   WHERE age BETWEEN 18 AND 65  est plus lisible que
   WHERE age >= 18 AND age <= 65  (mais les deux sont valides)

────────────────────────────────────────────────────────────────────────────────
18.6 ERREURS FRÉQUENTES
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — Bornes inversées
────────────────────────────
```sql
-- Retourne toujours 0 lignes
WHERE prix BETWEEN 200 AND 50
-- BETWEEN min AND max : min doit être <= max
```

ERREUR 2 — Oublier le temps dans les TIMESTAMP
────────────────────────────────────────────────
```sql
-- Exclut les commandes du 31 décembre après minuit
WHERE date_commande BETWEEN '2024-01-01' AND '2024-12-31'

-- Correct : spécifier l'heure de fin
WHERE date_commande BETWEEN '2024-01-01' AND '2024-12-31 23:59:59'
-- Encore mieux :
WHERE date_commande >= '2024-01-01' AND date_commande < '2025-01-01'
```

ERREUR 3 — Confondre BETWEEN avec un intervalle ouvert
───────────────────────────────────────────────────────
BETWEEN est INCLUSIF.
  BETWEEN 10 AND 20 inclut 10 et inclut 20.
Si on veut exclure les bornes -> utiliser > et <.
  WHERE prix > 10 AND prix < 20  (exclut 10 et 20)

ERREUR 4 — BETWEEN avec NULL
──────────────────────────────
```sql
-- Si prix est NULL pour certains produits :
WHERE prix BETWEEN 10 AND 100
-- -> les produits avec prix NULL sont exclus (NULL n'est pas dans l'intervalle)
```

────────────────────────────────────────────────────────────────────────────────
18.7 EXERCICES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

EXERCICES FACILES

EX18.1 — Produits milieu de gamme
Sélectionner nom, prix de tous les produits dont le prix
est entre 30€ et 100€. Trier par prix croissant.

EX18.2 — Bons avis
Sélectionner avis_id, produit_id, note, commentaire
de tous les avis dont la note est entre 4 et 5.

EX18.3 — Commandes de valeur moyenne
Sélectionner commande_id, montant_total, statut des commandes
dont le montant est entre 50€ et 300€.

EXERCICES INTERMÉDIAIRES

EX18.4 — Rapport trimestriel Q2 2024
Sélectionner commande_id, date_commande, montant_total, statut
des commandes du 2ème trimestre 2024 (avril, mai, juin).
Utiliser la méthode >= AND < pour les timestamps.

EX18.5 — Stocks à surveiller
Sélectionner nom, stock, prix des produits actifs
dont le stock est entre 1 et 10 unités (risque de rupture).
Trier par stock croissant.

EX18.6 — Avis intermédiaires
Sélectionner le nombre d'avis dont la note est exactement entre 2 et 3
(avis neutres ou légèrement négatifs).
Utiliser COUNT(*).

EXERCICES AVANCÉS

EX18.7 — Hors gamme
Sélectionner nom, prix des produits actifs
qui ne sont PAS dans la plage de prix 20€ à 200€.
Trier par prix.

EX18.8 — Analyse fenêtre temporelle
Compter le nombre de commandes par statut
pour les commandes passées entre le 1er janvier 2024 et le 31 mars 2024.
Grouper par statut (GROUP BY sera étudié au chapitre 24, tentez quand même).

EX18.9 — Rapport multi-conditions avec BETWEEN
Sélectionner commande_id, cl.nom, c.montant_total, c.date_commande
des commandes dont le montant est entre 100€ et 500€,
la date entre janvier et juin 2024,
et le client est de segment 'silver' ou 'gold'.

────────────────────────────────────────────────────────────────────────────────
18.8 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────────────────────────────────────────────

CORRIGÉ EX18.1
───────────────
```sql
SELECT
    nom,
    prix
FROM produits
WHERE prix BETWEEN 30.00 AND 100.00
ORDER BY prix ASC;
```
Raisonnement : BETWEEN inclusif sur les deux bornes.
30.00 et 100.00 sont inclus dans les résultats.

CORRIGÉ EX18.2
───────────────
```sql
SELECT
    avis_id,
    produit_id,
    note,
    commentaire
FROM avis
WHERE note BETWEEN 4 AND 5;
```
Raisonnement : note est un entier (1 à 5), BETWEEN 4 AND 5 = notes 4 ou 5.

CORRIGÉ EX18.3
───────────────
```sql
SELECT
    commande_id,
    montant_total,
    statut
FROM commandes
WHERE montant_total BETWEEN 50.00 AND 300.00;
```

CORRIGÉ EX18.4
───────────────
```sql
SELECT
    commande_id,
    date_commande,
    montant_total,
    statut
FROM commandes
WHERE date_commande >= '2024-04-01'
  AND date_commande < '2024-07-01'
ORDER BY date_commande ASC;
```
Raisonnement :
  Q2 = avril, mai, juin -> du 1er avril au 30 juin.
  On utilise >= 1er avril ET < 1er juillet.
  < 2024-07-01 inclut tout le 30 juin jusqu'à 23:59:59.999.
  Cette forme est préférée à BETWEEN pour les timestamps.

CORRIGÉ EX18.5
───────────────
```sql
SELECT
    nom,
    stock,
    prix
FROM produits
WHERE actif = TRUE
  AND stock BETWEEN 1 AND 10
ORDER BY stock ASC;
```
Raisonnement : produits actifs (actif = TRUE) avec stock faible.
Stock = 0 est exclu (déjà en rupture, on gère différemment).
Stock BETWEEN 1 AND 10 = risque de rupture imminente.

CORRIGÉ EX18.6
───────────────
```sql
SELECT COUNT(*) AS nb_avis_neutres
FROM avis
WHERE note BETWEEN 2 AND 3;
```
Raisonnement : COUNT(*) compte les lignes.
La note est un entier -> BETWEEN 2 AND 3 retourne les notes 2 ou 3.

CORRIGÉ EX18.7
───────────────
```sql
SELECT
    nom,
    prix
FROM produits
WHERE actif = TRUE
  AND prix NOT BETWEEN 20.00 AND 200.00
ORDER BY prix ASC;
```
Raisonnement : NOT BETWEEN exclut la plage [20, 200].
Retourne les produits < 20€ OU > 200€.

CORRIGÉ EX18.8
───────────────
```sql
SELECT
    statut,
    COUNT(*) AS nb_commandes
FROM commandes
WHERE date_commande >= '2024-01-01'
  AND date_commande < '2024-04-01'
GROUP BY statut
ORDER BY nb_commandes DESC;
```
Raisonnement :
  GROUP BY statut regroupe les lignes par valeur de statut.
  COUNT(*) compte les lignes dans chaque groupe.
  (GROUP BY est étudié en détail au Chapitre 24)

CORRIGÉ EX18.9
───────────────
```sql
SELECT
    c.commande_id,
    cl.nom,
    c.montant_total,
    c.date_commande
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
WHERE c.montant_total BETWEEN 100.00 AND 500.00
  AND c.date_commande >= '2024-01-01'
  AND c.date_commande < '2024-07-01'
  AND cl.segment IN ('silver', 'gold')
ORDER BY c.montant_total DESC;
```
Raisonnement :
  JOIN pour relier commandes et clients (détaillé Ch. 26).
  BETWEEN pour le montant (numérique, moins risqué).
  >= AND < pour la date (timestamps, plus sûr).
  IN pour le segment (detaillé Ch. 19).


================================================================================
CHAPITRE 19 — IN : LISTES DE VALEURS
================================================================================

────────────────────────────────────────────────────────────────────────────────
19.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

Qu'est-ce que IN ?
───────────────────
IN permet de tester si une valeur est présente dans une liste prédéfinie.

Sans IN :
  WHERE segment = 'gold' OR segment = 'silver' OR segment = 'vip'

Avec IN :
  WHERE segment IN ('gold', 'silver', 'vip')

Les deux sont équivalents, mais IN est plus lisible et maintenable.
Quand la liste grandit (10, 20 valeurs), IN devient indispensable.

Pourquoi il existe ?
────────────────────
Les requêtes filtrant sur des ensembles de valeurs sont très courantes :
  - filtrer des IDs spécifiques
  - filtrer sur des catégories
  - filtrer sur des statuts multiples
  - filtrer sur des codes pays

IN simplifie l'écriture et rend l'intention claire.

Dans quels cas l'utilise-t-on ?
────────────────────────────────
  - E-commerce : produits dans les catégories 'électronique' ou 'informatique'
  - CRM : clients des villes 'Paris', 'Lyon', 'Bordeaux', 'Marseille'
  - Logistique : commandes avec statut 'expédiée', 'en_transit', 'en_attente'
  - Finance : transactions dans les devises 'EUR', 'USD', 'GBP'
  - Analytics : filtrer sur une liste d'IDs générée dynamiquement

────────────────────────────────────────────────────────────────────────────────
19.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

Syntaxe officielle
───────────────────
  expression IN (valeur1, valeur2, valeur3, ...)

Équivalent à :
  expression = valeur1 OR expression = valeur2 OR expression = valeur3 ...

NOT IN
───────
  expression NOT IN (valeur1, valeur2, ...)

Équivalent à :
  expression <> valeur1 AND expression <> valeur2 AND ...

Types supportés
────────────────
IN fonctionne avec tous les types comparables :
  - Entiers : WHERE client_id IN (1, 5, 7, 42)
  - Décimaux : WHERE prix IN (9.99, 19.99, 29.99)
  - Chaînes : WHERE statut IN ('actif', 'inactif')
  - Dates : WHERE date_commande::DATE IN ('2024-01-01', '2024-06-15')
  - NULL : cas spécial (voir plus bas)

IN avec une sous-requête (avancé)
──────────────────────────────────
IN peut aussi prendre une sous-requête (SELECT) comme liste :
  WHERE client_id IN (SELECT client_id FROM vip_clients)

Ce cas est étudié en détail au Chapitre 33.
Ici on se concentre sur les listes statiques.

NULL dans IN : piège critique
──────────────────────────────
NULL IN (1, 2, NULL) -> NULL (pas TRUE !)
  - NULL n'est pas égal à NULL dans SQL
  - NULL IN (...) retourne NULL, pas TRUE

NOT IN avec NULL : piège encore plus critique
──────────────────────────────────────────────
Si la liste contient un NULL :
  5 NOT IN (1, 2, NULL) -> NULL (pas TRUE !)

Raisonnement logique :
  5 NOT IN (1, 2, NULL)
  ≡ 5 <> 1 AND 5 <> 2 AND 5 <> NULL
  ≡ TRUE AND TRUE AND NULL
  ≡ NULL

Résultat : 0 lignes retournées si la liste contient NULL et qu'on utilise NOT IN !

RÈGLE : Ne jamais inclure NULL dans une liste NOT IN.
Toujours utiliser NOT IN avec des listes dont on est sûr qu'elles ne contiennent pas NULL.

────────────────────────────────────────────────────────────────────────────────
19.3 COMMENT ÇA FONCTIONNE INTERNEMENT ?
────────────────────────────────────────────────────────────────────────────────

Pour une petite liste (< 20 valeurs) :
  Le moteur transforme IN en une série de comparaisons OR.
  Chaque valeur est comparée une par une.

Pour une grande liste :
  PostgreSQL peut utiliser une table de hachage (hash table) interne.
  Chaque valeur de la liste est hashée -> recherche en O(1).
  Beaucoup plus efficace qu'une série de comparaisons linéaires.

Pour IN avec sous-requête :
  Le moteur peut choisir entre HashJoin, NestedLoop, ou Semi-Join
  selon les statistiques des tables.

Avec index :
  Si un index existe sur la colonne :
    Le moteur peut faire N lookups dans l'index (un par valeur de la liste)
    ou un index scan selon l'optimiseur.

EXPLAIN ANALYZE sur une requête avec IN :
  Seq Scan on commandes (si la liste est petite et le selectivité faible)
  ou
  Bitmap Index Scan using idx_commandes_statut
    Recheck Cond: (statut = ANY ('{livrée,expédiée}'::text[]))

Note : PostgreSQL représente IN en interne comme = ANY(ARRAY[...]).

────────────────────────────────────────────────────────────────────────────────
19.4 IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

EXEMPLE 1 — IN avec des chaînes : filtrer par statut
──────────────────────────────────────────────────────

```sql
SELECT
    commande_id,
    client_id,
    date_commande,
    statut,
    montant_total
FROM commandes
WHERE statut IN ('confirmée', 'expédiée', 'livrée')
ORDER BY date_commande DESC;
```

Explication :
  WHERE statut IN ('confirmée', 'expédiée', 'livrée')
    -> retourne les lignes où statut vaut exactement l'une de ces trois valeurs
    -> exclut 'en_attente' et 'annulée'
    -> sensible à la casse en PostgreSQL

Équivalent sans IN :
  WHERE statut = 'confirmée'
     OR statut = 'expédiée'
     OR statut = 'livrée'

Le IN est préférable : plus lisible, plus simple à modifier.

EXEMPLE 2 — IN avec des entiers : filtrer par IDs
──────────────────────────────────────────────────

```sql
SELECT
    produit_id,
    nom,
    prix,
    stock
FROM produits
WHERE produit_id IN (1, 3, 5, 7, 9, 11)
ORDER BY produit_id ASC;
```

Explication :
  WHERE produit_id IN (1, 3, 5, 7, 9, 11)
    -> filtrer uniquement ces 6 produits spécifiques
    -> utile quand on reçoit une liste d'IDs depuis une autre source

Cas d'usage réel :
  Un utilisateur a mis 6 produits dans son panier.
  L'application envoie la requête avec les IDs du panier.

EXEMPLE 3 — NOT IN : exclure des valeurs
─────────────────────────────────────────

Objectif : toutes les commandes sauf les annulées et les en_attente.

```sql
SELECT
    commande_id,
    client_id,
    statut,
    montant_total
FROM commandes
WHERE statut NOT IN ('annulée', 'en_attente')
ORDER BY montant_total DESC;
```

Explication :
  WHERE statut NOT IN ('annulée', 'en_attente')
    -> exclut les lignes où statut est 'annulée' OU 'en_attente'
    -> retourne confirmée, expédiée, livrée

EXEMPLE 4 — IN avec des segments de clients
────────────────────────────────────────────

```sql
SELECT
    client_id,
    nom,
    email,
    segment,
    ville
FROM clients
WHERE segment IN ('silver', 'gold', 'vip')
ORDER BY segment, nom;
```

Filtrer les 3 segments premium (exclure 'bronze' et NULL).
ORDER BY segment, nom -> tri alphabétique dans chaque segment.

EXEMPLE 5 — IN pour filtrer des catégories (avec JOIN)
───────────────────────────────────────────────────────

```sql
SELECT
    p.produit_id,
    p.nom,
    p.prix,
    p.stock,
    c.nom AS categorie
FROM produits p
JOIN categories c ON p.categorie_id = c.categorie_id
WHERE c.nom IN ('Électronique', 'Informatique', 'Téléphonie')
ORDER BY c.nom, p.prix ASC;
```

Ici, le filtre IN s'applique sur une colonne de la table jointe (categories).
Pratique pour filtrer sur des libellés plutôt que des IDs.

EXEMPLE 6 — Combiner IN et AND/OR
───────────────────────────────────

```sql
SELECT
    commande_id,
    client_id,
    statut,
    montant_total,
    date_commande
FROM commandes
WHERE statut IN ('confirmée', 'expédiée')
  AND montant_total > 100.00
  AND client_id IN (1, 2, 3, 5, 8)
ORDER BY client_id, date_commande;
```

Deux filtres IN combinés avec AND.
  - statut doit être dans la liste de statuts actifs
  - client_id doit être dans la liste de clients surveillés

EXEMPLE 7 — NOT IN avec listes importantes
───────────────────────────────────────────

```sql
-- Clients qui ne sont pas dans les segments exclus
SELECT
    client_id,
    nom,
    segment
FROM clients
WHERE segment NOT IN ('bronze')
  AND segment IS NOT NULL;    -- protection contre NULL
```

On ajoute segment IS NOT NULL pour éviter le piège NOT IN avec NULL.
Un client sans segment ne serait pas retourné par NOT IN seul.

EXEMPLE 8 — IN avec des dates
──────────────────────────────

```sql
-- Commandes passées exactement ces jours spécifiques
SELECT
    commande_id,
    date_commande::DATE AS date,
    montant_total
FROM commandes
WHERE date_commande::DATE IN ('2024-01-15', '2024-02-20', '2024-03-10')
ORDER BY date_commande;
```

date_commande::DATE cast le TIMESTAMPTZ en DATE pure.
Cela permet de comparer la date sans l'heure.
Utile pour des dates de promo ou d'événements spécifiques.

────────────────────────────────────────────────────────────────────────────────
19.5 IN vs OR : QUAND UTILISER LEQUEL ?
────────────────────────────────────────────────────────────────────────────────

  Cas             | Recommandation     | Raison
  ────────────────┼────────────────────┼─────────────────────────────────────
  2 valeurs       | OR ou IN           | équivalents, style préférence
  3+ valeurs      | IN                 | beaucoup plus lisible
  Valeurs dynamiques (depuis app) | IN | liste facile à construire
  Sous-requête     | IN (SELECT...)    | cf. Ch. 33
  Exclure valeurs | NOT IN + IS NOT NULL | attention aux NULL

────────────────────────────────────────────────────────────────────────────────
19.6 BONNES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

1. Toujours ajouter IS NOT NULL avant NOT IN
   ──────────────────────────────────────────
   ```sql
   WHERE colonne IS NOT NULL
     AND colonne NOT IN ('val1', 'val2')
   ```

2. Indexer les colonnes utilisées dans IN fréquemment
   ────────────────────────────────────────────────────
   CREATE INDEX idx_clients_segment ON clients(segment);

3. Pour de très longues listes, préférer une table temporaire
   ──────────────────────────────────────────────────────────
   ```sql
   -- Plutôt que IN avec 1000 IDs
   CREATE TEMP TABLE ids_cibles AS VALUES (1),(2),(3),...;
   WHERE client_id IN (SELECT id FROM ids_cibles)
   -- OU
   WHERE client_id = ANY(SELECT id FROM ids_cibles)
   ```

4. Utiliser = ANY(ARRAY[...]) pour les paramètres de requête préparée
   ──────────────────────────────────────────────────────────────────
   ```sql
   -- Avec une requête préparée PostgreSQL
   WHERE statut = ANY($1::text[])  -- $1 = '{confirmée,livrée}'
   ```

────────────────────────────────────────────────────────────────────────────────
19.7 ERREURS FRÉQUENTES
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — NULL dans NOT IN
────────────────────────────
```sql
-- Si clients contient des segments NULL, cette requête retourne 0 lignes !
WHERE segment NOT IN ('bronze', NULL)

-- Solution
WHERE segment IS NOT NULL AND segment NOT IN ('bronze')
```

ERREUR 2 — Casse incorrecte pour les chaînes
─────────────────────────────────────────────
```sql
-- Ne retourne rien si les données sont en minuscules
WHERE statut IN ('Livrée', 'Expédiée')

-- Correct (respecter la casse des données)
WHERE statut IN ('livrée', 'expédiée')

-- Ou insensible à la casse avec LOWER()
WHERE LOWER(statut) IN ('livrée', 'expédiée')
```

ERREUR 3 — IN avec une seule valeur (style verbeux)
─────────────────────────────────────────────────────
```sql
-- Verbeux
WHERE statut IN ('livrée')

-- Simplifié
WHERE statut = 'livrée'
```

ERREUR 4 — Oublier les guillemets pour les chaînes
────────────────────────────────────────────────────
```sql
-- Erreur de syntaxe
WHERE statut IN (livrée, expédiée)

-- Correct
WHERE statut IN ('livrée', 'expédiée')
```

────────────────────────────────────────────────────────────────────────────────
19.8 EXERCICES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

EXERCICES FACILES

EX19.1 — Produits dans certaines catégories
Sélectionner produit_id, nom, prix des produits
dont le categorie_id est dans la liste : 1, 2, 3.
Trier par categorie_id, prix ASC.

EX19.2 — Commandes actives
Sélectionner commande_id, client_id, statut, montant_total
des commandes dont le statut est dans :
'confirmée', 'expédiée', 'en_attente'.

EX19.3 — Clients premium
Sélectionner nom, email, segment des clients
dont le segment est 'gold' ou 'vip'. Utiliser IN.

EXERCICES INTERMÉDIAIRES

EX19.4 — Produits hors stock dans certaines catégories
Sélectionner nom, stock, categorie_id des produits actifs
dont le stock = 0 ET le categorie_id est dans (1, 2, 3, 4).

EX19.5 — Exclure des statuts
Sélectionner commande_id, statut, montant_total, date_commande
des commandes dont le statut N'EST PAS dans ('annulée', 'en_attente').
Trier par montant_total DESC, afficher les 5 premières.

EX19.6 — Villes ciblées
Compter le nombre de clients par ville parmi :
'Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Lille'.
Utiliser GROUP BY ville.

EXERCICES AVANCÉS

EX19.7 — Analyse cross-table
Sélectionner les commandes (commande_id, montant_total, date_commande)
des clients dont le segment est dans ('gold', 'vip')
ET dont le statut de commande est dans ('livrée', 'confirmée').
Trier par montant_total DESC, afficher le top 10.

EX19.8 — NOT IN sécurisé
Sélectionner client_id, nom, segment des clients
dont le segment N'EST PAS 'bronze',
en gérant correctement les valeurs NULL.

EX19.9 — Rapport produits sélectionnés
Créer un rapport qui liste produit_id, nom, prix, stock, nom_categorie
pour les produits dont le produit_id est dans (2, 4, 6, 8, 10)
ET dont le prix > 50€.
(Nécessite un JOIN sur categories)

────────────────────────────────────────────────────────────────────────────────
19.9 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────────────────────────────────────────────

CORRIGÉ EX19.1
───────────────
```sql
SELECT
    produit_id,
    nom,
    prix
FROM produits
WHERE categorie_id IN (1, 2, 3)
ORDER BY categorie_id ASC, prix ASC;
```

CORRIGÉ EX19.2
───────────────
```sql
SELECT
    commande_id,
    client_id,
    statut,
    montant_total
FROM commandes
WHERE statut IN ('confirmée', 'expédiée', 'en_attente');
```

CORRIGÉ EX19.3
───────────────
```sql
SELECT
    nom,
    email,
    segment
FROM clients
WHERE segment IN ('gold', 'vip')
ORDER BY segment, nom;
```

CORRIGÉ EX19.4
───────────────
```sql
SELECT
    nom,
    stock,
    categorie_id
FROM produits
WHERE actif = TRUE
  AND stock = 0
  AND categorie_id IN (1, 2, 3, 4)
ORDER BY categorie_id;
```
Raisonnement : trois conditions AND.
stock = 0 (épuisé) dans des catégories prioritaires.

CORRIGÉ EX19.5
───────────────
```sql
SELECT
    commande_id,
    statut,
    montant_total,
    date_commande
FROM commandes
WHERE statut NOT IN ('annulée', 'en_attente')
ORDER BY montant_total DESC
LIMIT 5;
```
Note : si statut peut être NULL, ajouter :
WHERE statut IS NOT NULL AND statut NOT IN ('annulée', 'en_attente')

CORRIGÉ EX19.6
───────────────
```sql
SELECT
    ville,
    COUNT(*) AS nb_clients
FROM clients
WHERE ville IN ('Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Lille')
GROUP BY ville
ORDER BY nb_clients DESC;
```
Raisonnement :
  Filtre IN réduit les lignes aux 5 villes ciblées.
  GROUP BY regroupe par ville.
  COUNT(*) compte par groupe.

CORRIGÉ EX19.7
───────────────
```sql
SELECT
    c.commande_id,
    c.montant_total,
    c.date_commande
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
WHERE cl.segment IN ('gold', 'vip')
  AND c.statut IN ('livrée', 'confirmée')
ORDER BY c.montant_total DESC
LIMIT 10;
```
Raisonnement :
  JOIN pour accéder au segment client depuis la table commandes.
  Deux filtres IN sur des tables différentes.
  TOP 10 via LIMIT.

CORRIGÉ EX19.8
───────────────
```sql
SELECT
    client_id,
    nom,
    segment
FROM clients
WHERE segment IS NOT NULL
  AND segment NOT IN ('bronze')
ORDER BY segment, nom;
```
Raisonnement :
  segment IS NOT NULL : protège contre le piège NOT IN + NULL.
  Si on écrivait juste NOT IN ('bronze'), les clients avec segment NULL
  seraient exclus silencieusement.
  En ajoutant IS NOT NULL, on les exclut aussi explicitement
  (ou on pourrait ajouter OR segment IS NULL pour les inclure).

CORRIGÉ EX19.9
───────────────
```sql
SELECT
    p.produit_id,
    p.nom,
    p.prix,
    p.stock,
    c.nom AS nom_categorie
FROM produits p
JOIN categories c ON p.categorie_id = c.categorie_id
WHERE p.produit_id IN (2, 4, 6, 8, 10)
  AND p.prix > 50.00
ORDER BY p.produit_id;
```
Raisonnement :
  JOIN categories pour obtenir nom_categorie.
  Filtre IN sur produit_id pour les 5 produits cibles.
  Filtre AND prix > 50.00 supplémentaire.
  Si un produit est dans la liste mais < 50€, il est exclu.


================================================================================
CHAPITRE 20 — LIKE / ILIKE : RECHERCHE PAR MOTIF
================================================================================

────────────────────────────────────────────────────────────────────────────────
20.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

Qu'est-ce que LIKE ?
────────────────────
LIKE permet de rechercher des chaînes de caractères qui correspondent
à un motif (pattern) plutôt qu'à une valeur exacte.

Avec LIKE, on peut trouver :
  - tous les noms qui commencent par "Du"
  - tous les emails qui se terminent par "@gmail.com"
  - tous les produits dont le nom contient "USB"
  - toutes les adresses qui contiennent "avenue"

Deux caractères spéciaux (wildcards) :
  % -> remplace zéro ou plusieurs caractères quelconques
  _ -> remplace exactement un caractère quelconque

Pourquoi il existe ?
────────────────────
La recherche exacte (=) est insuffisante pour beaucoup de cas :
  - Moteur de recherche interne ("je cherche un produit avec 'cable'")
  - Validation de format ("emails se terminant par @entreprise.com")
  - Nettoyage de données ("toutes les valeurs contenant des chiffres")
  - Auto-complétion ("produits commençant par 'ip'")

ILIKE — variante insensible à la casse (PostgreSQL)
────────────────────────────────────────────────────
ILIKE est identique à LIKE mais ignore la casse :
  LIKE  : 'Apple' LIKE '%apple%'  -> FALSE (sensible à la casse)
  ILIKE : 'Apple' ILIKE '%apple%' -> TRUE  (insensible à la casse)

Note : ILIKE est spécifique à PostgreSQL.
En MySQL, LIKE est déjà insensible à la casse par défaut (sur les collations ci).

────────────────────────────────────────────────────────────────────────────────
20.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

Syntaxe officielle
───────────────────
  expression LIKE 'motif'
  expression NOT LIKE 'motif'
  expression ILIKE 'motif'       -- PostgreSQL uniquement
  expression NOT ILIKE 'motif'   -- PostgreSQL uniquement

Le motif (pattern) :
────────────────────
  %  -> zéro ou plusieurs caractères quelconques
  _  -> exactement un caractère quelconque
  Tout autre caractère -> caractère littéral (doit correspondre exactement)

Exemples de patterns :
─────────────────────
  Pattern           | Correspond à                      | Exemple match
  ──────────────────┼───────────────────────────────────┼───────────────────
  'cable%'          | commence par 'cable'               | 'cable USB 3m'
  '%usb%'           | contient 'usb' (n'importe où)     | 'hub usb 4 ports'
  '%@gmail.com'     | se termine par '@gmail.com'       | 'user@gmail.com'
  'A_pha%'          | A, n'importe quel caractère, 'pha' | 'Alpha produit'
  '___%'            | au moins 3 caractères              | 'ab' ne match pas
  '%'               | n'importe quelle chaîne (même vide)| tout correspond
  'exact'           | exactement 'exact' (sans wildcards)| équivalent à =

Comparaison avec l'opérateur = :
──────────────────────────────────
  'cable' = 'cable'        -> TRUE  (exactement égal)
  'cable' LIKE 'cable'     -> TRUE  (pas de wildcard -> comme =)
  'cable USB' LIKE 'cable%' -> TRUE (commence par 'cable')
  'cable USB' = 'cable%'   -> FALSE (= ne gère pas les wildcards)

Littéraliser les wildcards (ESCAPE)
─────────────────────────────────────
Si on cherche littéralement le caractère % ou _ dans les données :
  WHERE description LIKE '%50\%%' ESCAPE '\'
  -> cherche les chaînes contenant '50%' (le % final est littéral)

  WHERE code LIKE 'A\_code' ESCAPE '\'
  -> cherche exactement 'A_code' (le _ est littéral)

────────────────────────────────────────────────────────────────────────────────
20.3 COMMENT ÇA FONCTIONNE INTERNEMENT ?
────────────────────────────────────────────────────────────────────────────────

Évaluation du pattern LIKE
───────────────────────────
Le moteur compare le pattern caractère par caractère avec la valeur :

  Valeur : 'cable USB 3m'
  Pattern: 'cable%'

  Position 1 : 'c' == 'c' -> match
  Position 2 : 'a' == 'a' -> match
  Position 3 : 'b' == 'b' -> match
  Position 4 : 'l' == 'l' -> match
  Position 5 : 'e' == 'e' -> match
  Position 6 : 'e'  face à '%' -> % consomme le reste -> match !
  Résultat : TRUE

Pour '%usb%' :
  Le moteur recherche la sous-chaîne 'usb' n'importe où.
  Algorithmiquement similaire à strstr() en C.

Performance de LIKE
───────────────────
  Pattern avec % au début -> IMPOSSIBLE d'utiliser un index B-Tree standard
    'cable%'  -> index possible (préfixe connu)
    '%cable%' -> full table scan obligatoire (préfixe inconnu)

  Pour les patterns commençant par % :
    -> pg_trgm extension : CREATE INDEX ON table USING gin(colonne gin_trgm_ops)
    -> FULL TEXT SEARCH : pour de vraies recherches texte (tsvector, tsquery)

Cas où l'index standard est utilisé :
  WHERE nom LIKE 'cable%'
  -> Index Scan sur idx_produits_nom possible (lookup par préfixe)
  -> Équivalent à nom >= 'cable' AND nom < 'cablf' (approximativement)

────────────────────────────────────────────────────────────────────────────────
20.4 IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

EXEMPLE 1 — LIKE avec % à droite : commence par
─────────────────────────────────────────────────

Objectif : tous les produits dont le nom commence par 'Cable'.

```sql
SELECT
    produit_id,
    nom,
    prix,
    stock
FROM produits
WHERE nom LIKE 'Cable%';
```

Explication :
  'Cable%'
    -> 'Cable' doit être au début (caractères littéraux)
    -> % remplace tout ce qui suit (peut être vide)
  Match : 'Cable HDMI 2m', 'Cable USB 3.0', 'Cable réseau Cat6'
  Non-match : 'Adaptateur Cable', 'USB Cable' (ne commence pas par 'Cable')

EXEMPLE 2 — LIKE avec % à gauche : se termine par
───────────────────────────────────────────────────

Objectif : tous les clients avec un email Gmail.

```sql
SELECT
    client_id,
    nom,
    email
FROM clients
WHERE email LIKE '%@gmail.com';
```

Explication :
  '%@gmail.com'
    -> % remplace tout ce qui précède (n'importe quel préfixe)
    -> '@gmail.com' doit être exactement à la fin
  Match : 'alice@gmail.com', 'bob.dupont@gmail.com'
  Non-match : 'alice@gmail.com.fr', 'alice@yahoo.com'

[ATTENTION] Performance : % au début -> full table scan.
Pour les tables de clients importantes, considérer un index de trigrams.

EXEMPLE 3 — LIKE avec % des deux côtés : contient
───────────────────────────────────────────────────

Objectif : tous les produits dont le nom contient 'USB'.

```sql
SELECT
    produit_id,
    nom,
    prix
FROM produits
WHERE nom LIKE '%USB%';
```

Explication :
  '%USB%'
    -> % au début : n'importe quoi avant
    -> 'USB' : sous-chaîne exacte recherchée
    -> % à la fin : n'importe quoi après
  Match : 'Cable USB 3.0', 'Hub USB 4 ports', 'Chargeur USB-C'
  Non-match : 'Cable HDMI', 'Souris Bluetooth'

EXEMPLE 4 — ILIKE : recherche insensible à la casse
────────────────────────────────────────────────────

```sql
SELECT
    produit_id,
    nom,
    prix
FROM produits
WHERE nom ILIKE '%usb%';
```

Explication :
  ILIKE '%usb%'
    -> correspond à 'USB', 'usb', 'Usb', 'USB', 'uSb', etc.
    -> insensible à la casse (PostgreSQL uniquement)
  Match : 'Cable USB 3.0', 'hub usb 4 ports', 'Chargeur usb-c'

EXEMPLE 5 — NOT LIKE : exclure un motif
─────────────────────────────────────────

Objectif : produits dont le nom NE CONTIENT PAS 'USB'.

```sql
SELECT
    produit_id,
    nom,
    prix
FROM produits
WHERE nom NOT LIKE '%USB%';
```

Retourne tous les produits sans 'USB' dans le nom.
Attention : les produits avec nom NULL sont aussi exclus.

EXEMPLE 6 — LIKE avec _ : un seul caractère inconnu
─────────────────────────────────────────────────────

Objectif : trouver des codes produit au format 'PRD_001' à 'PRD_999'.
(Un seul chiffre variable)

```sql
SELECT
    produit_id,
    nom
FROM produits
WHERE nom LIKE 'PRD_00_';
-- _ = exactement 1 caractère -> PRD_001, PRD_002, ... PRD_009
```

Autre exemple : noms en 5 lettres commençant par 'A' :
```sql
WHERE nom LIKE 'A____'
-- 4 underscores = 4 caractères après 'A' -> total 5 caractères
```

EXEMPLE 7 — Recherche d'emails par domaine
────────────────────────────────────────────

```sql
SELECT
    client_id,
    nom,
    email
FROM clients
WHERE email ILIKE '%@%shopflow%'   -- emails liés à shopflow
   OR email ILIKE '%@gmail.com'
   OR email ILIKE '%@yahoo.%'      -- yahoo.com, yahoo.fr, etc.
ORDER BY email;
```

Chaîner plusieurs LIKE avec OR pour couvrir plusieurs patterns.

EXEMPLE 8 — Validation de données avec LIKE
────────────────────────────────────────────

Cas d'usage : trouver les emails qui ne semblent pas valides.

```sql
SELECT
    client_id,
    nom,
    email
FROM clients
WHERE email NOT LIKE '%@%.%'  -- un email valide doit contenir @ et un .
ORDER BY client_id;
```

Explication :
  Un email minimal valide : local@domaine.tld
  Pattern '%@%.%' = quelque chose, @, quelque chose, ., quelque chose.
  NOT LIKE -> retourne les emails qui ne correspondent pas à ce pattern minimal.

EXEMPLE 9 — LIKE combiné avec AND
──────────────────────────────────

```sql
SELECT
    client_id,
    nom,
    email,
    ville
FROM clients
WHERE nom ILIKE 'dup%'          -- nom commence par 'dup' (dupont, durand...)
  AND email ILIKE '%@gmail%'    -- email Gmail
  AND segment IN ('gold', 'vip')
ORDER BY nom;
```

────────────────────────────────────────────────────────────────────────────────
20.5 LIKE VS ALTERNATIVES
────────────────────────────────────────────────────────────────────────────────

  Méthode         | Cas d'usage                    | Performance
  ────────────────┼────────────────────────────────┼──────────────────────────
  LIKE 'abc%'     | Préfixe connu                  | Bon (index B-Tree)
  LIKE '%abc%'    | Sous-chaîne                    | Mauvais (full scan)
  ILIKE           | Insensible à casse             | Plus lent que LIKE
  pg_trgm + GIN   | Sous-chaîne rapide             | Excellent (index trigram)
  FULL TEXT (@@)  | Recherche sémantique complexe  | Excellent pour textes
  REGEXP_MATCH    | Patterns complexes (regex)     | Variable, puissant

Pour les applications avec vrai moteur de recherche :
  -> PostgreSQL Full Text Search (tsvector, tsquery) — Ch. SQL Avancé
  -> pg_trgm pour LIKE '%..%' rapide sur grandes tables

────────────────────────────────────────────────────────────────────────────────
20.6 BONNES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

1. Utiliser ILIKE plutôt que LOWER(col) LIKE LOWER(pattern)
   ──────────────────────────────────────────────────────────
   ```sql
   -- Verbeux
   WHERE LOWER(nom) LIKE LOWER('%usb%')

   -- Simple (PostgreSQL)
   WHERE nom ILIKE '%usb%'
   ```

2. Éviter LIKE '%...' sur les grandes tables sans index trigram
   ─────────────────────────────────────────────────────────────
   Sur une table de 10 millions de lignes :
   LIKE '%usb%' = 10M comparaisons de chaînes -> lent.

   Solution : extension pg_trgm
   ```sql
   CREATE EXTENSION pg_trgm;
   CREATE INDEX idx_produits_nom_trgm ON produits USING gin(nom gin_trgm_ops);
   -- Maintenant '%usb%' utilise l'index -> très rapide
   ```

3. Préférer LIKE 'prefix%' quand possible
   ─────────────────────────────────────────
   Si vous savez que les utilisateurs cherchent par début de mot,
   structurez les données pour permettre les recherches par préfixe.

4. Échapper les caractères spéciaux dans les entrées utilisateurs
   ───────────────────────────────────────────────────────────────
   Si un utilisateur tape '%' dans un champ de recherche,
   cela matcherait tout si non échappé.
   En application : remplacer les % et _ dans l'input utilisateur avant
   de les passer à LIKE (ou utiliser ESCAPE).

5. Documenter les patterns complexes
   ────────────────────────────────────
   ```sql
   -- Pattern : email valide (simpliste : contient @ et un point après)
   WHERE email LIKE '%@%.%'
   ```
   Un commentaire explique l'intention du pattern.

────────────────────────────────────────────────────────────────────────────────
20.7 ERREURS FRÉQUENTES
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — Oublier les guillemets autour du pattern
────────────────────────────────────────────────────
```sql
-- Erreur de syntaxe
WHERE nom LIKE %USB%

-- Correct
WHERE nom LIKE '%USB%'
```

ERREUR 2 — Utiliser = avec un wildcard
────────────────────────────────────────
```sql
-- Ne fonctionne PAS (recherche littéralement '%USB%')
WHERE nom = '%USB%'

-- Correct
WHERE nom LIKE '%USB%'
```

ERREUR 3 — LIKE insensible à la casse sans ILIKE
──────────────────────────────────────────────────
```sql
-- En PostgreSQL, LIKE est sensible à la casse
WHERE nom LIKE '%usb%'   -- ne trouve pas 'Cable USB 3.0' !

-- Correct
WHERE nom ILIKE '%usb%'  -- insensible à la casse
```

ERREUR 4 — Performance : % en début de pattern sur grande table
────────────────────────────────────────────────────────────────
```sql
-- LENT sur grande table sans index trigram
WHERE description LIKE '%promotion%'
-- -> full table scan inévitable sans pg_trgm
```

ERREUR 5 — Confondre _ et %
────────────────────────────
```sql
-- _ = exactement 1 caractère
WHERE code LIKE 'A_B'   -- AXB, A1B, A B -> mais pas AB, AXXB

-- % = zéro ou plusieurs caractères
WHERE code LIKE 'A%B'   -- AB, AXB, AXXXXB, AB
```

────────────────────────────────────────────────────────────────────────────────
20.8 EXERCICES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

EXERCICES FACILES

EX20.1 — Produits commençant par 'Sony'
Sélectionner nom, prix des produits dont le nom commence par 'Sony'.

EX20.2 — Emails Gmail
Sélectionner nom, email des clients dont l'email se termine par '@gmail.com'.

EX20.3 — Produits contenant 'HDMI'
Sélectionner nom, prix, stock des produits dont le nom contient 'HDMI'
(insensible à la casse).

EXERCICES INTERMÉDIAIRES

EX20.4 — Recherche de clients par nom
Trouver tous les clients dont le nom commence par 'Du' (Dupont, Durand, Dulac...).
Afficher client_id, nom, email, segment.

EX20.5 — Produits premium avec description
Sélectionner nom, prix des produits actifs
dont le nom contient soit 'Pro', soit 'Premium', soit 'Elite' (insensible à la casse).
Utiliser ILIKE et OR.

EX20.6 — Validation emails suspects
Trouver les clients dont l'email ne contient pas le caractère '@'
(données invalides). Afficher client_id, nom, email.

EXERCICES AVANCÉS

EX20.7 — Recherche flexible multi-champs
Trouver les clients dont :
  - le nom contient 'martin' (insensible à casse)
  OU
  - l'email contient 'martin' (insensible à casse)
Afficher client_id, nom, email, segment.
Éviter les doublons.

EX20.8 — Nettoyage données
Trouver tous les produits dont le nom :
  - commence par un espace (erreur de saisie)
  OU
  - se termine par un espace
  OU
  - contient deux espaces consécutifs
Afficher produit_id, nom (entre guillemets pour visualiser les espaces).

EX20.9 — Moteur de recherche simple
Créer une requête "moteur de recherche" qui retourne les produits
correspondant au terme 'usb' dans le nom.
La requête doit :
  1. Être insensible à la casse
  2. Chercher 'usb' n'importe où dans le nom
  3. Trier par pertinence approximative : d'abord les produits dont le nom
     COMMENCE par 'USB', ensuite les autres.
  (Astuce : utiliser ORDER BY CASE WHEN nom ILIKE 'usb%' THEN 0 ELSE 1 END)

────────────────────────────────────────────────────────────────────────────────
20.9 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────────────────────────────────────────────

CORRIGÉ EX20.1
───────────────
```sql
SELECT
    nom,
    prix
FROM produits
WHERE nom LIKE 'Sony%';
```
Raisonnement : pattern avec préfixe connu 'Sony', % pour le reste.
LIKE est sensible à la casse en PostgreSQL.
Si les données peuvent avoir 'sony' ou 'SONY' -> utiliser ILIKE.

CORRIGÉ EX20.2
───────────────
```sql
SELECT
    nom,
    email
FROM clients
WHERE email LIKE '%@gmail.com';
```
Raisonnement : % au début pour n'importe quel nom d'utilisateur,
'@gmail.com' fixe à la fin.
Ne matcherait pas '@gmail.com.fr' (le .com est le dernier élément).

CORRIGÉ EX20.3
───────────────
```sql
SELECT
    nom,
    prix,
    stock
FROM produits
WHERE nom ILIKE '%HDMI%';
```
Raisonnement : ILIKE pour insensibilité à la casse.
'%HDMI%' cherche 'HDMI', 'hdmi', 'Hdmi' n'importe où dans le nom.

CORRIGÉ EX20.4
───────────────
```sql
SELECT
    client_id,
    nom,
    email,
    segment
FROM clients
WHERE nom ILIKE 'du%'
ORDER BY nom ASC;
```
Raisonnement : ILIKE pour couvrir 'Du', 'du', 'DU'...
Préfixe 'du' puis % pour le reste du nom.
Un index B-Tree sur nom permettrait un range scan efficace ici.

CORRIGÉ EX20.5
───────────────
```sql
SELECT
    nom,
    prix
FROM produits
WHERE actif = TRUE
  AND (
       nom ILIKE '%Pro%'
    OR nom ILIKE '%Premium%'
    OR nom ILIKE '%Elite%'
  )
ORDER BY prix DESC;
```
Raisonnement :
  actif = TRUE : seulement les produits actifs.
  Trois ILIKE avec OR entre parenthèses.
  '%Pro%' matcherait 'Pro', 'ProMax', 'ProductPro', 'SuperPro'.
  Attention : '%Pro%' matcherait aussi 'Promotion' si non désiré.
  Pour être plus précis : ' Pro ' ou 'Pro ' (espace autour) — selon les données.

CORRIGÉ EX20.6
───────────────
```sql
SELECT
    client_id,
    nom,
    email
FROM clients
WHERE email NOT LIKE '%@%'
   OR email IS NULL
ORDER BY client_id;
```
Raisonnement :
  NOT LIKE '%@%' : pas de @ -> email invalide.
  OR email IS NULL : inclure aussi les emails manquants.
  Ces lignes représentent des données corrompues ou incomplètes.

CORRIGÉ EX20.7
───────────────
```sql
SELECT DISTINCT
    client_id,
    nom,
    email,
    segment
FROM clients
WHERE nom ILIKE '%martin%'
   OR email ILIKE '%martin%'
ORDER BY nom;
```
Raisonnement :
  DISTINCT évite les doublons si une même ligne match les deux conditions.
  (En réalité, sans DISTINCT, une ligne ne peut apparaître qu'une fois
   même si les deux conditions sont vraies — SQL est ensembliste — mais
   DISTINCT est une bonne habitude ici pour clarté.)
  ILIKE '%martin%' dans nom et email : recherche dans deux champs.

CORRIGÉ EX20.8
───────────────
```sql
SELECT
    produit_id,
    '"' || nom || '"' AS nom_avec_guillemets
FROM produits
WHERE nom LIKE ' %'         -- commence par un espace
   OR nom LIKE '% '         -- se termine par un espace
   OR nom LIKE '%  %'       -- contient deux espaces consécutifs
ORDER BY produit_id;
```
Raisonnement :
  ' %' : premier caractère est un espace (espace littéral suivi de %).
  '% ' : dernier caractère est un espace.
  '%  %' : deux espaces consécutifs quelque part (deux espaces entre les %).
  Concaténation '"' || nom || '"' pour visualiser les espaces dans le résultat.
  Note : || est l'opérateur de concaténation de chaînes en PostgreSQL.

CORRIGÉ EX20.9
───────────────
```sql
SELECT
    produit_id,
    nom,
    prix,
    stock,
    CASE
        WHEN nom ILIKE 'usb%' THEN 0   -- commence par USB -> plus pertinent
        ELSE 1                          -- contient USB mais pas au début
    END AS score_pertinence
FROM produits
WHERE nom ILIKE '%usb%'
ORDER BY
    score_pertinence ASC,    -- d'abord les plus pertinents (score 0)
    nom ASC;                 -- puis alphabétique dans chaque groupe
```
Raisonnement :
  WHERE nom ILIKE '%usb%' : tous les produits contenant 'usb'.
  CASE dans SELECT : attribue un score de pertinence.
    0 -> commence par 'usb' (match exact du début -> plus pertinent)
    1 -> contient 'usb' ailleurs (moins pertinent)
  ORDER BY score_pertinence : les 0 arrivent avant les 1.
  Résultat simulant un tri de pertinence basique de moteur de recherche.

Ce pattern CASE dans ORDER BY est très utilisé dans les applications e-commerce
pour trier les résultats de recherche par pertinence décroissante.


================================================================================
SYNTHÈSE DE LA PARTIE 4 — FILTRAGE AVANCÉ
================================================================================

RÉCAPITULATIF DES OPÉRATEURS VUS
──────────────────────────────────

Opérateur   | Usage                           | Exemple
────────────┼─────────────────────────────────┼──────────────────────────────────
AND         | Toutes les conditions vraies     | WHERE prix > 10 AND stock > 0
OR          | Au moins une condition vraie     | WHERE ville = 'Paris' OR ville = 'Lyon'
NOT         | Inverser une condition           | WHERE NOT actif / WHERE actif = FALSE
BETWEEN     | Plage inclusive [min, max]       | WHERE prix BETWEEN 10 AND 100
NOT BETWEEN | Hors de la plage                | WHERE prix NOT BETWEEN 50 AND 200
IN          | Valeur dans une liste            | WHERE statut IN ('livrée', 'expédiée')
NOT IN      | Valeur hors d'une liste          | WHERE statut NOT IN ('annulée')
LIKE        | Pattern (sensible à la casse)    | WHERE nom LIKE 'Cable%'
ILIKE       | Pattern (insensible à la casse)  | WHERE nom ILIKE '%usb%'
NOT LIKE    | Ne correspond pas au pattern     | WHERE email NOT LIKE '%@gmail%'

RÈGLES D'OR À RETENIR
──────────────────────
  1. AND est PRIORITAIRE sur OR -> toujours utiliser des PARENTHÈSES quand on mélange
  2. BETWEEN est INCLUSIF des deux bornes
  3. Pour les TIMESTAMP, préférer >= AND < plutôt que BETWEEN
  4. NOT IN avec une liste contenant NULL -> 0 résultats ! Ajouter IS NOT NULL
  5. LIKE '%...' (% au début) -> full table scan -> considérer pg_trgm sur grande table
  6. NULL n'est jamais = à quoi que ce soit -> utiliser IS NULL / IS NOT NULL
  7. ILIKE = insensible à la casse (PostgreSQL uniquement)

CHECKLIST AVANT D'ÉCRIRE UN WHERE COMPLEXE
────────────────────────────────────────────
  [WHITE_SQUARE] Les parenthèses sont-elles correctement placées autour des OR ?
  [WHITE_SQUARE] Les colonnes testées peuvent-elles contenir NULL ? (IS NULL ?)
  [WHITE_SQUARE] BETWEEN : les bornes sont-elles dans le bon ordre (min d'abord) ?
  [WHITE_SQUARE] NOT IN : la liste ne contient-elle pas de NULL ?
  [WHITE_SQUARE] LIKE : le % est-il au début (performance) ?
  [WHITE_SQUARE] Les colonnes filtrées sont-elles indexées ?

INDEX RECOMMANDÉS POUR LA PARTIE 4
────────────────────────────────────
```sql
-- Index pour les filtres courants du chapitre
CREATE INDEX IF NOT EXISTS idx_commandes_statut ON commandes(statut);
CREATE INDEX IF NOT EXISTS idx_commandes_montant ON commandes(montant_total);
CREATE INDEX IF NOT EXISTS idx_commandes_date ON commandes(date_commande);
CREATE INDEX IF NOT EXISTS idx_clients_segment ON clients(segment);
CREATE INDEX IF NOT EXISTS idx_clients_ville ON clients(ville);
CREATE INDEX IF NOT EXISTS idx_produits_prix ON produits(prix);
CREATE INDEX IF NOT EXISTS idx_produits_stock ON produits(stock);
CREATE INDEX IF NOT EXISTS idx_avis_note ON avis(note);

-- Index trigram pour les recherches LIKE '%..%'
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_produits_nom_trgm ON produits USING gin(nom gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_clients_email_trgm ON clients USING gin(email gin_trgm_ops);
```

================================================================================
FIN DE LA PARTIE 4 — FILTRAGE AVANCÉ
================================================================================
Chapitres couverts : 17 (AND/OR), 18 (BETWEEN), 19 (IN), 20 (LIKE/ILIKE)
Prochaine partie : PARTIE 5 — AGRÉGATIONS (COUNT, SUM, AVG, GROUP BY, HAVING)
================================================================================


================================================================================
GUIDE SQL COMPLET — PARTIE 5
AGRÉGATIONS : COUNT, SUM, AVG, GROUP BY, HAVING
Chapitres 21 à 25
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Débutant -> Intermédiaire
================================================================================

TABLE DES MATIÈRES — PARTIE 5
══════════════════════════════
  Chapitre 21 : COUNT — Compter les enregistrements
  Chapitre 22 : SUM — Sommer des valeurs
  Chapitre 23 : AVG, MIN, MAX — Statistiques de base
  Chapitre 24 : GROUP BY — Regrouper les données
  Chapitre 25 : HAVING — Filtrer les groupes


================================================================================
CHAPITRE 21 : COUNT — COMPTER LES ENREGISTREMENTS
================================================================================

────────────────────────────────────────────────────────────────────────────────
21.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

Qu'est-ce que COUNT ?
─────────────────────
COUNT est la fonction d'agrégation la plus simple et la plus utilisée en SQL.
Elle compte le nombre de lignes ou le nombre de valeurs non-NULL dans une
colonne. C'est votre première réponse à "combien de...".

Pourquoi COUNT existe-t-il ?
────────────────────────────
Dans une table de 10 millions de commandes, "combien de commandes livrées ?" est
une question courante. Sans COUNT, vous devriez ramener toutes les lignes vers
votre application et compter en code. COUNT délègue ce travail à la base de
données — plus proche des données, infiniment plus efficace.

Cas d'usage typiques :
  - Combien de clients sont inscrits ? -> COUNT(*)
  - Combien de produits actifs ? -> COUNT(*) WHERE actif = TRUE
  - Combien de commandes différentes par client ? -> COUNT(commande_id)
  - Combien de clients ont laissé un avis ? -> COUNT(DISTINCT client_id)

────────────────────────────────────────────────────────────────────────────────
21.2 THÉORIE — LES TROIS FORMES DE COUNT
────────────────────────────────────────────────────────────────────────────────

FORME 1 : COUNT(*) — Compte toutes les lignes (y compris celles avec NULL)
───────────────────────────────────────────────────────────────────────────

    SELECT COUNT(*) FROM clients;

- Compte chaque ligne, peu importe si ses colonnes contiennent des NULL.
- La forme la plus rapide : PostgreSQL n'a pas besoin de lire les valeurs.
- Retourne toujours un entier >= 0 (jamais NULL).

FORME 2 : COUNT(colonne) — Compte les valeurs non-NULL d'une colonne
──────────────────────────────────────────────────────────────────────

    SELECT COUNT(telephone) FROM clients;

- Compte seulement les lignes où telephone n'est pas NULL.
- Si 10 clients sur 10 n'ont pas de téléphone, COUNT(telephone) = 0.
- Utile pour mesurer la "complétude" d'un champ.

Comparaison directe :
    SELECT
        COUNT(*)           AS total_clients,
        COUNT(telephone)   AS clients_avec_tel,
        COUNT(*) - COUNT(telephone) AS clients_sans_tel
    FROM clients;

FORME 3 : COUNT(DISTINCT colonne) — Compte les valeurs uniques non-NULL
────────────────────────────────────────────────────────────────────────

    SELECT COUNT(DISTINCT client_id) FROM commandes;

- Compte combien de clients différents ont passé au moins une commande.
- Élimine les doublons avant de compter.
- Plus coûteux que COUNT(*) car nécessite un tri ou une table de hachage.

Table de vérité pour comprendre la différence :

    Données dans commandes :
    commande_id | client_id
    ────────────┼──────────
    1           | 1
    2           | 1          <- même client que ligne 1
    3           | 2
    4           | NULL       <- pas de client (orphelin)

    COUNT(*)                   -> 4  (toutes les lignes)
    COUNT(client_id)            -> 3  (exclut le NULL)
    COUNT(DISTINCT client_id)   -> 2  (clients uniques : 1 et 2)

────────────────────────────────────────────────────────────────────────────────
21.3 COUNT AVEC WHERE ET FILTER
────────────────────────────────────────────────────────────────────────────────

COUNT avec WHERE :

    -- Compter uniquement les commandes livrées
    SELECT COUNT(*) AS commandes_livrees
    FROM commandes
    WHERE statut = 'livree';

COUNT avec FILTER (PostgreSQL 9.4+) :
Permet plusieurs COUNT conditionnels en une seule requête.

    SELECT
        COUNT(*)                                         AS total_commandes,
        COUNT(*) FILTER (WHERE statut = 'en_attente')   AS en_attente,
        COUNT(*) FILTER (WHERE statut = 'expediee')     AS expediees,
        COUNT(*) FILTER (WHERE statut = 'livree')       AS livrees,
        COUNT(*) FILTER (WHERE statut = 'annulee')      AS annulees
    FROM commandes;

Résultat (exemple ShopFlow) :
    total | en_attente | expediees | livrees | annulees
    ──────┼────────────┼───────────┼─────────┼─────────
       10 |          2 |         1 |       6 |        1

Alternative avec CASE WHEN (compatible tous SGBD) :

    SELECT
        COUNT(CASE WHEN statut = 'en_attente' THEN 1 END) AS en_attente,
        COUNT(CASE WHEN statut = 'livree'     THEN 1 END) AS livrees
    FROM commandes;

────────────────────────────────────────────────────────────────────────────────
21.4 COUNT ET PERFORMANCES
────────────────────────────────────────────────────────────────────────────────

COUNT(*) sur une grande table peut être lent sans index car PostgreSQL doit
compter toutes les lignes visibles. Quelques optimisations :

1. Pour une estimation rapide (pas un compte exact) :

    SELECT reltuples::BIGINT AS estimation_lignes
    FROM pg_class
    WHERE relname = 'commandes';
    -- Retourne l'estimation des statistiques (mise à jour par ANALYZE).
    -- Pas précis à 100%, mais instantané sur des milliards de lignes.

2. Avec un index couvrant (Index Only Scan) :
   Si COUNT(*) filtre sur des colonnes indexées et que la visibilité des
   lignes est à jour (grace au vacuum), PostgreSQL peut faire un
   Index Only Scan (très rapide).

3. Eviter COUNT(DISTINCT) sur de très grandes colonnes -> utiliser HLL
   (HyperLogLog, extension pg_hll) pour des estimations.

────────────────────────────────────────────────────────────────────────────────
21.5 EXERCICES COUNT
────────────────────────────────────────────────────────────────────────────────

EXERCICE 21-1 (Facile)
Comptez le nombre total de produits dans la table produits.

SOLUTION :
    SELECT COUNT(*) AS total_produits FROM produits;

EXERCICE 21-2 (Facile)
Comptez combien de produits ont une description renseignée (non-NULL).

SOLUTION :
    SELECT
        COUNT(*)            AS total_produits,
        COUNT(description)  AS avec_description,
        COUNT(*) - COUNT(description) AS sans_description
    FROM produits;

EXERCICE 21-3 (Moyen)
En une seule requête, comptez le nombre de commandes par statut, plus le total.
Utilisez COUNT FILTER.

SOLUTION :
    SELECT
        COUNT(*)                                              AS total,
        COUNT(*) FILTER (WHERE statut = 'EN_ATTENTE')         AS en_attente,
        COUNT(*) FILTER (WHERE statut = 'CONFIRMEE')          AS confirmees,
        COUNT(*) FILTER (WHERE statut = 'EN_PREPARATION')     AS en_preparation,
        COUNT(*) FILTER (WHERE statut = 'EXPEDIEE')           AS expediees,
        COUNT(*) FILTER (WHERE statut = 'LIVREE')             AS livrees,
        COUNT(*) FILTER (WHERE statut = 'ANNULEE')            AS annulees
    FROM commandes;

EXERCICE 21-4 (Moyen)
Combien de clients distincts ont passé au moins une commande ?
Et combien de clients n'ont jamais commandé ?

SOLUTION :
    SELECT
        COUNT(DISTINCT c.id_client)   AS clients_avec_commande,
        (SELECT COUNT(*) FROM clients)
        - COUNT(DISTINCT c.id_client) AS clients_sans_commande
    FROM commandes c;

EXERCICE 21-5 (Avancé)
Calculez le taux de complétion du profil client (% de clients ayant renseigné
telephone, date_naissance ET adresse_rue). Afficher en pourcentage arrondi.

SOLUTION :
    SELECT
        COUNT(*)  AS total_clients,
        COUNT(*) FILTER (
            WHERE telephone   IS NOT NULL
              AND date_naissance IS NOT NULL
              AND adresse_rue  IS NOT NULL
        )         AS profils_complets,
        ROUND(
            100.0 * COUNT(*) FILTER (
                WHERE telephone   IS NOT NULL
                  AND date_naissance IS NOT NULL
                  AND adresse_rue  IS NOT NULL
            ) / COUNT(*), 1
        )         AS taux_completion_pct
    FROM clients;


================================================================================
CHAPITRE 22 : SUM — ADDITIONNER DES VALEURS
================================================================================

────────────────────────────────────────────────────────────────────────────────
22.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

SUM additionne toutes les valeurs numériques d'une colonne dans un groupe de
lignes. C'est la fonction pour calculer des totaux : chiffre d'affaires,
quantités vendues, montants de panier.

Propriétés importantes :
  - SUM ignore automatiquement les valeurs NULL
  - SUM d'une colonne entièrement NULL retourne NULL (pas 0 !)
  - SUM fonctionne sur tous les types numériques : INTEGER, DECIMAL, FLOAT

────────────────────────────────────────────────────────────────────────────────
22.2 EXEMPLES PRATIQUES SUR SHOPFLOW
────────────────────────────────────────────────────────────────────────────────

Chiffre d'affaires total :

    SELECT
        SUM(montant_ttc)             AS ca_total,
        SUM(montant_ht)              AS ca_ht_total,
        SUM(frais_livraison)         AS frais_livraison_total,
        COALESCE(SUM(montant_ttc), 0) AS ca_total_sans_null
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE');

Quantité totale vendue par produit :

    SELECT
        p.nom                             AS produit,
        SUM(lc.quantite)                  AS quantite_vendue,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS ca_produit,
        ROUND(
            SUM(lc.quantite * lc.prix_unitaire_ht) /
            NULLIF(SUM(lc.quantite), 0),
        2)                                AS prix_moyen_vente
    FROM lignes_commande lc
    JOIN produits p ON lc.id_produit = p.id_produit
    GROUP BY p.id_produit, p.nom
    ORDER BY ca_produit DESC;

SUM avec pondération — panier moyen pondéré par catégorie :

    SELECT
        cat.nom                            AS categorie,
        COUNT(DISTINCT co.id_commande)     AS nb_commandes,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS ca_categorie,
        ROUND(
            SUM(lc.quantite * lc.prix_unitaire_ht) /
            COUNT(DISTINCT co.id_commande),
        2)                                 AS panier_moyen
    FROM categories cat
    JOIN produits p  ON cat.id_categorie = p.id_categorie
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    JOIN commandes co ON lc.id_commande = co.id_commande
    WHERE co.statut = 'LIVREE'
    GROUP BY cat.id_categorie, cat.nom
    ORDER BY ca_categorie DESC;

────────────────────────────────────────────────────────────────────────────────
22.3 PIÈGE : SUM RETOURNE NULL SI AUCUNE LIGNE NE CORRESPOND
────────────────────────────────────────────────────────────────────────────────

    -- Si aucune commande n'existe pour ce client :
    SELECT SUM(montant_ttc) FROM commandes WHERE id_client = 9999;
    -- Résultat : NULL  (pas 0 !)

    -- Solution : COALESCE pour transformer NULL en 0
    SELECT COALESCE(SUM(montant_ttc), 0) AS total_client
    FROM commandes WHERE id_client = 9999;
    -- Résultat : 0

────────────────────────────────────────────────────────────────────────────────
22.4 EXERCICES SUM
────────────────────────────────────────────────────────────────────────────────

EXERCICE 22-1 (Facile)
Calculez la valeur totale du stock actuel (stock × prix_ht pour chaque produit).

SOLUTION :
    SELECT
        SUM(stock * prix_ht)                    AS valeur_stock_totale_ht,
        SUM(stock * prix_ht * (1 + taux_tva/100)) AS valeur_stock_totale_ttc
    FROM produits
    WHERE actif = TRUE;

EXERCICE 22-2 (Moyen)
Pour chaque fournisseur, calculez le chiffre d'affaires total généré,
le nombre de commandes impliquées, et la contribution en pourcentage
du CA total.

SOLUTION :
    WITH ca_par_fournisseur AS (
        SELECT
            f.raison_sociale                       AS fournisseur,
            SUM(lc.quantite * lc.prix_unitaire_ht) AS ca
        FROM fournisseurs f
        JOIN produits p          ON f.id_fournisseur = p.id_fournisseur
        JOIN lignes_commande lc  ON p.id_produit = lc.id_produit
        JOIN commandes co        ON lc.id_commande = co.id_commande
        WHERE co.statut NOT IN ('ANNULEE')
        GROUP BY f.id_fournisseur, f.raison_sociale
    ),
    ca_total AS (SELECT SUM(ca) AS total FROM ca_par_fournisseur)
    SELECT
        cpf.fournisseur,
        ROUND(cpf.ca, 2)                              AS chiffre_affaires,
        ROUND(cpf.ca / ct.total * 100, 1)             AS part_pct
    FROM ca_par_fournisseur cpf, ca_total ct
    ORDER BY cpf.ca DESC;

EXERCICE 22-3 (Avancé)
Calculez le chiffre d'affaires cumulé mois par mois sur 2024 (running total).

SOLUTION :
    SELECT
        TO_CHAR(DATE_TRUNC('month', co.date_commande), 'YYYY-MM') AS mois,
        SUM(lc.quantite * lc.prix_unitaire_ht)                     AS ca_mois,
        SUM(SUM(lc.quantite * lc.prix_unitaire_ht))
            OVER (ORDER BY DATE_TRUNC('month', co.date_commande))  AS ca_cumule
    FROM commandes co
    JOIN lignes_commande lc ON co.id_commande = lc.id_commande
    WHERE co.date_commande >= '2024-01-01'
      AND co.date_commande <  '2025-01-01'
      AND co.statut NOT IN ('ANNULEE')
    GROUP BY DATE_TRUNC('month', co.date_commande)
    ORDER BY DATE_TRUNC('month', co.date_commande);


================================================================================
CHAPITRE 23 : AVG, MIN, MAX — STATISTIQUES DE BASE
================================================================================

────────────────────────────────────────────────────────────────────────────────
23.1 LES FONCTIONS STATISTIQUES FONDAMENTALES
────────────────────────────────────────────────────────────────────────────────

Toutes ignorent les NULL. Toutes retournent NULL si aucune ligne n'existe.

  AVG(colonne)  : Moyenne arithmétique
  MIN(colonne)  : Valeur minimale
  MAX(colonne)  : Valeur maximale
  STDDEV(col)   : Écart-type (population ou échantillon)
  VARIANCE(col) : Variance

────────────────────────────────────────────────────────────────────────────────
23.2 AVG — LA MOYENNE
────────────────────────────────────────────────────────────────────────────────

AVG retourne un DECIMAL (même pour des entiers), jamais arrondi automatiquement.

    SELECT
        ROUND(AVG(prix_ht), 2)      AS prix_moyen,
        ROUND(AVG(stock), 1)        AS stock_moyen,
        ROUND(AVG(taux_tva), 2)     AS tva_moyenne
    FROM produits
    WHERE actif = TRUE;

Panier moyen (montant moyen par commande) :

    SELECT
        ROUND(AVG(montant_ttc), 2)  AS panier_moyen,
        MIN(montant_ttc)            AS panier_min,
        MAX(montant_ttc)            AS panier_max
    FROM commandes
    WHERE statut = 'LIVREE';

Attention AVG vs SUM/COUNT :
    -- AVG(montant) ≡ SUM(montant) / COUNT(montant)
    -- Mais COUNT(montant) ignore les NULL -> résultats différents si NULL présents
    SELECT
        AVG(montant_ttc)                        AS avg_natif,
        SUM(montant_ttc) / COUNT(montant_ttc)   AS avg_manuel_sans_null,
        SUM(montant_ttc) / COUNT(*)             AS avg_manuel_avec_null
    FROM commandes;
    -- Les trois peuvent donner des résultats différents si montant_ttc contient NULL

────────────────────────────────────────────────────────────────────────────────
23.3 MIN / MAX — EXTREMES
────────────────────────────────────────────────────────────────────────────────

MIN et MAX fonctionnent sur tout type ordonnable : nombres, dates, textes.

    -- Première et dernière commande
    SELECT
        MIN(date_commande)  AS premiere_commande,
        MAX(date_commande)  AS derniere_commande,
        MAX(date_commande) - MIN(date_commande) AS duree_activite
    FROM commandes;

    -- Produit le moins cher et le plus cher
    SELECT
        MIN(prix_ht) AS prix_mini,
        MAX(prix_ht) AS prix_maxi,
        MAX(prix_ht) - MIN(prix_ht) AS amplitude_prix
    FROM produits WHERE actif = TRUE;

    -- Récupérer la LIGNE du produit le plus cher (pas juste la valeur)
    SELECT nom, prix_ht
    FROM produits
    WHERE prix_ht = (SELECT MAX(prix_ht) FROM produits WHERE actif = TRUE)
    AND actif = TRUE;

────────────────────────────────────────────────────────────────────────────────
23.4 STATISTIQUES COMPLÈTES EN UNE SEULE REQUÊTE
────────────────────────────────────────────────────────────────────────────────

    SELECT
        cat.nom                                 AS categorie,
        COUNT(p.id_produit)                     AS nb_produits,
        ROUND(MIN(p.prix_ht), 2)                AS prix_min,
        ROUND(MAX(p.prix_ht), 2)                AS prix_max,
        ROUND(AVG(p.prix_ht), 2)                AS prix_moyen,
        ROUND(STDDEV(p.prix_ht), 2)             AS ecart_type,
        ROUND(
            (MAX(p.prix_ht) - MIN(p.prix_ht)) /
            NULLIF(AVG(p.prix_ht), 0) * 100, 1
        )                                       AS coefficient_variation_pct
    FROM produits p
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    WHERE p.actif = TRUE
    GROUP BY cat.id_categorie, cat.nom
    ORDER BY prix_moyen DESC;

────────────────────────────────────────────────────────────────────────────────
23.5 EXERCICES AVG / MIN / MAX
────────────────────────────────────────────────────────────────────────────────

EXERCICE 23-1 (Facile)
Pour les avis de la base ShopFlow, calculez la note moyenne, minimale,
maximale et l'écart-type. Affichez aussi le nombre d'avis.

SOLUTION :
    SELECT
        COUNT(*)           AS nb_avis,
        ROUND(AVG(note), 2) AS note_moyenne,
        MIN(note)           AS note_min,
        MAX(note)           AS note_max,
        ROUND(STDDEV(note), 2) AS ecart_type
    FROM avis;

EXERCICE 23-2 (Moyen)
Calculez l'âge moyen, minimum et maximum de nos clients (utiliser date_naissance).
Exclure les clients sans date de naissance.

SOLUTION :
    SELECT
        ROUND(AVG(EXTRACT(YEAR FROM AGE(date_naissance))), 1) AS age_moyen,
        MIN(EXTRACT(YEAR FROM AGE(date_naissance))::INTEGER)   AS age_min,
        MAX(EXTRACT(YEAR FROM AGE(date_naissance))::INTEGER)   AS age_max
    FROM clients
    WHERE date_naissance IS NOT NULL;

EXERCICE 23-3 (Avancé)
Calculez la note moyenne pondérée par catégorie (la note d'un produit avec
1000 avis doit peser plus qu'un produit avec 1 avis). Formule :
note_ponderee = SUM(note × nb_avis_du_produit) / SUM(nb_avis_du_produit).

SOLUTION :
    WITH stats_produit AS (
        SELECT
            p.id_categorie,
            AVG(a.note)    AS note_moy_produit,
            COUNT(a.id_avis) AS nb_avis_produit
        FROM produits p
        JOIN avis a ON p.id_produit = a.id_produit
        GROUP BY p.id_produit, p.id_categorie
    )
    SELECT
        cat.nom                         AS categorie,
        ROUND(
            SUM(sp.note_moy_produit * sp.nb_avis_produit) /
            NULLIF(SUM(sp.nb_avis_produit), 0),
        2)                              AS note_ponderee,
        ROUND(AVG(sp.note_moy_produit), 2) AS note_simple_non_ponderee,
        SUM(sp.nb_avis_produit)         AS total_avis
    FROM stats_produit sp
    JOIN categories cat ON sp.id_categorie = cat.id_categorie
    GROUP BY cat.id_categorie, cat.nom
    ORDER BY note_ponderee DESC;


================================================================================
CHAPITRE 24 : GROUP BY — REGROUPER LES DONNÉES
================================================================================

────────────────────────────────────────────────────────────────────────────────
24.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

GROUP BY est la clause qui transforme SQL d'un outil de recherche en un
véritable outil d'analyse. Elle regroupe les lignes ayant la même valeur
dans une (ou plusieurs) colonnes, puis applique des fonctions d'agrégation
sur chaque groupe.

Sans GROUP BY, COUNT/SUM/AVG opèrent sur TOUTES les lignes de la table
(ou celles filtrées par WHERE). Avec GROUP BY, ils opèrent groupe par groupe.

Question sans GROUP BY : "Quel est le CA total de la boutique ?"
    -> SELECT SUM(montant_ttc) FROM commandes;

Question avec GROUP BY : "Quel est le CA par client ?"
    -> SELECT id_client, SUM(montant_ttc) FROM commandes GROUP BY id_client;

────────────────────────────────────────────────────────────────────────────────
24.2 RÈGLE FONDAMENTALE DE GROUP BY
────────────────────────────────────────────────────────────────────────────────

RÈGLE ABSOLUE : Dans une requête avec GROUP BY, chaque colonne du SELECT
doit soit être dans le GROUP BY, soit être à l'intérieur d'une fonction
d'agrégation.

    -- [X] ERREUR : 'nom' n'est ni dans GROUP BY, ni agrégé
    SELECT id_categorie, nom, COUNT(*)
    FROM produits
    GROUP BY id_categorie;
    -- ERROR: column "produits.nom" must appear in GROUP BY clause

    -- [OK] CORRECT : 'nom' est aussi dans GROUP BY
    SELECT id_categorie, nom, COUNT(*)
    FROM produits
    GROUP BY id_categorie, nom;

    -- [OK] CORRECT : 'nom' est agrégé avec MIN (valeur arbitraire du groupe)
    SELECT id_categorie, MIN(nom) AS exemple_nom, COUNT(*)
    FROM produits
    GROUP BY id_categorie;

────────────────────────────────────────────────────────────────────────────────
24.3 ORDER D'EXÉCUTION — WHERE AVANT GROUP BY
────────────────────────────────────────────────────────────────────────────────

L'ordre d'exécution dans une requête avec GROUP BY :

    1. FROM + JOIN    -> tables sources
    2. WHERE          -> filtre les LIGNES individuelles (avant regroupement)
    3. GROUP BY       -> regroupe les lignes restantes
    4. HAVING         -> filtre les GROUPES (après regroupement)
    5. SELECT         -> calcule les colonnes (fonctions agrégats incluses)
    6. ORDER BY       -> trie le résultat

Conséquence importante :
    -- [X] ERREUR : WHERE ne peut pas utiliser les fonctions d'agrégation
    SELECT id_client, SUM(montant_ttc)
    FROM commandes
    WHERE SUM(montant_ttc) > 1000  -- SUM n'existe pas encore à cette étape !
    GROUP BY id_client;

    -- [OK] CORRECT : HAVING filtre APRÈS l'agrégation
    SELECT id_client, SUM(montant_ttc)
    FROM commandes
    GROUP BY id_client
    HAVING SUM(montant_ttc) > 1000;

────────────────────────────────────────────────────────────────────────────────
24.4 GROUP BY SUR PLUSIEURS COLONNES
────────────────────────────────────────────────────────────────────────────────

GROUP BY peut grouper sur plusieurs colonnes -> un groupe = une combinaison
unique de toutes les colonnes spécifiées.

    -- CA par client ET par statut de commande
    SELECT
        cl.prenom || ' ' || cl.nom  AS client,
        co.statut,
        COUNT(co.id_commande)       AS nb_commandes,
        SUM(co.montant_ttc)         AS montant_total
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    GROUP BY cl.id_client, cl.prenom, cl.nom, co.statut
    ORDER BY client, co.statut;

    -- Ventes mensuelles par catégorie
    SELECT
        DATE_TRUNC('month', co.date_commande)   AS mois,
        cat.nom                                  AS categorie,
        COUNT(DISTINCT co.id_commande)           AS nb_commandes,
        SUM(lc.quantite)                         AS quantite_vendue,
        SUM(lc.quantite * lc.prix_unitaire_ht)   AS chiffre_affaires
    FROM commandes co
    JOIN lignes_commande lc  ON co.id_commande = lc.id_commande
    JOIN produits p          ON lc.id_produit = p.id_produit
    JOIN categories cat      ON p.id_categorie = cat.id_categorie
    WHERE co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY DATE_TRUNC('month', co.date_commande), cat.id_categorie, cat.nom
    ORDER BY mois, chiffre_affaires DESC;

────────────────────────────────────────────────────────────────────────────────
24.5 GROUP BY AVEC ROLLUP, CUBE, GROUPING SETS
────────────────────────────────────────────────────────────────────────────────

Ces extensions permettent de calculer plusieurs niveaux d'agrégation en une
seule requête, évitant les UNION multiples.

ROLLUP — Totaux hiérarchiques :

    SELECT
        COALESCE(cat.nom, 'TOUS') AS categorie,
        COALESCE(p.nom, 'TOTAL')  AS produit,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS ca
    FROM lignes_commande lc
    JOIN produits p ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    GROUP BY ROLLUP(cat.nom, p.nom)
    ORDER BY cat.nom NULLS LAST, p.nom NULLS LAST;

    -- Résultat :
    -- categorie    | produit        | ca
    -- Informatique | MacBook Pro    | 99958.50
    -- Informatique | Dell XPS       | 52470.00
    -- Informatique | TOTAL          | 152428.50   <- sous-total catégorie
    -- Smartphones  | iPhone 15      | 119900.40
    -- Smartphones  | TOTAL          | 119900.40
    -- TOUS         | TOTAL          | 272328.90   <- grand total

CUBE — Toutes les combinaisons d'agrégation :

    SELECT
        COALESCE(cat.nom, 'TOUTES CATÉGORIES') AS categorie,
        COALESCE(co.statut, 'TOUS STATUTS')    AS statut,
        COUNT(DISTINCT co.id_commande)          AS nb_commandes
    FROM commandes co
    JOIN lignes_commande lc ON co.id_commande = lc.id_commande
    JOIN produits p         ON lc.id_produit = p.id_produit
    JOIN categories cat     ON p.id_categorie = cat.id_categorie
    GROUP BY CUBE(cat.nom, co.statut)
    ORDER BY cat.nom NULLS LAST, co.statut NULLS LAST;

────────────────────────────────────────────────────────────────────────────────
24.6 EXERCICES GROUP BY
────────────────────────────────────────────────────────────────────────────────

EXERCICE 24-1 (Facile)
Calculez le nombre de clients par segment (VIP, PREMIUM, STANDARD, NOUVEAU).
Affichez aussi le pourcentage par segment.

SOLUTION :
    SELECT
        segment,
        COUNT(*)                                  AS nb_clients,
        ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pourcentage
    FROM clients
    GROUP BY segment
    ORDER BY nb_clients DESC;

EXERCICE 24-2 (Facile)
Pour chaque employé (prénom + nom), comptez le nombre de commandes traitées
et calculez le CA total généré.

SOLUTION :
    SELECT
        e.prenom || ' ' || e.nom                  AS employe,
        e.poste,
        COUNT(co.id_commande)                     AS nb_commandes,
        COALESCE(
            ROUND(SUM(co.montant_ttc), 2), 0
        )                                         AS ca_total
    FROM employes e
    LEFT JOIN commandes co ON e.id_employe = co.id_employe
    GROUP BY e.id_employe, e.prenom, e.nom, e.poste
    ORDER BY ca_total DESC;

EXERCICE 24-3 (Moyen)
Calculez les statistiques de vente par jour de la semaine (lundi à dimanche).
Identifier quel jour génère le plus de commandes et le plus de CA.

SOLUTION :
    SELECT
        EXTRACT(DOW FROM date_commande)::INTEGER  AS num_jour,
        TO_CHAR(date_commande, 'Day')              AS jour_semaine,
        COUNT(*)                                   AS nb_commandes,
        ROUND(AVG(montant_ttc), 2)                 AS panier_moyen,
        ROUND(SUM(montant_ttc), 2)                 AS ca_total
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY num_jour, TO_CHAR(date_commande, 'Day')
    ORDER BY ca_total DESC;

EXERCICE 24-4 (Avancé)
Créez un rapport de segmentation RFM (Récence, Fréquence, Montant) simplifié :
pour chaque client, calculez le nombre de jours depuis sa dernière commande
(R), le nombre de commandes (F), et le montant total (M).

SOLUTION :
    SELECT
        cl.id_client,
        cl.prenom || ' ' || cl.nom          AS client,
        cl.segment,
        -- Récence : jours depuis dernière commande
        CURRENT_DATE - MAX(co.date_commande)::DATE AS jours_depuis_derniere_cmd,
        -- Fréquence : nombre de commandes
        COUNT(DISTINCT co.id_commande)       AS frequence,
        -- Montant : CA total
        ROUND(SUM(co.montant_ttc), 2)        AS montant_total,
        -- Score RFM simplifié
        CASE
            WHEN CURRENT_DATE - MAX(co.date_commande)::DATE < 30 THEN 'RÉCENT'
            WHEN CURRENT_DATE - MAX(co.date_commande)::DATE < 90 THEN 'ACTIF'
            ELSE 'INACTIF'
        END                                  AS statut_recence
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    WHERE co.statut NOT IN ('ANNULEE')
    GROUP BY cl.id_client, cl.prenom, cl.nom, cl.segment
    ORDER BY montant_total DESC;


================================================================================
CHAPITRE 25 : HAVING — FILTRER LES GROUPES
================================================================================

────────────────────────────────────────────────────────────────────────────────
25.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

HAVING est au GROUP BY ce que WHERE est aux lignes individuelles. Il filtre
les groupes APRÈS que GROUP BY les a constitués et que les agrégations ont
été calculées.

Règle mémotechnique :
  WHERE  -> filtre les LIGNES avant regroupement (pas d'agrégats)
  HAVING -> filtre les GROUPES après regroupement (peut utiliser les agrégats)

Quand utiliser HAVING vs WHERE ?
  WHERE  : "Je ne veux pas les commandes annulées dans mon calcul."
  HAVING : "Je ne veux que les clients ayant commandé plus de 3 fois."

────────────────────────────────────────────────────────────────────────────────
25.2 SYNTAXE ET EXEMPLES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

HAVING simple :

    -- Clients qui ont dépensé plus de 500€ au total
    SELECT
        cl.prenom || ' ' || cl.nom   AS client,
        COUNT(co.id_commande)        AS nb_commandes,
        SUM(co.montant_ttc)          AS total_depense
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    GROUP BY cl.id_client, cl.prenom, cl.nom
    HAVING SUM(co.montant_ttc) > 500
    ORDER BY total_depense DESC;

HAVING avec plusieurs conditions :

    -- Catégories avec plus de 2 produits ET note moyenne >= 4
    SELECT
        cat.nom                    AS categorie,
        COUNT(p.id_produit)        AS nb_produits,
        ROUND(AVG(a.note), 2)      AS note_moyenne,
        COUNT(a.id_avis)           AS nb_avis
    FROM categories cat
    JOIN produits p ON cat.id_categorie = p.id_categorie
    LEFT JOIN avis a ON p.id_produit = a.id_produit
    GROUP BY cat.id_categorie, cat.nom
    HAVING COUNT(p.id_produit) > 2
       AND AVG(a.note) >= 4.0
    ORDER BY note_moyenne DESC;

WHERE + GROUP BY + HAVING ensemble :

    -- Produits actifs, commandés en 2024, vendus en quantité > 10 unités
    SELECT
        p.nom                               AS produit,
        SUM(lc.quantite)                    AS total_vendu,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS ca
    FROM produits p                          -- FROM
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit  -- JOIN
    JOIN commandes co ON lc.id_commande = co.id_commande     -- JOIN
    WHERE p.actif = TRUE                     -- WHERE : filtre les lignes
      AND co.date_commande >= '2024-01-01'   -- (avant regroupement)
      AND co.date_commande <  '2025-01-01'
    GROUP BY p.id_produit, p.nom             -- GROUP BY : regroupe
    HAVING SUM(lc.quantite) > 10             -- HAVING : filtre les groupes
    ORDER BY total_vendu DESC;               -- ORDER BY : trie

────────────────────────────────────────────────────────────────────────────────
25.3 HAVING SANS GROUP BY (CAS PARTICULIER)
────────────────────────────────────────────────────────────────────────────────

HAVING peut être utilisé sans GROUP BY pour filtrer sur une agrégation
portant sur toute la table.

    -- La table a-t-elle plus de 100 produits ?
    SELECT COUNT(*) AS nb_produits
    FROM produits
    HAVING COUNT(*) > 100;
    -- Retourne 0 ligne si moins de 100 produits, la valeur sinon.

    -- Différent de WHERE COUNT(*) > 100 (qui est invalide)

────────────────────────────────────────────────────────────────────────────────
25.4 OPTIMISATION — HAVING vs WHERE
────────────────────────────────────────────────────────────────────────────────

Règle de performance : quand un filtre ne nécessite PAS d'agrégation,
le mettre dans WHERE plutôt que HAVING. WHERE réduit les lignes AVANT
le regroupement -> moins de travail pour GROUP BY.

    -- [X] Sous-optimal : filtre APRÈS le regroupement
    SELECT id_categorie, COUNT(*)
    FROM produits
    GROUP BY id_categorie
    HAVING id_categorie IN (1, 2, 3);

    -- [OK] Optimal : filtre AVANT le regroupement
    SELECT id_categorie, COUNT(*)
    FROM produits
    WHERE id_categorie IN (1, 2, 3)  -- réduit les lignes à traiter
    GROUP BY id_categorie;

    -- [OK] Les deux ensemble (cas correct)
    SELECT id_categorie, COUNT(*) AS nb
    FROM produits
    WHERE actif = TRUE              -- WHERE : filtre les lignes individuelles
    GROUP BY id_categorie
    HAVING COUNT(*) > 3;           -- HAVING : filtre sur l'agrégat COUNT

────────────────────────────────────────────────────────────────────────────────
25.5 EXERCICES HAVING
────────────────────────────────────────────────────────────────────────────────

EXERCICE 25-1 (Facile)
Trouvez les produits ayant reçu au moins 2 avis, avec leur note moyenne.
Triez par note décroissante.

SOLUTION :
    SELECT
        p.nom                       AS produit,
        COUNT(a.id_avis)            AS nb_avis,
        ROUND(AVG(a.note), 2)       AS note_moyenne
    FROM produits p
    JOIN avis a ON p.id_produit = a.id_produit
    GROUP BY p.id_produit, p.nom
    HAVING COUNT(a.id_avis) >= 2
    ORDER BY note_moyenne DESC;

EXERCICE 25-2 (Moyen)
Trouvez les clients qui ont commandé au moins 2 fois ET dont le panier
moyen est supérieur à 200€. Affichez leur segment et leur historique.

SOLUTION :
    SELECT
        cl.prenom || ' ' || cl.nom    AS client,
        cl.segment,
        COUNT(co.id_commande)         AS nb_commandes,
        ROUND(AVG(co.montant_ttc), 2) AS panier_moyen,
        ROUND(SUM(co.montant_ttc), 2) AS total_depense
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    WHERE co.statut NOT IN ('ANNULEE')
    GROUP BY cl.id_client, cl.prenom, cl.nom, cl.segment
    HAVING COUNT(co.id_commande) >= 2
       AND AVG(co.montant_ttc) > 200
    ORDER BY total_depense DESC;

EXERCICE 25-3 (Moyen)
Identifiez les catégories où le taux de rupture de stock (produits avec
stock = 0 / total produits actifs) dépasse 20%.

SOLUTION :
    SELECT
        cat.nom                          AS categorie,
        COUNT(p.id_produit)              AS total_produits,
        COUNT(p.id_produit) FILTER (WHERE p.stock = 0) AS en_rupture,
        ROUND(
            100.0 * COUNT(p.id_produit) FILTER (WHERE p.stock = 0)
            / COUNT(p.id_produit), 1
        )                                AS taux_rupture_pct
    FROM categories cat
    JOIN produits p ON cat.id_categorie = p.id_categorie
    WHERE p.actif = TRUE
    GROUP BY cat.id_categorie, cat.nom
    HAVING COUNT(p.id_produit) FILTER (WHERE p.stock = 0)
         > COUNT(p.id_produit) * 0.20
    ORDER BY taux_rupture_pct DESC;

EXERCICE 25-4 (Avancé)
Créez un rapport des "séries de commandes" : les mois où le nombre de
commandes est supérieur à la moyenne mensuelle globale. Comparer avec
la moyenne et calculer le delta.

SOLUTION :
    WITH commandes_par_mois AS (
        SELECT
            DATE_TRUNC('month', date_commande)  AS mois,
            COUNT(*)                             AS nb_commandes,
            SUM(montant_ttc)                     AS ca_mois
        FROM commandes
        WHERE statut NOT IN ('ANNULEE')
        GROUP BY DATE_TRUNC('month', date_commande)
    ),
    moyenne_globale AS (
        SELECT AVG(nb_commandes) AS moy_commandes
        FROM commandes_par_mois
    )
    SELECT
        TO_CHAR(cpm.mois, 'Month YYYY') AS periode,
        cpm.nb_commandes,
        ROUND(mg.moy_commandes, 1)       AS moyenne_globale,
        cpm.nb_commandes - mg.moy_commandes AS delta,
        ROUND(cpm.ca_mois, 2)            AS ca_mois
    FROM commandes_par_mois cpm
    CROSS JOIN moyenne_globale mg
    WHERE cpm.nb_commandes > mg.moy_commandes
    ORDER BY cpm.nb_commandes DESC;

EXERCICE 25-5 (Avancé)
Trouvez les fournisseurs "star" : ceux dont tous leurs produits actifs
ont une note moyenne >= 3.5 (et au moins un produit noté). Afficher
le nombre de produits, la note minimale parmi leurs produits.

SOLUTION :
    SELECT
        f.raison_sociale              AS fournisseur,
        f.pays,
        COUNT(DISTINCT p.id_produit)  AS nb_produits_actifs,
        ROUND(MIN(stats.note_moy), 2) AS note_min_produit,
        ROUND(AVG(stats.note_moy), 2) AS note_moy_fournisseur
    FROM fournisseurs f
    JOIN produits p ON f.id_fournisseur = p.id_fournisseur
    JOIN (
        SELECT id_produit, AVG(note) AS note_moy
        FROM avis
        GROUP BY id_produit
    ) stats ON p.id_produit = stats.id_produit
    WHERE p.actif = TRUE
    GROUP BY f.id_fournisseur, f.raison_sociale, f.pays
    HAVING MIN(stats.note_moy) >= 3.5
    ORDER BY note_moy_fournisseur DESC;


================================================================================
SYNTHÈSE DE LA PARTIE 5 — AGRÉGATIONS
================================================================================

TABLEAU RÉCAPITULATIF DES FONCTIONS D'AGRÉGATION
──────────────────────────────────────────────────
  Fonction          | Description                    | NULL ?   | Retourne NULL si 0 ?
  ──────────────────┼────────────────────────────────┼──────────┼─────────────────────
  COUNT(*)          | Toutes les lignes              | Inclus   | Non -> retourne 0
  COUNT(col)        | Lignes non-NULL                | Exclus   | Non -> retourne 0
  COUNT(DISTINCT c) | Valeurs uniques non-NULL       | Exclus   | Non -> retourne 0
  SUM(col)          | Somme                          | Exclus   | Oui -> NULL
  AVG(col)          | Moyenne                        | Exclus   | Oui -> NULL
  MIN(col)          | Minimum                        | Exclus   | Oui -> NULL
  MAX(col)          | Maximum                        | Exclus   | Oui -> NULL
  STDDEV(col)       | Écart-type                     | Exclus   | Oui -> NULL

RÈGLES D'OR
────────────
  1. COUNT(*) pour compter les lignes — COUNT(col) pour les non-NULL
  2. COALESCE(SUM(col), 0) pour éviter NULL dans les résultats
  3. WHERE filtre les LIGNES (avant GROUP BY)
  4. HAVING filtre les GROUPES (après GROUP BY)
  5. Toute colonne dans SELECT sans agrégation -> doit être dans GROUP BY
  6. Mettre les filtres non-agrégés dans WHERE (performance)
  7. COUNT FILTER (WHERE ...) pour plusieurs comptes conditionnels

ORDRE D'EXÉCUTION COMPLET
──────────────────────────
  1. FROM + JOIN   -> tables sources
  2. WHERE         -> filtre lignes individuelles
  3. GROUP BY      -> regroupe
  4. HAVING        -> filtre groupes
  5. SELECT        -> calcule les colonnes (agrégats compris)
  6. DISTINCT      -> déduplique
  7. ORDER BY      -> trie
  8. LIMIT/OFFSET  -> pagine

================================================================================
FIN DE LA PARTIE 5 — AGRÉGATIONS
================================================================================
Chapitres couverts : 21 (COUNT), 22 (SUM), 23 (AVG/MIN/MAX),
                     24 (GROUP BY), 25 (HAVING)
================================================================================


================================================================================
GUIDE SQL COMPLET — PARTIE 6
JOINTURES (INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, SELF JOIN)
Chapitres 26 à 30
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Débutant -> Intermédiaire -> Avancé
================================================================================

TABLE DES MATIÈRES — PARTIE 6
══════════════════════════════
  Chapitre 26 : INNER JOIN — La jointure fondamentale
  Chapitre 27 : LEFT JOIN — Conserver tous les enregistrements de gauche
  Chapitre 28 : RIGHT JOIN — Conserver tous les enregistrements de droite
  Chapitre 29 : FULL OUTER JOIN — L'union complète de deux tables
  Chapitre 30 : SELF JOIN et jointures multiples — Techniques avancées


================================================================================
CHAPITRE 26 : INNER JOIN — LA JOINTURE FONDAMENTALE
================================================================================

────────────────────────────────────────────────────────────────────────────────
26.1 INTRODUCTION — POURQUOI LES JOINTURES EXISTENT
────────────────────────────────────────────────────────────────────────────────

Rappel fondamental : dans une base de données relationnelle normalisée, les
informations sont réparties dans plusieurs tables. Ce n'est pas un défaut,
c'est un choix délibéré pour éviter la redondance et garantir la cohérence.

Exemple concret dans ShopFlow :
  - La table `commandes` stocke les commandes. Elle contient un `client_id`.
  - La table `clients` stocke les clients. Elle contient le nom, l'email, etc.
  - La table `lignes_commande` stocke chaque produit commandé.
  - La table `produits` stocke le nom et le prix des produits.

Si tu veux savoir "Qui a commandé quoi et pour combien ?", tu ne peux pas
répondre avec une seule table. Tu dois COMBINER plusieurs tables. C'est
précisément le rôle des JOIN.

Sans JOIN, tu serais obligé de faire plusieurs requêtes séparées et de
reconstituer les données dans ton application. C'est lent, verbeux, et
source d'erreurs. Les JOIN permettent de faire ce travail directement dans
la base de données, de façon déclarative, optimisée et lisible.


────────────────────────────────────────────────────────────────────────────────
26.2 THÉORIE — PRINCIPE DU INNER JOIN
────────────────────────────────────────────────────────────────────────────────

Le INNER JOIN (ou simplement JOIN) combine deux tables en ne retournant que les
lignes pour lesquelles la condition de jointure est vraie dans LES DEUX tables.

Représentation visuelle (diagramme de Venn) :

    Table A           Table B
   ┌───────┐         ┌───────┐
   │       │         │       │
   │   A   │█████████│   B   │
   │ seul  │█ A ∩ B █│ seul  │
   │       │█████████│       │
   └───────┘         └───────┘
                ^
         Zone retournée
         par INNER JOIN

INNER JOIN = intersection. Seules les lignes qui ont une correspondance dans
les DEUX tables sont incluses dans le résultat.

Règle de base :
  Si une ligne de la table A n'a pas de correspondance dans la table B,
  elle est EXCLUE du résultat.
  Si une ligne de la table B n'a pas de correspondance dans la table A,
  elle est EXCLUE du résultat.

Traduction SQL de la théorie ensembliste :
  INNER JOIN retourne l'INTERSECTION des deux ensembles, définie par la
  condition ON.


────────────────────────────────────────────────────────────────────────────────
26.3 SYNTAXE COMPLÈTE DU INNER JOIN
────────────────────────────────────────────────────────────────────────────────

Syntaxe de base :
```sql
SELECT colonnes
FROM table_gauche
INNER JOIN table_droite ON table_gauche.colonne = table_droite.colonne;
```

Le mot-clé INNER est optionnel. Ces deux requêtes sont identiques :
```sql
SELECT * FROM commandes INNER JOIN clients ON commandes.client_id = clients.client_id;
SELECT * FROM commandes JOIN clients ON commandes.client_id = clients.client_id;
```

Par convention professionnelle, on écrit toujours JOIN (sans INNER) car c'est
la jointure par défaut et la plus courante. Le mot INNER est implicite.

Syntaxe avec alias de table (OBLIGATOIRE dès que tu travailles avec plusieurs
tables pour éviter l'ambiguïté) :
```sql
SELECT
    c.numero_commande,
    cl.nom,
    cl.prenom,
    c.montant_total,
    c.statut
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id;
```

Ici :
  - `c` est l'alias de la table `commandes`
  - `cl` est l'alias de la table `clients`
  - On préfixe chaque colonne par son alias pour lever toute ambiguïté


────────────────────────────────────────────────────────────────────────────────
26.4 PREMIER EXEMPLE CONCRET — COMMANDES ET CLIENTS
────────────────────────────────────────────────────────────────────────────────

Problème : Afficher le numéro de commande, le nom du client et le montant
total pour toutes les commandes.

```sql
-- Requête naïve SANS JOIN (impossible en pratique)
-- La table commandes ne contient que client_id, pas le nom

-- Avec JOIN :
SELECT
    c.numero_commande,
    cl.prenom || ' ' || cl.nom AS client_complet,
    c.montant_total,
    c.statut,
    c.date_commande
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
ORDER BY c.date_commande DESC;
```

Résultat attendu :
  numero_commande | client_complet        | montant_total | statut    | date_commande
  ────────────────┼───────────────────────┼───────────────┼───────────┼──────────────
  CMD-0010        | Sophie Leroy          | 45.00         | livré     | 2024-02-20
  CMD-0009        | Pierre Bernard        | 315.00        | livré     | 2024-02-18
  CMD-0008        | Marie Dupont          | 680.00        | expédié   | 2024-02-15
  CMD-0007        | Jean Martin           | 120.00        | livré     | 2024-02-10
  ...             | ...                   | ...           | ...       | ...

Comment SQL exécute ce JOIN :
  1. Lit chaque ligne de `commandes` (table de gauche)
  2. Pour chaque commande, prend la valeur de `client_id`
  3. Cherche dans `clients` la ligne dont `client_id` correspond
  4. Si trouvé : combine les colonnes des deux lignes -> inclus dans résultat
  5. Si pas trouvé : ligne ignorée (ne peut pas arriver ici car FK est définie)


────────────────────────────────────────────────────────────────────────────────
26.5 JOIN SUR PLUSIEURS TABLES
────────────────────────────────────────────────────────────────────────────────

On peut chaîner autant de JOIN que nécessaire. Chaque JOIN ajoute une table.

Exemple : Afficher les détails complets d'une commande — numéro, client,
produit commandé, quantité, prix unitaire.

```sql
SELECT
    c.numero_commande,
    cl.prenom || ' ' || cl.nom     AS client,
    p.nom_produit,
    lc.quantite,
    lc.prix_unitaire,
    lc.quantite * lc.prix_unitaire AS sous_total
FROM lignes_commande lc
JOIN commandes c  ON lc.commande_id = c.commande_id
JOIN clients cl   ON c.client_id = cl.client_id
JOIN produits p   ON lc.produit_id = p.produit_id
ORDER BY c.numero_commande, p.nom_produit;
```

Explication des 3 JOIN :
  1. `lc JOIN c` : chaque ligne de commande est associée à sa commande
  2. `c JOIN cl` : chaque commande est associée à son client
  3. `lc JOIN p` : chaque ligne de commande est associée au produit concerné

Résultat attendu :
  numero_commande | client        | nom_produit          | quantite | prix_unitaire | sous_total
  ────────────────┼───────────────┼──────────────────────┼──────────┼───────────────┼───────────
  CMD-0001        | Jean Martin   | Laptop ProBook 15    |        1 |       1299.00 |   1299.00
  CMD-0001        | Jean Martin   | Souris Bluetooth Pro |        2 |         45.00 |     90.00
  CMD-0002        | Marie Dupont  | Écran 27" 4K         |        1 |        649.00 |    649.00
  CMD-0003        | Pierre Bernard| Clavier Mécanique RGB|        1 |         89.00 |     89.00
  ...

Règle des JOIN multiples :
  On construit une "chaîne" logique de jointures.
  Chaque nouvelle table doit être reliée à au moins une table déjà présente
  dans la requête via une condition ON valide.


────────────────────────────────────────────────────────────────────────────────
26.6 CONDITION ON COMPLEXE
────────────────────────────────────────────────────────────────────────────────

La condition ON peut contenir plusieurs prédicats avec AND/OR, comme un WHERE.

Exemple : Joindre les stocks d'entrepôt en filtrant sur un entrepôt spécifique
```sql
SELECT
    p.nom_produit,
    se.quantite_disponible,
    e.nom_entrepot
FROM produits p
JOIN stocks_entrepot se ON p.produit_id = se.produit_id
                        AND se.quantite_disponible > 0
JOIN entrepots e ON se.entrepot_id = e.entrepot_id
ORDER BY p.nom_produit;
```

Ici, la condition ON du premier JOIN combine deux prédicats :
  1. La correspondance des IDs (condition de jointure classique)
  2. Un filtre sur la quantité (filtre appliqué pendant la jointure)

Note technique : mettre la condition de filtrage dans le ON ou dans le WHERE
donne le même résultat pour un INNER JOIN. Cependant, pour les LEFT JOIN,
cela fait une différence importante (voir chapitre 27).

Bonne pratique :
  Pour INNER JOIN : placer les filtres dans WHERE (plus lisible)
  Pour OUTER JOIN : la position (ON vs WHERE) change le comportement, il faut
  comprendre la différence (expliqué en détail au chapitre 27).


────────────────────────────────────────────────────────────────────────────────
26.7 USING() — SYNTAXE ALTERNATIVE (quand les colonnes ont le même nom)
────────────────────────────────────────────────────────────────────────────────

Quand les deux tables ont une colonne avec exactement le même nom pour la clé
de jointure, on peut utiliser USING() au lieu de ON.

```sql
-- Avec ON (syntaxe classique)
SELECT c.numero_commande, cl.nom
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id;

-- Avec USING (syntaxe courte, identique au résultat)
SELECT c.numero_commande, cl.nom
FROM commandes c
JOIN clients cl USING (client_id);
```

Différence importante avec ON :
  Avec USING, la colonne `client_id` n'apparaît qu'UNE SEULE FOIS dans le
  résultat (pas de duplication). Avec ON, elle apparaît deux fois si tu fais
  SELECT *.

Préférence professionnelle :
  La syntaxe ON est largement préférée car elle est plus explicite et
  fonctionne même quand les colonnes ont des noms différents.
  USING est une commodité mais rarement utilisée dans du code de production.


────────────────────────────────────────────────────────────────────────────────
26.8 PERFORMANCES DU INNER JOIN
────────────────────────────────────────────────────────────────────────────────

Les JOIN peuvent être coûteux. Voici les mécanismes que PostgreSQL utilise :

Nested Loop Join :
  Pour chaque ligne de la table externe, parcourt toutes les lignes de la
  table interne pour trouver les correspondances.
  Complexité : O(n × m). Efficace quand l'une des tables est petite.

Hash Join :
  Construit une table de hachage sur la table la plus petite, puis parcourt
  la grande table en cherchant dans la table de hachage.
  Complexité : O(n + m). Très efficace pour les grandes tables sans index.

Merge Join :
  Les deux tables sont triées sur la clé de jointure, puis fusionnées.
  Complexité : O(n log n + m log m). Efficace quand les données sont déjà
  triées (index B-tree).

PostgreSQL choisit automatiquement l'algorithme selon les statistiques.
Tu peux voir ce choix avec EXPLAIN.

Rôle des index dans les JOIN :
  Un index B-tree sur la colonne de jointure (clé étrangère) accélère
  considérablement les INNER JOIN.

```sql
-- Ces index sont déjà créés dans ShopFlow (partie 1)
-- Ils rendent les JOIN suivants très rapides :
CREATE INDEX idx_commandes_client     ON commandes(client_id);
CREATE INDEX idx_lignes_commande_cmd  ON lignes_commande(commande_id);
CREATE INDEX idx_lignes_commande_prod ON lignes_commande(produit_id);
```

Bonne pratique : toujours indexer les colonnes de clé étrangère. C'est la
règle numéro 1 pour des JOIN performants.


────────────────────────────────────────────────────────────────────────────────
26.9 ERREURS COURANTES AVEC INNER JOIN
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — Ambiguïté de colonne (column reference is ambiguous)
```sql
-- [X] Erreur : client_id existe dans commandes ET dans clients
SELECT client_id, nom, montant_total
FROM commandes
JOIN clients ON commandes.client_id = clients.client_id;
-- ERROR: column reference "client_id" is ambiguous

-- [OK] Correct : préfixer avec l'alias de table
SELECT c.client_id, cl.nom, c.montant_total
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id;
```

ERREUR 2 — Produit cartésien accidentel (oubli de ON)
```sql
-- [X] JOINTURE CROISÉE INVOLONTAIRE : 10 commandes × 10 clients = 100 lignes !
SELECT * FROM commandes, clients;
-- ou
SELECT * FROM commandes CROSS JOIN clients;

-- [OK] Toujours spécifier la condition ON
SELECT * FROM commandes c JOIN clients cl ON c.client_id = cl.client_id;
```

ERREUR 3 — Lignes manquantes (données orphelines)
```sql
-- Si certains produits n'ont pas de catégorie (categorie_id = NULL),
-- ils seront EXCLUS du résultat du INNER JOIN.

SELECT p.nom_produit, cat.nom_categorie
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id;
-- Les produits sans catégorie disparaissent silencieusement !

-- Solution : utiliser LEFT JOIN si on veut TOUS les produits
-- (voir chapitre 27)
```

ERREUR 4 — Multiplication des lignes (duplication)
```sql
-- Si un client a 3 commandes et qu'on joint avec les lignes_commande,
-- une commande ayant 4 produits génère 4 lignes pour cette commande.
-- Résultat attendu, mais surprenant pour les débutants !

SELECT cl.nom, COUNT(*) AS nb_lignes
FROM clients cl
JOIN commandes c ON cl.client_id = c.client_id
JOIN lignes_commande lc ON c.commande_id = lc.commande_id
GROUP BY cl.nom;
-- Comptage correct des produits commandés par client
```


────────────────────────────────────────────────────────────────────────────────
26.10 EXERCICES INNER JOIN
────────────────────────────────────────────────────────────────────────────────

EXERCICE 26-1 (Facile) — Liste des commandes avec nom du client
Affiche : numéro de commande, prénom + nom du client, date de commande, statut.
Tri : date de commande descendante.

SOLUTION 26-1 :
```sql
SELECT
    c.numero_commande,
    cl.prenom || ' ' || cl.nom AS client_complet,
    c.date_commande,
    c.statut
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
ORDER BY c.date_commande DESC;
```


EXERCICE 26-2 (Facile) — Produits avec leur catégorie
Affiche : nom du produit, prix de vente, nom de la catégorie.
Tri : nom de catégorie, puis nom de produit.

SOLUTION 26-2 :
```sql
SELECT
    p.nom_produit,
    p.prix_vente,
    cat.nom_categorie
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
ORDER BY cat.nom_categorie, p.nom_produit;
```


EXERCICE 26-3 (Facile) — Avis avec le nom du produit et du client
Affiche : nom du produit, prénom du client, note (étoiles), commentaire.
Tri : note descendante.

SOLUTION 26-3 :
```sql
SELECT
    p.nom_produit,
    cl.prenom,
    a.note,
    a.commentaire
FROM avis a
JOIN produits p  ON a.produit_id = p.produit_id
JOIN clients cl  ON a.client_id = cl.client_id
ORDER BY a.note DESC;
```


EXERCICE 26-4 (Moyen) — Chiffre d'affaires par catégorie
Affiche : nom de la catégorie, nombre de produits différents commandés,
          chiffre d'affaires total.
Tri : chiffre d'affaires descendant.

SOLUTION 26-4 :
```sql
SELECT
    cat.nom_categorie,
    COUNT(DISTINCT p.produit_id)            AS nb_produits_vendus,
    SUM(lc.quantite * lc.prix_unitaire)     AS chiffre_affaires
FROM categories cat
JOIN produits p        ON cat.categorie_id = p.categorie_id
JOIN lignes_commande lc ON p.produit_id = lc.produit_id
GROUP BY cat.nom_categorie
ORDER BY chiffre_affaires DESC;
```


EXERCICE 26-5 (Moyen) — Top 5 clients par montant total commandé
Affiche : prénom + nom, nombre de commandes, montant total, montant moyen.
Filtre : commandes avec statut 'livré' uniquement.
Limite : 5 premiers résultats.

SOLUTION 26-5 :
```sql
SELECT
    cl.prenom || ' ' || cl.nom  AS client,
    COUNT(c.commande_id)         AS nb_commandes,
    SUM(c.montant_total)         AS total_depense,
    ROUND(AVG(c.montant_total), 2) AS panier_moyen
FROM clients cl
JOIN commandes c ON cl.client_id = c.client_id
WHERE c.statut = 'livré'
GROUP BY cl.client_id, cl.prenom, cl.nom
ORDER BY total_depense DESC
LIMIT 5;
```


EXERCICE 26-6 (Avancé) — Rapport détaillé des ventes avec fournisseur
Affiche : fournisseur, catégorie, produit, quantité vendue totale,
          chiffre d'affaires, note moyenne du produit.
Inclure uniquement les produits qui ont au moins un avis.
Tri : fournisseur, chiffre d'affaires descendant.

SOLUTION 26-6 :
```sql
SELECT
    f.nom_entreprise                           AS fournisseur,
    cat.nom_categorie                          AS categorie,
    p.nom_produit,
    SUM(lc.quantite)                           AS quantite_vendue,
    SUM(lc.quantite * lc.prix_unitaire)        AS chiffre_affaires,
    ROUND(AVG(a.note), 1)                      AS note_moyenne
FROM fournisseurs f
JOIN produits p        ON p.fournisseur_id = f.fournisseur_id
JOIN categories cat    ON p.categorie_id = cat.categorie_id
JOIN lignes_commande lc ON lc.produit_id = p.produit_id
JOIN avis a            ON a.produit_id = p.produit_id
GROUP BY
    f.fournisseur_id, f.nom_entreprise,
    cat.categorie_id, cat.nom_categorie,
    p.produit_id, p.nom_produit
ORDER BY f.nom_entreprise, chiffre_affaires DESC;
```


================================================================================
CHAPITRE 27 : LEFT JOIN — CONSERVER TOUS LES ENREGISTREMENTS DE GAUCHE
================================================================================

────────────────────────────────────────────────────────────────────────────────
27.1 INTRODUCTION — LE PROBLÈME QUE RÉSOUT LEFT JOIN
────────────────────────────────────────────────────────────────────────────────

Imagine la situation suivante dans ShopFlow :
  - Tu as 10 clients enregistrés.
  - Seulement 8 d'entre eux ont passé au moins une commande.
  - 2 clients se sont inscrits mais n'ont encore rien acheté.

Question : "Affiche tous les clients avec leur nombre de commandes."

Avec INNER JOIN, les 2 clients sans commande disparaissent du résultat.
Pourtant, tu veux les voir avec 0 commande. C'est ici que LEFT JOIN intervient.

Autre situation fréquente :
  - Tous les produits, même ceux jamais commandés.
  - Toutes les catégories, même celles sans produits.
  - Tous les employés, même ceux sans vente assignée.

LEFT JOIN garantit que TOUTES les lignes de la table de gauche (celle citée
avant LEFT JOIN) apparaissent dans le résultat, qu'elles aient ou non une
correspondance dans la table de droite.


────────────────────────────────────────────────────────────────────────────────
27.2 THÉORIE — FONCTIONNEMENT DU LEFT JOIN
────────────────────────────────────────────────────────────────────────────────

Représentation visuelle :

    Table A           Table B
   ┌───────┐         ┌───────┐
   │       │         │       │
   │███████│█████████│       │
   │███████│█ A ∩ B █│ seul  │
   │███████│█████████│       │
   └───────┘         └───────┘
        ^
   Zone retournée
   par LEFT JOIN
   (tout A + intersection)

LEFT JOIN = tout A + les correspondances avec B.

Règle de base :
  - Chaque ligne de la table A (gauche) apparaît dans le résultat.
  - Si une ligne de A a une correspondance dans B : les colonnes de B sont
    remplies avec les vraies valeurs.
  - Si une ligne de A n'a PAS de correspondance dans B : les colonnes de B
    sont remplies avec NULL.

C'est ce comportement NULL qui est la clé du LEFT JOIN.

Nom complet : LEFT OUTER JOIN (OUTER est optionnel, comme INNER).
Ces deux requêtes sont identiques :
```sql
SELECT ... FROM a LEFT JOIN b ON ...
SELECT ... FROM a LEFT OUTER JOIN b ON ...
```


────────────────────────────────────────────────────────────────────────────────
27.3 PREMIER EXEMPLE — CLIENTS AVEC OU SANS COMMANDES
────────────────────────────────────────────────────────────────────────────────

```sql
SELECT
    cl.prenom || ' ' || cl.nom   AS client,
    cl.email,
    COUNT(c.commande_id)          AS nb_commandes,
    COALESCE(SUM(c.montant_total), 0) AS total_depense
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.client_id, cl.prenom, cl.nom, cl.email
ORDER BY total_depense DESC;
```

Résultat attendu (exemple) :
  client              | email                   | nb_commandes | total_depense
  ────────────────────┼─────────────────────────┼──────────────┼──────────────
  Jean Martin         | jean.martin@email.com   |            3 |       1500.00
  Marie Dupont        | marie.dupont@email.com  |            2 |        900.00
  Sophie Leroy        | sophie.leroy@email.com  |            1 |         45.00
  Thomas Moreau       | thomas.moreau@email.com |            0 |          0.00
  Lucie Fontaine      | lucie.fontaine@email.com|            0 |          0.00

Analyse ligne par ligne :
  - Jean Martin, Marie Dupont, Sophie Leroy ont des commandes -> valeurs réelles
  - Thomas Moreau, Lucie Fontaine : pas de commandes -> commande_id = NULL
    COUNT(NULL) = 0 [OK]
    SUM(NULL) = NULL -> COALESCE transforme en 0 [OK]

Détail crucial :
  COUNT(c.commande_id) compte les non-NULL -> 0 pour les clients sans commande
  COUNT(*) compterait 1 pour les clients sans commande (compterait la ligne)
  -> Toujours utiliser COUNT(colonne) et non COUNT(*) avec LEFT JOIN !


────────────────────────────────────────────────────────────────────────────────
27.4 IDENTIFIER LES LIGNES SANS CORRESPONDANCE (technique IS NULL)
────────────────────────────────────────────────────────────────────────────────

Pattern très fréquent : "Trouver les éléments de A qui n'ont PAS de
correspondance dans B."

Application classique : trouver les clients qui n'ont jamais commandé.

```sql
-- MAUVAISE approche (sous-requête souvent moins performante) :
SELECT * FROM clients
WHERE client_id NOT IN (SELECT client_id FROM commandes);

-- BONNE approche (LEFT JOIN + IS NULL) :
SELECT
    cl.prenom || ' ' || cl.nom AS client,
    cl.email,
    cl.date_inscription
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
WHERE c.commande_id IS NULL
ORDER BY cl.date_inscription;
```

Explication du WHERE c.commande_id IS NULL :
  Après le LEFT JOIN, les clients sans commande ont NULL dans toutes les
  colonnes issues de la table commandes. En filtrant sur IS NULL, on ne
  garde que ces clients "orphelins".

Ce pattern s'appelle "anti-jointure" ou "anti-join". C'est l'une des requêtes
les plus utiles en pratique.

Autres exemples d'anti-jointures dans ShopFlow :
```sql
-- Produits jamais commandés
SELECT p.nom_produit, p.prix_vente
FROM produits p
LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
WHERE lc.ligne_id IS NULL
ORDER BY p.nom_produit;

-- Catégories sans produits
SELECT cat.nom_categorie
FROM categories cat
LEFT JOIN produits p ON cat.categorie_id = p.categorie_id
WHERE p.produit_id IS NULL;

-- Fournisseurs sans produits dans le catalogue
SELECT f.nom_entreprise, f.contact_email
FROM fournisseurs f
LEFT JOIN produits p ON f.fournisseur_id = p.fournisseur_id
WHERE p.produit_id IS NULL;
```


────────────────────────────────────────────────────────────────────────────────
27.5 PIÈGE MAJEUR — FILTRE DANS ON VS FILTRE DANS WHERE
────────────────────────────────────────────────────────────────────────────────

C'est l'une des erreurs les plus subtiles avec LEFT JOIN. La position d'un
filtre (dans ON ou dans WHERE) change complètement le comportement.

Scénario : Afficher tous les clients avec leurs commandes "livrées" seulement,
ET conserver les clients sans commande livrée (avec NULL).

```sql
-- Version 1 : filtre dans WHERE (MAUVAISE approche pour ce besoin)
SELECT cl.prenom, cl.nom, c.numero_commande, c.statut
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
WHERE c.statut = 'livré';
-- PROBLÈME : WHERE c.statut = 'livré' transforme le LEFT JOIN en INNER JOIN !
-- Les clients sans commande livrée (qui ont NULL dans statut) sont EXCLUS.
-- NULL = 'livré' est FAUX -> filtré.

-- Version 2 : filtre dans ON (BONNE approche)
SELECT cl.prenom, cl.nom, c.numero_commande, c.statut
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
                      AND c.statut = 'livré';
-- CORRECT : le filtre est appliqué PENDANT la jointure.
-- Si un client n'a pas de commande livrée, la colonne c.* est NULL.
-- Le client apparaît quand même dans le résultat.
```

Résultat version 1 (incorrect si on veut tous les clients) :
  prenom  | nom      | numero_commande | statut
  ────────┼──────────┼─────────────────┼────────
  Jean    | Martin   | CMD-0001        | livré
  Sophie  | Leroy    | CMD-0010        | livré
  (clients sans commande livrée sont absents !)

Résultat version 2 (correct) :
  prenom  | nom       | numero_commande | statut
  ────────┼───────────┼─────────────────┼────────
  Jean    | Martin    | CMD-0001        | livré
  Marie   | Dupont    | NULL            | NULL
  Sophie  | Leroy     | CMD-0010        | livré
  Thomas  | Moreau    | NULL            | NULL
  (tous les clients apparaissent, même sans commande livrée)

RÈGLE D'OR :
  Pour un LEFT JOIN, si tu veux conserver les lignes de la table de gauche
  sans correspondance, les filtres sur la table de DROITE doivent être dans
  la clause ON, pas dans WHERE.
  Les filtres sur la table de GAUCHE peuvent rester dans WHERE.


────────────────────────────────────────────────────────────────────────────────
27.6 EXEMPLE AVANCÉ — RAPPORT COMPLET AVEC VALEURS MANQUANTES
────────────────────────────────────────────────────────────────────────────────

Rapport mensuel des ventes par catégorie, avec les catégories sans vente :

```sql
SELECT
    cat.nom_categorie,
    TO_CHAR(DATE_TRUNC('month', c.date_commande), 'YYYY-MM') AS mois,
    COUNT(DISTINCT c.commande_id)                             AS nb_commandes,
    COALESCE(SUM(lc.quantite * lc.prix_unitaire), 0)         AS chiffre_affaires
FROM categories cat
LEFT JOIN produits p         ON cat.categorie_id = p.categorie_id
LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
LEFT JOIN commandes c        ON lc.commande_id = c.commande_id
                             AND c.date_commande >= '2024-01-01'
                             AND c.date_commande < '2024-03-01'
GROUP BY cat.nom_categorie, mois
ORDER BY cat.nom_categorie, mois;
```

Ce rapport est utile car il montre même les catégories avec 0 vente, ce
qu'un INNER JOIN masquerait.

Note sur les LEFT JOIN chaînés :
  Quand on chaîne plusieurs LEFT JOIN, chaque jointure conserve les lignes
  de la table immédiatement à gauche. Si une ligne disparaît à cause d'un
  LEFT JOIN intermédiaire devenu NULL, les jointures suivantes voient NULL.


────────────────────────────────────────────────────────────────────────────────
27.7 ERREURS COURANTES AVEC LEFT JOIN
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 — Utiliser COUNT(*) au lieu de COUNT(colonne) avec LEFT JOIN
```sql
-- [X] Comptage incorrect
SELECT cl.nom, COUNT(*) AS nb_commandes
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.nom;
-- Un client sans commande retourne 1 (compte la ligne avec NULL)

-- [OK] Comptage correct
SELECT cl.nom, COUNT(c.commande_id) AS nb_commandes
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.nom;
-- COUNT(NULL) = 0. Correct.
```

ERREUR 2 — Oublier COALESCE pour les SUM et AVG
```sql
-- [X] Résultat NULL pour les clients sans commande
SELECT cl.nom, SUM(c.montant_total) AS total
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.nom;
-- Les clients sans commande affichent NULL dans total

-- [OK] Correct
SELECT cl.nom, COALESCE(SUM(c.montant_total), 0) AS total
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.nom;
```

ERREUR 3 — LEFT JOIN transformé en INNER JOIN par le WHERE
(Voir section 27.5 pour le détail complet de ce piège.)


────────────────────────────────────────────────────────────────────────────────
27.8 EXERCICES LEFT JOIN
────────────────────────────────────────────────────────────────────────────────

EXERCICE 27-1 (Facile) — Tous les produits avec leur note moyenne
Affiche : nom du produit, prix de vente, note moyenne (NULL si aucun avis).
Tri : note moyenne descendante (NULL en dernier).

SOLUTION 27-1 :
```sql
SELECT
    p.nom_produit,
    p.prix_vente,
    ROUND(AVG(a.note), 1) AS note_moyenne,
    COUNT(a.avis_id)       AS nb_avis
FROM produits p
LEFT JOIN avis a ON p.produit_id = a.produit_id
GROUP BY p.produit_id, p.nom_produit, p.prix_vente
ORDER BY note_moyenne DESC NULLS LAST;
```


EXERCICE 27-2 (Facile) — Clients qui n'ont jamais commandé
Affiche : prénom, nom, email, date d'inscription.
Tri : date d'inscription.

SOLUTION 27-2 :
```sql
SELECT
    cl.prenom,
    cl.nom,
    cl.email,
    cl.date_inscription
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
WHERE c.commande_id IS NULL
ORDER BY cl.date_inscription;
```


EXERCICE 27-3 (Facile) — Tous les fournisseurs avec leur nombre de produits
Affiche : nom de l'entreprise, pays, nombre de produits (0 si aucun).
Tri : nombre de produits descendant.

SOLUTION 27-3 :
```sql
SELECT
    f.nom_entreprise,
    f.pays,
    COUNT(p.produit_id) AS nb_produits
FROM fournisseurs f
LEFT JOIN produits p ON f.fournisseur_id = p.fournisseur_id
GROUP BY f.fournisseur_id, f.nom_entreprise, f.pays
ORDER BY nb_produits DESC;
```


EXERCICE 27-4 (Moyen) — Produits jamais commandés avec leur stock
Affiche : nom du produit, catégorie, prix, stock total disponible.
Filtrer : uniquement les produits actifs (actif = TRUE).

SOLUTION 27-4 :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    p.prix_vente,
    COALESCE(SUM(se.quantite_disponible), 0) AS stock_total
FROM produits p
JOIN categories cat       ON p.categorie_id = cat.categorie_id
LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
LEFT JOIN stocks_entrepot se ON p.produit_id = se.produit_id
WHERE p.actif = TRUE
  AND lc.ligne_id IS NULL
GROUP BY p.produit_id, p.nom_produit, cat.nom_categorie, p.prix_vente
ORDER BY stock_total DESC;
```


EXERCICE 27-5 (Moyen) — Rapport clients : commandes par statut
Pour chaque client, affiche le nombre de commandes par statut.
Inclure les clients sans commande (avec 0 partout).
Colonnes : client, en_attente, en_traitement, expédié, livré, annulé, total.

SOLUTION 27-5 :
```sql
SELECT
    cl.prenom || ' ' || cl.nom AS client,
    COUNT(c.commande_id) FILTER (WHERE c.statut = 'en_attente')     AS en_attente,
    COUNT(c.commande_id) FILTER (WHERE c.statut = 'en_traitement')  AS en_traitement,
    COUNT(c.commande_id) FILTER (WHERE c.statut = 'expédié')        AS expedie,
    COUNT(c.commande_id) FILTER (WHERE c.statut = 'livré')          AS livre,
    COUNT(c.commande_id) FILTER (WHERE c.statut = 'annulé')         AS annule,
    COUNT(c.commande_id)                                             AS total
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.client_id, cl.prenom, cl.nom
ORDER BY total DESC;
```


EXERCICE 27-6 (Avancé) — Analyse de rétention clients
Trouver les clients qui ont commandé en janvier 2024 mais PAS en février 2024.
Affiche : client, nombre de commandes en janvier, montant total janvier.
Tri : montant total descendant.

SOLUTION 27-6 :
```sql
SELECT
    cl.prenom || ' ' || cl.nom              AS client,
    COUNT(c_jan.commande_id)                AS nb_commandes_janv,
    SUM(c_jan.montant_total)                AS total_janv
FROM clients cl
JOIN commandes c_jan
    ON cl.client_id = c_jan.client_id
    AND c_jan.date_commande >= '2024-01-01'
    AND c_jan.date_commande < '2024-02-01'
LEFT JOIN commandes c_fev
    ON cl.client_id = c_fev.client_id
    AND c_fev.date_commande >= '2024-02-01'
    AND c_fev.date_commande < '2024-03-01'
WHERE c_fev.commande_id IS NULL
GROUP BY cl.client_id, cl.prenom, cl.nom
ORDER BY total_janv DESC;
```


================================================================================
CHAPITRE 28 : RIGHT JOIN — CONSERVER TOUS LES ENREGISTREMENTS DE DROITE
================================================================================

────────────────────────────────────────────────────────────────────────────────
28.1 THÉORIE — PRINCIPE DU RIGHT JOIN
────────────────────────────────────────────────────────────────────────────────

Le RIGHT JOIN est l'exact symétrique du LEFT JOIN. Il conserve toutes les
lignes de la table de DROITE (celle citée après RIGHT JOIN), qu'elles aient
ou non une correspondance dans la table de gauche.

Représentation visuelle :

    Table A           Table B
   ┌───────┐         ┌───────┐
   │       │         │       │
   │       │█████████│███████│
   │ seul  │█ A ∩ B █│███████│
   │       │█████████│███████│
   └───────┘         └───────┘
                          ^
                    Zone retournée
                    par RIGHT JOIN
                    (tout B + intersection)

RIGHT JOIN = tout B + les correspondances avec A.

Règle de base :
  - Chaque ligne de la table B (droite) apparaît dans le résultat.
  - Si une ligne de B n'a pas de correspondance dans A : colonnes de A = NULL.


────────────────────────────────────────────────────────────────────────────────
28.2 RIGHT JOIN EN PRATIQUE — RÉALITÉ DANS L'INDUSTRIE
────────────────────────────────────────────────────────────────────────────────

Vérité importante : le RIGHT JOIN est très peu utilisé dans la pratique.

Pourquoi ? Parce que tout RIGHT JOIN peut être réécrit en LEFT JOIN en
inversant simplement l'ordre des tables. Les deux requêtes suivantes sont
sémantiquement identiques :

```sql
-- Avec RIGHT JOIN
SELECT cl.nom, c.numero_commande
FROM commandes c
RIGHT JOIN clients cl ON c.client_id = cl.client_id;

-- Équivalent avec LEFT JOIN (préféré)
SELECT cl.nom, c.numero_commande
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id;
```

Les développeurs préfèrent toujours réécrire en LEFT JOIN pour une raison de
lisibilité : dans un LEFT JOIN, on lit "je pars de X et je cherche Y", ce qui
correspond à l'ordre mental naturel. Le RIGHT JOIN inverse cet ordre et
déroute les lecteurs.

Cas où RIGHT JOIN peut être utile :
  Quand une requête existante utilise plusieurs JOIN et qu'ajouter une table
  à "droite" est plus pratique que de restructurer toute la requête.

Recommandation professionnelle :
  Évite RIGHT JOIN. Si tu te retrouves à écrire un RIGHT JOIN, inverse l'ordre
  des tables et utilise LEFT JOIN à la place. Le résultat est identique et le
  code est plus lisible.


────────────────────────────────────────────────────────────────────────────────
28.3 EXEMPLE RIGHT JOIN
────────────────────────────────────────────────────────────────────────────────

Exemple : Afficher tous les clients, même ceux sans commandes, avec les
commandes disponibles.

```sql
-- Avec RIGHT JOIN (table de droite = clients = tous conservés)
SELECT
    c.numero_commande,
    c.statut,
    cl.prenom || ' ' || cl.nom AS client
FROM commandes c
RIGHT JOIN clients cl ON c.client_id = cl.client_id
ORDER BY cl.nom;

-- Équivalent avec LEFT JOIN (recommandé)
SELECT
    c.numero_commande,
    c.statut,
    cl.prenom || ' ' || cl.nom AS client
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
ORDER BY cl.nom;
```

Les deux requêtes retournent exactement le même résultat. La version LEFT JOIN
est plus lisible car on commence par la table principale (clients) et on lui
joint les données annexes (commandes).


────────────────────────────────────────────────────────────────────────────────
28.4 EXERCICES RIGHT JOIN
────────────────────────────────────────────────────────────────────────────────

EXERCICE 28-1 (Facile) — Toutes les catégories avec leurs produits (RIGHT JOIN)
Utilise RIGHT JOIN avec categories comme table de droite.
Affiche : nom de la catégorie, nom du produit (NULL si aucun produit).

SOLUTION 28-1 :
```sql
-- Avec RIGHT JOIN
SELECT
    p.nom_produit,
    cat.nom_categorie
FROM produits p
RIGHT JOIN categories cat ON p.categorie_id = cat.categorie_id
ORDER BY cat.nom_categorie, p.nom_produit;

-- Équivalent recommandé avec LEFT JOIN
SELECT
    p.nom_produit,
    cat.nom_categorie
FROM categories cat
LEFT JOIN produits p ON cat.categorie_id = p.categorie_id
ORDER BY cat.nom_categorie, p.nom_produit;
```

Les deux retournent le même résultat. Préférer la version LEFT JOIN.


EXERCICE 28-2 (Moyen) — RIGHT JOIN vers LEFT JOIN : réécriture
Réécrire la requête suivante en utilisant LEFT JOIN (sans changer le résultat).
```sql
-- Requête originale à réécrire
SELECT f.nom_entreprise, p.nom_produit, p.prix_vente
FROM produits p
RIGHT JOIN fournisseurs f ON p.fournisseur_id = f.fournisseur_id
WHERE f.pays = 'France'
ORDER BY f.nom_entreprise;
```

SOLUTION 28-2 :
```sql
-- Réécriture avec LEFT JOIN (résultat identique)
SELECT f.nom_entreprise, p.nom_produit, p.prix_vente
FROM fournisseurs f
LEFT JOIN produits p ON f.fournisseur_id = p.fournisseur_id
WHERE f.pays = 'France'
ORDER BY f.nom_entreprise;
```

Note : On a simplement inversé l'ordre des tables et changé RIGHT en LEFT.


================================================================================
CHAPITRE 29 : FULL OUTER JOIN — L'UNION COMPLÈTE DE DEUX TABLES
================================================================================

────────────────────────────────────────────────────────────────────────────────
29.1 THÉORIE — PRINCIPE DU FULL OUTER JOIN
────────────────────────────────────────────────────────────────────────────────

Le FULL OUTER JOIN combine les comportements de LEFT JOIN et RIGHT JOIN.
Il retourne TOUTES les lignes des DEUX tables, qu'il y ait correspondance ou non.

Représentation visuelle :

    Table A           Table B
   ┌───────┐         ┌───────┐
   │       │         │       │
   │███████│█████████│███████│
   │███████│█ A ∩ B █│███████│
   │███████│█████████│███████│
   └───────┘         └───────┘
        ^                 ^
   Toutes les lignes des deux tables

FULL OUTER JOIN = tout A + tout B (avec correspondances si elles existent).

Règle de base :
  - Lignes avec correspondance : colonnes des deux tables remplies (comme INNER)
  - Lignes de A sans correspondance dans B : colonnes de B = NULL
  - Lignes de B sans correspondance dans A : colonnes de A = NULL


────────────────────────────────────────────────────────────────────────────────
29.2 QUAND UTILISER FULL OUTER JOIN
────────────────────────────────────────────────────────────────────────────────

Le FULL OUTER JOIN est moins courant que LEFT ou INNER JOIN. Il est utile dans
ces situations :

1. Comparaison de deux tables similaires (avant/après migration)
2. Détection des incohérences entre deux sources de données
3. Fusion de données de deux systèmes différents
4. Audit : trouver ce qui est dans A mais pas B, et dans B mais pas A

Cas d'usage concret pour ShopFlow :
  Comparer les produits dans le catalogue (`produits`) vs les produits ayant
  reçu des avis (`avis`). Certains produits peuvent être dans le catalogue
  sans avis, et certains avis peuvent référencer des produits supprimés.


────────────────────────────────────────────────────────────────────────────────
29.3 EXEMPLES FULL OUTER JOIN
────────────────────────────────────────────────────────────────────────────────

Exemple 1 : Réconciliation produits et avis
```sql
SELECT
    p.produit_id       AS id_produit_catalogue,
    p.nom_produit,
    a.produit_id       AS id_produit_avis,
    COUNT(a.avis_id)   AS nb_avis,
    CASE
        WHEN p.produit_id IS NULL THEN 'Avis sans produit (données orphelines)'
        WHEN a.produit_id IS NULL THEN 'Produit sans aucun avis'
        ELSE 'Correspondance normale'
    END AS statut_reconciliation
FROM produits p
FULL OUTER JOIN avis a ON p.produit_id = a.produit_id
GROUP BY p.produit_id, p.nom_produit, a.produit_id
ORDER BY statut_reconciliation, p.nom_produit;
```

Exemple 2 : Comparaison de deux périodes de ventes
```sql
-- Ventes janvier vs ventes février pour chaque catégorie
WITH ventes_jan AS (
    SELECT cat.nom_categorie, SUM(lc.quantite * lc.prix_unitaire) AS ca_jan
    FROM lignes_commande lc
    JOIN commandes c   ON lc.commande_id = c.commande_id
    JOIN produits p    ON lc.produit_id = p.produit_id
    JOIN categories cat ON p.categorie_id = cat.categorie_id
    WHERE c.date_commande >= '2024-01-01' AND c.date_commande < '2024-02-01'
    GROUP BY cat.nom_categorie
),
ventes_fev AS (
    SELECT cat.nom_categorie, SUM(lc.quantite * lc.prix_unitaire) AS ca_fev
    FROM lignes_commande lc
    JOIN commandes c   ON lc.commande_id = c.commande_id
    JOIN produits p    ON lc.produit_id = p.produit_id
    JOIN categories cat ON p.categorie_id = cat.categorie_id
    WHERE c.date_commande >= '2024-02-01' AND c.date_commande < '2024-03-01'
    GROUP BY cat.nom_categorie
)
SELECT
    COALESCE(jan.nom_categorie, fev.nom_categorie) AS categorie,
    COALESCE(jan.ca_jan, 0)                        AS ca_janvier,
    COALESCE(fev.ca_fev, 0)                        AS ca_fevrier,
    COALESCE(fev.ca_fev, 0) - COALESCE(jan.ca_jan, 0) AS evolution
FROM ventes_jan jan
FULL OUTER JOIN ventes_fev fev ON jan.nom_categorie = fev.nom_categorie
ORDER BY evolution DESC;
```

Ce rapport montre :
  - Les catégories vendues les deux mois (avec comparaison)
  - Les catégories vendues en janvier mais pas en février (ca_fevrier = 0)
  - Les catégories vendues en février mais pas en janvier (ca_janvier = 0)


────────────────────────────────────────────────────────────────────────────────
29.4 SIMULER FULL OUTER JOIN DANS LES SGBD QUI NE LE SUPPORTENT PAS
────────────────────────────────────────────────────────────────────────────────

MySQL ne supporte pas FULL OUTER JOIN directement. Pour le simuler :

```sql
-- Simulation de FULL OUTER JOIN en MySQL (et compatible PostgreSQL)
SELECT * FROM a LEFT JOIN b ON a.id = b.id
UNION
SELECT * FROM a RIGHT JOIN b ON a.id = b.id;

-- Plus précis avec UNION ALL + exclusion des doublons
SELECT * FROM a LEFT JOIN b ON a.id = b.id
UNION ALL
SELECT * FROM a RIGHT JOIN b ON a.id = b.id
WHERE a.id IS NULL;
```

En PostgreSQL, utiliser directement FULL OUTER JOIN est toujours préférable
car c'est plus lisible et potentiellement plus performant.


────────────────────────────────────────────────────────────────────────────────
29.5 EXERCICES FULL OUTER JOIN
────────────────────────────────────────────────────────────────────────────────

EXERCICE 29-1 (Moyen) — Audit complet produits et stocks
Utilise FULL OUTER JOIN entre produits et stocks_entrepot.
Affiche : nom du produit (ou "Inconnu"), entrepot_id (ou "Aucun"),
          quantité disponible (0 si NULL), statut de la correspondance.

SOLUTION 29-1 :
```sql
SELECT
    COALESCE(p.nom_produit, 'Produit inconnu')          AS produit,
    COALESCE(se.entrepot_id::TEXT, 'Non stocké')        AS entrepot,
    COALESCE(se.quantite_disponible, 0)                 AS quantite,
    CASE
        WHEN p.produit_id IS NULL  THEN 'Stock sans produit'
        WHEN se.produit_id IS NULL THEN 'Produit non stocké'
        ELSE 'OK'
    END AS statut
FROM produits p
FULL OUTER JOIN stocks_entrepot se ON p.produit_id = se.produit_id
ORDER BY statut, p.nom_produit;
```


EXERCICE 29-2 (Avancé) — Comparaison des ventes Q1 2023 vs Q1 2024
Pour chaque produit, compare le chiffre d'affaires Q1 2023 vs Q1 2024.
Affiche les produits présents dans l'une ou l'autre période (ou les deux).
Inclure la variation en pourcentage.

SOLUTION 29-2 :
```sql
WITH q1_2023 AS (
    SELECT
        p.produit_id,
        p.nom_produit,
        SUM(lc.quantite * lc.prix_unitaire) AS ca_q1_2023
    FROM produits p
    JOIN lignes_commande lc ON p.produit_id = lc.produit_id
    JOIN commandes c        ON lc.commande_id = c.commande_id
    WHERE c.date_commande >= '2023-01-01' AND c.date_commande < '2023-04-01'
    GROUP BY p.produit_id, p.nom_produit
),
q1_2024 AS (
    SELECT
        p.produit_id,
        p.nom_produit,
        SUM(lc.quantite * lc.prix_unitaire) AS ca_q1_2024
    FROM produits p
    JOIN lignes_commande lc ON p.produit_id = lc.produit_id
    JOIN commandes c        ON lc.commande_id = c.commande_id
    WHERE c.date_commande >= '2024-01-01' AND c.date_commande < '2024-04-01'
    GROUP BY p.produit_id, p.nom_produit
)
SELECT
    COALESCE(a.nom_produit, b.nom_produit)  AS produit,
    COALESCE(a.ca_q1_2023, 0)              AS ca_q1_2023,
    COALESCE(b.ca_q1_2024, 0)              AS ca_q1_2024,
    CASE
        WHEN a.ca_q1_2023 IS NULL THEN NULL
        WHEN a.ca_q1_2023 = 0    THEN NULL
        ELSE ROUND(
            (COALESCE(b.ca_q1_2024, 0) - a.ca_q1_2023) / a.ca_q1_2023 * 100,
            1
        )
    END AS variation_pct
FROM q1_2023 a
FULL OUTER JOIN q1_2024 b ON a.produit_id = b.produit_id
ORDER BY ca_q1_2024 DESC NULLS LAST;
```


================================================================================
CHAPITRE 30 : SELF JOIN ET JOINTURES MULTIPLES — TECHNIQUES AVANCÉES
================================================================================

────────────────────────────────────────────────────────────────────────────────
30.1 SELF JOIN — UNE TABLE JOINTE À ELLE-MÊME
────────────────────────────────────────────────────────────────────────────────

Un SELF JOIN consiste à joindre une table avec elle-même. On crée deux alias
différents pour la même table, comme si c'étaient deux tables distinctes.

Quand utilise-t-on un SELF JOIN ?

Cas 1 — Structure hiérarchique (managers et employés) :
  Dans ShopFlow, la table `employes` a un champ `manager_id` qui référence
  `employe_id` dans la même table. Pour afficher "Nom de l'employé — Nom du
  manager", il faut joindre la table avec elle-même.

Cas 2 — Comparaison de lignes de la même table :
  "Trouver les produits dont le prix est supérieur au prix moyen des produits
  de la même catégorie." -> Utilise une sous-requête ou un SELF JOIN.

Cas 3 — Relations de paires :
  "Trouver tous les clients qui habitent dans la même ville."


────────────────────────────────────────────────────────────────────────────────
30.2 SELF JOIN — STRUCTURE HIÉRARCHIQUE DES EMPLOYÉS
────────────────────────────────────────────────────────────────────────────────

La table employés dans ShopFlow :

```sql
-- Rappel de la structure
CREATE TABLE employes (
    employe_id    SERIAL PRIMARY KEY,
    prenom        VARCHAR(50),
    nom           VARCHAR(50),
    poste         VARCHAR(100),
    manager_id    INTEGER REFERENCES employes(employe_id), -- Auto-référence !
    date_embauche DATE,
    salaire       DECIMAL(10,2)
);

-- Données exemples
INSERT INTO employes (prenom, nom, poste, manager_id, date_embauche, salaire)
VALUES
    ('Alice',   'Directeur',  'Directrice Générale',     NULL, '2020-01-15', 8000.00),
    ('Bob',     'Manager',    'Responsable Commercial',   1,   '2020-03-01', 5500.00),
    ('Claire',  'Vendeur',    'Commerciale Senior',       2,   '2021-06-15', 3800.00),
    ('David',   'Vendeur',    'Commercial Junior',        2,   '2022-01-10', 2800.00),
    ('Eve',     'Tech',       'Développeuse Backend',     1,   '2021-02-20', 5200.00);
```

Requête SELF JOIN pour afficher employé et son manager :
```sql
SELECT
    e.prenom || ' ' || e.nom          AS employe,
    e.poste                           AS poste_employe,
    COALESCE(
        m.prenom || ' ' || m.nom,
        'Aucun manager (direction)'
    )                                 AS manager,
    m.poste                           AS poste_manager
FROM employes e
LEFT JOIN employes m ON e.manager_id = m.employe_id
ORDER BY m.employe_id NULLS FIRST, e.prenom;
```

Explications :
  - `e` est l'alias pour "l'employé" (chaque ligne de la table)
  - `m` est l'alias pour "le manager" (autre ligne de la même table)
  - La condition ON relie le `manager_id` de l'employé à l'`employe_id` du manager
  - On utilise LEFT JOIN pour conserver le directeur général (manager_id = NULL)

Résultat attendu :
  employe          | poste_employe          | manager          | poste_manager
  ─────────────────┼────────────────────────┼──────────────────┼──────────────
  Alice Directeur  | Directrice Générale    | Aucun (direction)| NULL
  Bob Manager      | Responsable Commercial | Alice Directeur  | Directrice Générale
  Eve Tech         | Développeuse Backend   | Alice Directeur  | Directrice Générale
  Claire Vendeur   | Commerciale Senior     | Bob Manager      | Resp. Commercial
  David Vendeur    | Commercial Junior      | Bob Manager      | Resp. Commercial


────────────────────────────────────────────────────────────────────────────────
30.3 SELF JOIN — COMPARER DES PRODUITS DE MÊME CATÉGORIE
────────────────────────────────────────────────────────────────────────────────

Trouver les produits dont le prix est supérieur à celui d'au moins un autre
produit dans la même catégorie :

```sql
SELECT DISTINCT
    p1.nom_produit                    AS produit,
    p1.prix_vente                     AS prix,
    p2.nom_produit                    AS produit_moins_cher,
    p2.prix_vente                     AS prix_reference,
    cat.nom_categorie
FROM produits p1
JOIN produits p2    ON p1.categorie_id = p2.categorie_id
                    AND p1.produit_id <> p2.produit_id
                    AND p1.prix_vente > p2.prix_vente
JOIN categories cat ON p1.categorie_id = cat.categorie_id
ORDER BY cat.nom_categorie, p1.prix_vente DESC;
```

Explication :
  - `p1` et `p2` sont deux alias de la même table `produits`
  - La jointure sur `categorie_id` relie les produits de la même catégorie
  - `p1.produit_id <> p2.produit_id` évite de comparer un produit avec lui-même
  - `p1.prix_vente > p2.prix_vente` filtre les produits plus chers que p2


────────────────────────────────────────────────────────────────────────────────
30.4 CROSS JOIN — LE PRODUIT CARTÉSIEN VOLONTAIRE
────────────────────────────────────────────────────────────────────────────────

Le CROSS JOIN retourne le produit cartésien de deux tables : chaque ligne de
la table A est combinée avec chaque ligne de la table B.

```sql
-- 5 catégories × 4 entrepots = 20 lignes
SELECT cat.nom_categorie, e.nom_entrepot
FROM categories cat
CROSS JOIN entrepots e
ORDER BY cat.nom_categorie, e.nom_entrepot;
```

Cas d'usage légitimes du CROSS JOIN :
  1. Générer une grille de combinaisons (tailles × couleurs × produits)
  2. Créer un calendrier (jours × heures)
  3. Générer des données de test
  4. Construire des rapports avec toutes les combinaisons possibles

Exemple concret : rapport de stock avec toutes les combinaisons produit × entrepôt
(même si certaines combinaisons n'ont pas de stock réel) :

```sql
-- Toutes les combinaisons produit × entrepôt, avec stock réel (0 si absent)
SELECT
    p.nom_produit,
    e.nom_entrepot,
    COALESCE(se.quantite_disponible, 0) AS stock
FROM produits p
CROSS JOIN entrepots e
LEFT JOIN stocks_entrepot se
    ON p.produit_id = se.produit_id
    AND e.entrepot_id = se.entrepot_id
ORDER BY p.nom_produit, e.nom_entrepot;
```

Cette requête génère une ligne pour CHAQUE combinaison produit × entrepôt,
même si aucun stock n'existe pour cette paire. C'est utile pour un rapport
de type "matrice de stock".

ATTENTION : Jamais de CROSS JOIN sur de grandes tables sans réfléchir !
1000 produits × 1000 entrepôts = 1 000 000 lignes.


────────────────────────────────────────────────────────────────────────────────
30.5 JOINTURES MULTIPLES COMPLEXES — BONNES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

Quand une requête a 4, 5, 6 JOIN ou plus, voici les règles pour écrire du
code lisible et performant.

Règle 1 — Commencer par la table principale (celle qui a le plus de lignes
          ou celle qui représente le sujet principal de la requête)

Règle 2 — Utiliser des alias courts et mémorables :
```sql
FROM commandes        c    -- c pour commandes
JOIN clients          cl   -- cl pour clients
JOIN lignes_commande  lc   -- lc pour lignes_commande
JOIN produits         p    -- p pour produits
JOIN categories       cat  -- cat pour categories
JOIN fournisseurs     f    -- f pour fournisseurs
JOIN entrepots        e    -- e pour entrepots
JOIN stocks_entrepot  se   -- se pour stocks_entrepot
```

Règle 3 — Aligner les ON pour la lisibilité
```sql
FROM lignes_commande lc
JOIN commandes  c    ON lc.commande_id  = c.commande_id
JOIN clients    cl   ON c.client_id     = cl.client_id
JOIN produits   p    ON lc.produit_id   = p.produit_id
JOIN categories cat  ON p.categorie_id  = cat.categorie_id
```

Règle 4 — Mettre les filtres dans WHERE, sauf si c'est un LEFT JOIN
```sql
FROM lignes_commande lc
JOIN commandes c ON lc.commande_id = c.commande_id
WHERE c.statut = 'livré'
  AND c.date_commande >= '2024-01-01'
```

Règle 5 — Utiliser WITH (CTE) pour décomposer les requêtes complexes
(Voir partie 13 pour les CTE — Common Table Expressions)


────────────────────────────────────────────────────────────────────────────────
30.6 REQUÊTE DE SYNTHÈSE — LE RAPPORT ULTIME SHOPFLOW
────────────────────────────────────────────────────────────────────────────────

Requête qui utilise 6 tables jointes pour produire un rapport complet :

```sql
SELECT
    c.numero_commande,
    TO_CHAR(c.date_commande, 'DD/MM/YYYY')   AS date,
    cl.prenom || ' ' || cl.nom               AS client,
    cl.segment                               AS segment_client,
    cat.nom_categorie                        AS categorie,
    p.nom_produit,
    f.nom_entreprise                         AS fournisseur,
    lc.quantite,
    lc.prix_unitaire,
    lc.quantite * lc.prix_unitaire           AS sous_total,
    c.statut
FROM lignes_commande lc
JOIN commandes  c   ON lc.commande_id  = c.commande_id
JOIN clients    cl  ON c.client_id     = cl.client_id
JOIN produits   p   ON lc.produit_id   = p.produit_id
JOIN categories cat ON p.categorie_id  = cat.categorie_id
JOIN fournisseurs f ON p.fournisseur_id = f.fournisseur_id
WHERE c.date_commande >= '2024-01-01'
ORDER BY c.date_commande DESC, c.numero_commande, p.nom_produit;
```

Cette requête est un exemple typique de ce qu'on trouve dans des outils
de BI (Business Intelligence) pour extraire des données d'un datamart.


────────────────────────────────────────────────────────────────────────────────
30.7 EXERCICES SELF JOIN ET JOINTURES AVANCÉES
────────────────────────────────────────────────────────────────────────────────

EXERCICE 30-1 (Facile) — Organigramme des employés
Affiche : prénom+nom employé, poste, prénom+nom manager, poste manager.
Inclure tous les employés, même ceux sans manager.

SOLUTION 30-1 :
```sql
SELECT
    e.prenom || ' ' || e.nom   AS employe,
    e.poste,
    COALESCE(
        m.prenom || ' ' || m.nom,
        '— Direction —'
    )                           AS manager,
    m.poste                     AS poste_manager
FROM employes e
LEFT JOIN employes m ON e.manager_id = m.employe_id
ORDER BY m.nom NULLS FIRST, e.nom;
```


EXERCICE 30-2 (Moyen) — Produits dans la même catégorie avec prix similaire
Trouver les paires de produits de la même catégorie dont la différence de
prix est inférieure à 50€. Éviter les doublons (A-B et B-A).
Affiche : produit1, prix1, produit2, prix2, catégorie, différence.

SOLUTION 30-2 :
```sql
SELECT
    p1.nom_produit                   AS produit_1,
    p1.prix_vente                    AS prix_1,
    p2.nom_produit                   AS produit_2,
    p2.prix_vente                    AS prix_2,
    cat.nom_categorie,
    ABS(p1.prix_vente - p2.prix_vente) AS difference_prix
FROM produits p1
JOIN produits p2    ON p1.categorie_id = p2.categorie_id
                    AND p1.produit_id < p2.produit_id  -- évite les doublons
JOIN categories cat ON p1.categorie_id = cat.categorie_id
WHERE ABS(p1.prix_vente - p2.prix_vente) < 50
ORDER BY cat.nom_categorie, difference_prix;
```

Note : `p1.produit_id < p2.produit_id` garantit que chaque paire (A, B)
n'apparaît qu'une fois et pas aussi sous la forme (B, A).


EXERCICE 30-3 (Moyen) — Matrice de stock complète (CROSS JOIN)
Affiche une ligne par combinaison produit × entrepôt.
Colonnes : produit, entrepôt, stock disponible (0 si absent), statut stock
(Rupture = 0, Faible <= 5, Normal > 5).

SOLUTION 30-3 :
```sql
SELECT
    p.nom_produit,
    e.nom_entrepot,
    COALESCE(se.quantite_disponible, 0)     AS stock,
    CASE
        WHEN COALESCE(se.quantite_disponible, 0) = 0  THEN 'Rupture'
        WHEN COALESCE(se.quantite_disponible, 0) <= 5 THEN 'Faible'
        ELSE 'Normal'
    END                                      AS statut_stock
FROM produits p
CROSS JOIN entrepots e
LEFT JOIN stocks_entrepot se
    ON p.produit_id  = se.produit_id
    AND e.entrepot_id = se.entrepot_id
WHERE p.actif = TRUE
ORDER BY p.nom_produit, e.nom_entrepot;
```


EXERCICE 30-4 (Avancé) — Rapport hiérarchique des ventes par manager
Pour chaque manager, affiche le total des ventes générées par son équipe
(tous les employés qu'il supervise directement).
Colonnes : manager, équipe (liste des noms), nb_commandes équipe, CA équipe.

SOLUTION 30-4 :
```sql
SELECT
    m.prenom || ' ' || m.nom         AS manager,
    STRING_AGG(
        e.prenom || ' ' || e.nom,
        ', ' ORDER BY e.nom
    )                                 AS equipe,
    COUNT(DISTINCT c.commande_id)     AS nb_commandes_equipe,
    COALESCE(SUM(c.montant_total), 0) AS ca_equipe
FROM employes m
JOIN employes e    ON e.manager_id = m.employe_id
LEFT JOIN commandes c ON c.employe_id = e.employe_id
GROUP BY m.employe_id, m.prenom, m.nom
ORDER BY ca_equipe DESC;
```


EXERCICE 30-5 (Avancé) — Analyse croisée : clients × catégories
Pour chaque combinaison client × catégorie de produit, affiche le nombre
d'achats et le montant total. Inclure les combinaisons sans achats (0).
Limiter aux 5 premiers clients par montant total tous produits confondus.

SOLUTION 30-5 :
```sql
-- Étape 1 : identifier les 5 meilleurs clients
WITH top_clients AS (
    SELECT cl.client_id
    FROM clients cl
    JOIN commandes c ON cl.client_id = c.client_id
    GROUP BY cl.client_id
    ORDER BY SUM(c.montant_total) DESC
    LIMIT 5
)
-- Étape 2 : croiser ces clients avec toutes les catégories
SELECT
    cl.prenom || ' ' || cl.nom               AS client,
    cat.nom_categorie,
    COUNT(lc.ligne_id)                        AS nb_achats,
    COALESCE(SUM(lc.quantite * lc.prix_unitaire), 0) AS montant_total
FROM clients cl
CROSS JOIN categories cat
LEFT JOIN commandes c        ON cl.client_id = c.client_id
LEFT JOIN lignes_commande lc ON c.commande_id = lc.commande_id
LEFT JOIN produits p         ON lc.produit_id = p.produit_id
                             AND p.categorie_id = cat.categorie_id
WHERE cl.client_id IN (SELECT client_id FROM top_clients)
GROUP BY cl.client_id, cl.prenom, cl.nom, cat.categorie_id, cat.nom_categorie
ORDER BY client, montant_total DESC;
```


================================================================================
SYNTHÈSE DE LA PARTIE 6 — LES JOINTURES SQL
================================================================================

TABLEAU RÉCAPITULATIF DES TYPES DE JOIN
──────────────────────────────────────────
  Type de JOIN         | Ce qui est retourné                    | Utilisation
  ─────────────────────┼────────────────────────────────────────┼────────────────
  INNER JOIN (JOIN)    | Lignes avec correspondance dans les 2  | Jointure standard
  LEFT JOIN            | Toutes lignes de gauche + corresp.     | Inclure les NULL
  RIGHT JOIN           | Toutes lignes de droite + corresp.     | Rare (-> LEFT JOIN)
  FULL OUTER JOIN      | Toutes lignes des 2 tables             | Audit, comparaison
  CROSS JOIN           | Produit cartésien (A × B)              | Matrice, combos
  SELF JOIN            | Table jointe à elle-même               | Hiérarchies, paires

RÈGLES D'OR À MÉMORISER
─────────────────────────
  1. JOIN = INNER JOIN (par défaut, le plus courant)
  2. LEFT JOIN pour "tous les A avec les B optionnels"
  3. WHERE colonne IS NULL après LEFT JOIN = anti-jointure (lignes sans corresp.)
  4. Filtre sur table droite -> dans ON, pas WHERE (sinon LEFT JOIN devient INNER)
  5. COUNT(colonne) et non COUNT(*) après LEFT JOIN
  6. COALESCE(SUM(...), 0) pour les montants avec LEFT JOIN
  7. Toujours utiliser des alias de table (c, cl, p, cat...)
  8. Aligner les ON pour la lisibilité
  9. RIGHT JOIN -> toujours réécrire en LEFT JOIN (invertir les tables)
  10. SELF JOIN nécessite deux alias différents pour la même table

ORDRE MENTAL POUR ÉCRIRE UN JOIN
──────────────────────────────────
  1. Quelle est ma table principale ? (FROM)
  2. De quelle(s) autre(s) table(s) ai-je besoin ? (JOIN)
  3. Quel est le lien entre ces tables ? (ON)
  4. Dois-je conserver les lignes sans correspondance ? (LEFT / FULL)
  5. Quels filtres appliqués ? (WHERE — ou ON pour LEFT JOIN)

PERFORMANCES
─────────────
```sql
-- Index indispensables pour les JOIN rapides dans ShopFlow :
CREATE INDEX IF NOT EXISTS idx_commandes_client       ON commandes(client_id);
CREATE INDEX IF NOT EXISTS idx_lignes_cmd_commande    ON lignes_commande(commande_id);
CREATE INDEX IF NOT EXISTS idx_lignes_cmd_produit     ON lignes_commande(produit_id);
CREATE INDEX IF NOT EXISTS idx_produits_categorie     ON produits(categorie_id);
CREATE INDEX IF NOT EXISTS idx_produits_fournisseur   ON produits(fournisseur_id);
CREATE INDEX IF NOT EXISTS idx_avis_produit           ON avis(produit_id);
CREATE INDEX IF NOT EXISTS idx_avis_client            ON avis(client_id);
CREATE INDEX IF NOT EXISTS idx_stocks_produit         ON stocks_entrepot(produit_id);
CREATE INDEX IF NOT EXISTS idx_stocks_entrepot        ON stocks_entrepot(entrepot_id);
```

================================================================================
FIN DE LA PARTIE 6 — JOINTURES SQL
================================================================================
Chapitres couverts : 26 (INNER JOIN), 27 (LEFT JOIN), 28 (RIGHT JOIN),
                     29 (FULL OUTER JOIN), 30 (SELF JOIN et avancé)
Prochaine partie : PARTIE 7 — SOUS-REQUÊTES (Subqueries, EXISTS, IN)
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 7
SOUS-REQUÊTES (SUBQUERIES, EXISTS, IN, CORRÉLÉES)
Chapitres 31 à 33
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Intermédiaire -> Avancé
================================================================================

TABLE DES MATIÈRES — PARTIE 7
══════════════════════════════
  Chapitre 31 : Sous-requêtes scalaires et dans WHERE
  Chapitre 32 : Sous-requêtes avec IN, NOT IN, EXISTS, NOT EXISTS
  Chapitre 33 : Sous-requêtes corrélées et dans FROM (tables dérivées)


================================================================================
CHAPITRE 31 : SOUS-REQUÊTES SCALAIRES ET DANS WHERE
================================================================================

────────────────────────────────────────────────────────────────────────────────
31.1 INTRODUCTION — QU'EST-CE QU'UNE SOUS-REQUÊTE ?
────────────────────────────────────────────────────────────────────────────────

Une sous-requête (subquery, ou sous-sélection) est une requête SELECT imbriquée
à l'intérieur d'une autre requête SQL. Elle est toujours encadrée par des
parenthèses.

Pourquoi les sous-requêtes existent-elles ?

Problème concret : "Affiche les produits dont le prix est supérieur au prix
moyen de tous les produits."

Pour répondre, tu as besoin de deux informations :
  1. Le prix moyen de tous les produits (un nombre)
  2. La liste des produits dont le prix dépasse ce nombre

Sans sous-requête, tu ferais deux requêtes séparées :
```sql
-- Étape 1 : calculer la moyenne
SELECT AVG(prix_vente) FROM produits;  -- Résultat : 350.00 (exemple)

-- Étape 2 : utiliser cette valeur manuellement
SELECT nom_produit, prix_vente FROM produits WHERE prix_vente > 350.00;
```

Avec une sous-requête, on fait tout en une seule requête dynamique :
```sql
SELECT nom_produit, prix_vente
FROM produits
WHERE prix_vente > (SELECT AVG(prix_vente) FROM produits);
```

Avantages des sous-requêtes :
  - Une seule requête = une seule aller-retour avec la base de données
  - Dynamique : si le prix moyen change, la requête s'adapte automatiquement
  - Lisible : on exprime le problème en SQL sans valeurs hardcodées
  - Composable : on peut imbriquer plusieurs niveaux de sous-requêtes


────────────────────────────────────────────────────────────────────────────────
31.2 CLASSIFICATION DES SOUS-REQUÊTES
────────────────────────────────────────────────────────────────────────────────

Les sous-requêtes se classifient selon deux dimensions :

Selon leur RÉSULTAT :
  - Scalaire : retourne exactement 1 valeur (1 ligne, 1 colonne)
  - Colonne  : retourne plusieurs lignes, 1 colonne
  - Ligne    : retourne 1 ligne, plusieurs colonnes
  - Table    : retourne plusieurs lignes et colonnes

Selon leur DÉPENDANCE à la requête externe :
  - Non corrélée : indépendante, s'exécute une seule fois
  - Corrélée     : dépend de la requête externe, s'exécute pour chaque ligne

Selon leur POSITION dans la requête :
  - Dans WHERE : filtre basé sur la sous-requête
  - Dans SELECT : colonne calculée par la sous-requête
  - Dans FROM : table dérivée utilisée comme source
  - Dans HAVING : filtre sur agrégation
  - Dans INSERT/UPDATE/DELETE : manipulation de données conditionnelle


────────────────────────────────────────────────────────────────────────────────
31.3 SOUS-REQUÊTE SCALAIRE — RETOURNE UNE SEULE VALEUR
────────────────────────────────────────────────────────────────────────────────

Une sous-requête scalaire retourne exactement 1 valeur (1 ligne × 1 colonne).
Elle peut être utilisée partout où une valeur scalaire est attendue.

Exemple 1 : Produits au-dessus du prix moyen
```sql
SELECT
    nom_produit,
    prix_vente,
    ROUND(prix_vente - (SELECT AVG(prix_vente) FROM produits), 2) AS ecart_moyenne
FROM produits
WHERE prix_vente > (SELECT AVG(prix_vente) FROM produits)
ORDER BY prix_vente DESC;
```

Exemple 2 : Commande avec le montant le plus élevé
```sql
SELECT
    c.numero_commande,
    cl.prenom || ' ' || cl.nom AS client,
    c.montant_total,
    c.date_commande
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
WHERE c.montant_total = (SELECT MAX(montant_total) FROM commandes);
```

Note importante : si plusieurs commandes ont le même montant maximum, toutes
seront retournées (contrairement à LIMIT 1 qui n'en prendrait qu'une).

Exemple 3 : Sous-requête scalaire dans le SELECT (colonne calculée)
```sql
SELECT
    nom_produit,
    prix_vente,
    (SELECT AVG(prix_vente) FROM produits)         AS prix_moyen_global,
    ROUND(
        prix_vente / (SELECT AVG(prix_vente) FROM produits) * 100 - 100,
        1
    )                                               AS pct_au_dessus_moyenne
FROM produits
ORDER BY prix_vente DESC;
```

Attention sur les performances :
  La sous-requête dans SELECT s'exécute pour CHAQUE ligne du résultat.
  Si la table a 10 000 produits, la sous-requête AVG s'exécute 10 000 fois.
  PostgreSQL optimise souvent cela (matérialisation), mais il faut être
  conscient de ce comportement. Alternativement, utilise une CTE (partie 13).


────────────────────────────────────────────────────────────────────────────────
31.4 SOUS-REQUÊTES DANS HAVING
────────────────────────────────────────────────────────────────────────────────

Les sous-requêtes dans HAVING permettent de filtrer des groupes en fonction
d'agrégations calculées dynamiquement.

Exemple : Catégories dont le chiffre d'affaires est supérieur à la moyenne
des chiffres d'affaires par catégorie.

```sql
SELECT
    cat.nom_categorie,
    SUM(lc.quantite * lc.prix_unitaire) AS chiffre_affaires
FROM categories cat
JOIN produits p         ON cat.categorie_id = p.categorie_id
JOIN lignes_commande lc ON p.produit_id = lc.produit_id
GROUP BY cat.nom_categorie
HAVING SUM(lc.quantite * lc.prix_unitaire) > (
    -- Sous-requête : CA moyen par catégorie
    SELECT AVG(ca_par_categorie)
    FROM (
        SELECT SUM(lc2.quantite * lc2.prix_unitaire) AS ca_par_categorie
        FROM lignes_commande lc2
        JOIN produits p2 ON lc2.produit_id = p2.produit_id
        GROUP BY p2.categorie_id
    ) AS sous_agregation
)
ORDER BY chiffre_affaires DESC;
```

Décomposition de la logique :
  1. La requête principale group by catégorie et calcule le CA de chacune
  2. La sous-requête interne (dans HAVING) calcule la moyenne des CA
     en utilisant une table dérivée (sous-requête dans FROM)
  3. HAVING filtre les catégories dont le CA dépasse cette moyenne


────────────────────────────────────────────────────────────────────────────────
31.5 SOUS-REQUÊTES NON CORRÉLÉES VS CORRÉLÉES
────────────────────────────────────────────────────────────────────────────────

Sous-requête NON corrélée :
  Ne fait aucune référence à la requête externe. S'exécute une seule fois,
  avant la requête principale. Le résultat est réutilisé pour chaque ligne.

```sql
-- NON CORRÉLÉE : AVG(prix_vente) ne dépend pas de la ligne courante
SELECT nom_produit, prix_vente
FROM produits
WHERE prix_vente > (SELECT AVG(prix_vente) FROM produits);
--                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
--                  S'exécute UNE SEULE FOIS -> très efficace
```

Sous-requête CORRÉLÉE :
  Fait référence à une colonne de la requête externe. S'exécute pour CHAQUE
  ligne de la requête externe. Plus puissante mais potentiellement plus lente.

```sql
-- CORRÉLÉE : référence à p.categorie_id (table externe)
SELECT p.nom_produit, p.prix_vente, p.categorie_id
FROM produits p
WHERE p.prix_vente > (
    SELECT AVG(p2.prix_vente)
    FROM produits p2
    WHERE p2.categorie_id = p.categorie_id  -- <- référence à la requête externe
);
-- Pour chaque produit p, cette sous-requête s'exécute avec le categorie_id de p
-- Si 15 produits -> sous-requête exécutée 15 fois
```

Ce dernier exemple fait quelque chose d'impossible avec une sous-requête non
corrélée : calculer la moyenne de prix PAR catégorie et comparer chaque produit
à la moyenne de SA propre catégorie.

Performance des sous-requêtes corrélées :
  Pour de grandes tables, les sous-requêtes corrélées peuvent être lentes.
  La solution est souvent de les réécrire avec un JOIN ou une CTE.
  PostgreSQL est intelligent et peut parfois optimiser automatiquement.


────────────────────────────────────────────────────────────────────────────────
31.6 EXERCICES SOUS-REQUÊTES SCALAIRES
────────────────────────────────────────────────────────────────────────────────

EXERCICE 31-1 (Facile) — Produit le moins cher de chaque catégorie
Pour chaque catégorie, affiche le produit le moins cher.
(Utilise une sous-requête dans WHERE)

SOLUTION 31-1 :
```sql
SELECT p.nom_produit, p.prix_vente, cat.nom_categorie
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
WHERE p.prix_vente = (
    SELECT MIN(p2.prix_vente)
    FROM produits p2
    WHERE p2.categorie_id = p.categorie_id  -- sous-requête corrélée
)
ORDER BY cat.nom_categorie;
```


EXERCICE 31-2 (Facile) — Clients avec un montant total supérieur à la moyenne
Affiche les clients dont la somme de toutes leurs commandes dépasse le montant
moyen de commande par client.
Colonnes : client, total dépensé, moyenne globale, écart.

SOLUTION 31-2 :
```sql
SELECT
    cl.prenom || ' ' || cl.nom       AS client,
    SUM(c.montant_total)              AS total_depense,
    (SELECT AVG(total_par_client)
     FROM (
         SELECT client_id, SUM(montant_total) AS total_par_client
         FROM commandes
         GROUP BY client_id
     ) AS t)                          AS moyenne_globale,
    SUM(c.montant_total) - (
        SELECT AVG(total_par_client)
        FROM (
            SELECT client_id, SUM(montant_total) AS total_par_client
            FROM commandes
            GROUP BY client_id
        ) AS t
    )                                 AS ecart
FROM clients cl
JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.client_id, cl.prenom, cl.nom
HAVING SUM(c.montant_total) > (
    SELECT AVG(total_par_client)
    FROM (
        SELECT client_id, SUM(montant_total) AS total_par_client
        FROM commandes
        GROUP BY client_id
    ) AS t
)
ORDER BY total_depense DESC;
```


EXERCICE 31-3 (Moyen) — Produits au-dessus de la moyenne de leur catégorie
Pour chaque produit, affiche s'il est au-dessus ou en dessous de la moyenne
de prix de sa catégorie. Colonnes : produit, catégorie, prix, moyenne catégorie,
écart, positionnement (Au-dessus / En dessous / Dans la moyenne).

SOLUTION 31-3 :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    p.prix_vente,
    ROUND((
        SELECT AVG(p2.prix_vente)
        FROM produits p2
        WHERE p2.categorie_id = p.categorie_id
    ), 2)                              AS prix_moyen_categorie,
    ROUND(p.prix_vente - (
        SELECT AVG(p2.prix_vente)
        FROM produits p2
        WHERE p2.categorie_id = p.categorie_id
    ), 2)                              AS ecart,
    CASE
        WHEN p.prix_vente > (SELECT AVG(p2.prix_vente) FROM produits p2
                             WHERE p2.categorie_id = p.categorie_id) * 1.05
        THEN 'Au-dessus'
        WHEN p.prix_vente < (SELECT AVG(p2.prix_vente) FROM produits p2
                             WHERE p2.categorie_id = p.categorie_id) * 0.95
        THEN 'En dessous'
        ELSE 'Dans la moyenne'
    END                                AS positionnement
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
ORDER BY cat.nom_categorie, p.prix_vente DESC;
```


================================================================================
CHAPITRE 32 : IN, NOT IN, EXISTS, NOT EXISTS
================================================================================

────────────────────────────────────────────────────────────────────────────────
32.1 OPÉRATEUR IN AVEC SOUS-REQUÊTE
────────────────────────────────────────────────────────────────────────────────

L'opérateur IN permet de tester si une valeur est présente dans une liste.
Cette liste peut être définie statiquement ou dynamiquement via une sous-requête.

Rappel IN statique (déjà vu en partie 4) :
```sql
SELECT * FROM commandes WHERE statut IN ('livré', 'expédié');
```

IN avec sous-requête :
```sql
-- Trouver les clients qui ont passé au moins une commande livrée
SELECT cl.prenom, cl.nom, cl.email
FROM clients cl
WHERE cl.client_id IN (
    SELECT DISTINCT c.client_id
    FROM commandes c
    WHERE c.statut = 'livré'
)
ORDER BY cl.nom;
```

Comment SQL évalue IN avec sous-requête :
  1. Exécute la sous-requête -> liste de client_id : {1, 3, 5, 7, 8}
  2. Pour chaque ligne de `clients`, vérifie si `client_id` est dans cette liste
  3. Inclus dans le résultat si OUI, exclu si NON

Exemple plus complexe : clients qui ont commandé un produit d'une catégorie
spécifique.
```sql
SELECT DISTINCT cl.prenom, cl.nom
FROM clients cl
WHERE cl.client_id IN (
    SELECT c.client_id
    FROM commandes c
    JOIN lignes_commande lc ON c.commande_id = lc.commande_id
    JOIN produits p         ON lc.produit_id = p.produit_id
    JOIN categories cat     ON p.categorie_id = cat.categorie_id
    WHERE cat.nom_categorie = 'Informatique'
)
ORDER BY cl.nom;
```


────────────────────────────────────────────────────────────────────────────────
32.2 OPÉRATEUR NOT IN — ANTI-JOINTURE PAR LISTE
────────────────────────────────────────────────────────────────────────────────

NOT IN sélectionne les lignes dont la valeur n'est PAS dans la liste.

```sql
-- Clients qui n'ont JAMAIS commandé un produit informatique
SELECT cl.prenom, cl.nom
FROM clients cl
WHERE cl.client_id NOT IN (
    SELECT DISTINCT c.client_id
    FROM commandes c
    JOIN lignes_commande lc ON c.commande_id = lc.commande_id
    JOIN produits p         ON lc.produit_id = p.produit_id
    JOIN categories cat     ON p.categorie_id = cat.categorie_id
    WHERE cat.nom_categorie = 'Informatique'
)
ORDER BY cl.nom;
```

PIÈGE MAJEUR DE NOT IN — COMPORTEMENT AVEC NULL :

NOT IN avec NULL dans la liste retourne ZÉRO lignes. Toujours.

```sql
-- Exemple dangereux
SELECT * FROM produits
WHERE categorie_id NOT IN (1, 2, NULL);
-- Retourne 0 lignes ! Car : x NOT IN (..., NULL) ≡ x != 1 AND x != 2 AND x != NULL
-- x != NULL est toujours UNKNOWN (jamais TRUE) -> filtre tout
```

Solution : toujours filtrer les NULL dans la sous-requête NOT IN :
```sql
-- Sécurisé avec WHERE ... IS NOT NULL
SELECT cl.prenom, cl.nom
FROM clients cl
WHERE cl.client_id NOT IN (
    SELECT c.client_id
    FROM commandes c
    WHERE c.client_id IS NOT NULL  -- protection contre les NULL
)
ORDER BY cl.nom;
```

Recommandation professionnelle :
  Préférer NOT EXISTS à NOT IN pour les sous-requêtes, car NOT EXISTS se
  comporte correctement avec les NULL (voir section 32.4).


────────────────────────────────────────────────────────────────────────────────
32.3 OPÉRATEUR EXISTS
────────────────────────────────────────────────────────────────────────────────

EXISTS retourne TRUE si la sous-requête retourne au moins une ligne,
FALSE sinon. Il ne regarde pas les valeurs, juste l'existence.

Syntaxe :
```sql
SELECT ...
FROM table_principale
WHERE EXISTS (
    SELECT 1  -- la valeur n'a pas d'importance
    FROM autre_table
    WHERE condition_de_lien
);
```

Exemple : clients qui ont passé au moins une commande
```sql
SELECT cl.prenom, cl.nom, cl.email
FROM clients cl
WHERE EXISTS (
    SELECT 1
    FROM commandes c
    WHERE c.client_id = cl.client_id  -- sous-requête corrélée
)
ORDER BY cl.nom;
```

SELECT 1 dans la sous-requête EXISTS :
  Par convention, on écrit `SELECT 1` car EXISTS ne regarde que si une ligne
  existe, pas ce que cette ligne contient. `SELECT *`, `SELECT 42`,
  `SELECT NULL` fonctionneraient aussi, mais `SELECT 1` est la convention
  standard pour signaler clairement l'intention.

EXISTS est une sous-requête CORRÉLÉE :
  Elle fait référence à `cl.client_id` de la requête externe.
  Pour chaque client cl, PostgreSQL vérifie s'il existe une commande
  avec ce client_id. Dès qu'une ligne est trouvée, la vérification s'arrête
  (court-circuit). C'est ce qui rend EXISTS très efficace.


────────────────────────────────────────────────────────────────────────────────
32.4 OPÉRATEUR NOT EXISTS — L'ANTI-JOINTURE SÛRE
────────────────────────────────────────────────────────────────────────────────

NOT EXISTS retourne TRUE si la sous-requête ne retourne AUCUNE ligne.
C'est l'exact opposé de EXISTS.

Exemple : clients qui n'ont JAMAIS commandé (version sécurisée)
```sql
SELECT cl.prenom, cl.nom, cl.email, cl.date_inscription
FROM clients cl
WHERE NOT EXISTS (
    SELECT 1
    FROM commandes c
    WHERE c.client_id = cl.client_id
)
ORDER BY cl.date_inscription;
```

Comparaison avec le même résultat via LEFT JOIN + IS NULL :
```sql
-- Équivalent avec LEFT JOIN (également valide)
SELECT cl.prenom, cl.nom, cl.email
FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
WHERE c.commande_id IS NULL
ORDER BY cl.date_inscription;
```

Quelle approche choisir ?
  Les deux sont équivalentes en résultat. PostgreSQL les optimise souvent
  de la même façon. Préférences :
  - NOT EXISTS : plus explicite sur l'intention ("n'existe pas")
  - LEFT JOIN + IS NULL : plus lisible pour les débutants
  - NOT IN : à éviter avec des sous-requêtes (problème NULL)

Avantage de NOT EXISTS sur NOT IN avec NULL :
```sql
-- NOT EXISTS gère correctement les NULL
SELECT * FROM produits p
WHERE NOT EXISTS (
    SELECT 1 FROM lignes_commande lc
    WHERE lc.produit_id = p.produit_id
);
-- Retourne les produits jamais commandés, même si certains lc.produit_id sont NULL

-- NOT IN aurait un comportement problématique si lc.produit_id peut être NULL
```


────────────────────────────────────────────────────────────────────────────────
32.5 COMPARATIF IN VS EXISTS — QUAND CHOISIR QUOI
────────────────────────────────────────────────────────────────────────────────

IN (liste statique) : pour une liste courte et connue à l'avance.
```sql
WHERE statut IN ('livré', 'expédié')  -- Parfait ici
```

IN (sous-requête) : quand on a besoin d'une liste calculée dynamiquement
et que la sous-requête ne peut pas retourner NULL.
```sql
WHERE client_id IN (SELECT client_id FROM clients WHERE segment = 'premium')
```

EXISTS : quand on veut juste vérifier l'existence (pas besoin des valeurs).
Très efficace car s'arrête dès la première correspondance trouvée.
```sql
WHERE EXISTS (SELECT 1 FROM commandes c WHERE c.client_id = cl.client_id)
```

NOT EXISTS : préféré à NOT IN pour les sous-requêtes (sûr avec NULL).
```sql
WHERE NOT EXISTS (SELECT 1 FROM commandes c WHERE c.client_id = cl.client_id)
```

Performance générale :
  EXISTS est souvent plus rapide que IN avec une sous-requête car il
  court-circuite dès la première correspondance. IN évalue toute la liste.
  Cependant, PostgreSQL est très intelligent et optimise les deux de façon
  similaire dans la plupart des cas.


────────────────────────────────────────────────────────────────────────────────
32.6 OPÉRATEURS ANY/ALL — COMPARAISONS AVEC UNE LISTE
────────────────────────────────────────────────────────────────────────────────

ANY (ou SOME) : TRUE si la comparaison est vraie pour AU MOINS UNE valeur
de la liste.

ALL : TRUE si la comparaison est vraie pour TOUTES les valeurs de la liste.

Syntaxe :
```sql
valeur opérateur ANY (sous-requête)
valeur opérateur ALL (sous-requête)
```

Exemple ANY — produits plus chers qu'au moins un produit de la catégorie 1 :
```sql
SELECT nom_produit, prix_vente
FROM produits
WHERE prix_vente > ANY (
    SELECT prix_vente FROM produits WHERE categorie_id = 1
)
ORDER BY prix_vente DESC;
-- Équivalent à : WHERE prix_vente > MIN(prix catégorie 1)
```

Exemple ALL — produits plus chers que TOUS les produits de la catégorie 1 :
```sql
SELECT nom_produit, prix_vente
FROM produits
WHERE prix_vente > ALL (
    SELECT prix_vente FROM produits WHERE categorie_id = 1
)
ORDER BY prix_vente DESC;
-- Équivalent à : WHERE prix_vente > MAX(prix catégorie 1)
```

Note : IN est équivalent à = ANY :
```sql
WHERE client_id IN (SELECT client_id FROM ...)
-- est identique à :
WHERE client_id = ANY (SELECT client_id FROM ...)
```

Usage pratique :
  ANY et ALL sont moins courants que IN et EXISTS dans la pratique.
  Ils sont utiles pour des comparaisons autres que l'égalité (>, <, >=, etc.).


────────────────────────────────────────────────────────────────────────────────
32.7 EXERCICES IN, NOT IN, EXISTS, NOT EXISTS
────────────────────────────────────────────────────────────────────────────────

EXERCICE 32-1 (Facile) — Produits commandés au moins une fois
Liste tous les produits qui ont été commandés au moins une fois.
Utilise EXISTS. Colonnes : produit, catégorie, prix.

SOLUTION 32-1 :
```sql
SELECT p.nom_produit, cat.nom_categorie, p.prix_vente
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
WHERE EXISTS (
    SELECT 1
    FROM lignes_commande lc
    WHERE lc.produit_id = p.produit_id
)
ORDER BY cat.nom_categorie, p.nom_produit;
```


EXERCICE 32-2 (Facile) — Produits JAMAIS commandés
Liste tous les produits actifs qui n'ont jamais été commandés.
Utilise NOT EXISTS. Colonnes : produit, catégorie, prix, stock total.

SOLUTION 32-2 :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    p.prix_vente,
    COALESCE(SUM(se.quantite_disponible), 0) AS stock_total
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
LEFT JOIN stocks_entrepot se ON p.produit_id = se.produit_id
WHERE p.actif = TRUE
  AND NOT EXISTS (
      SELECT 1
      FROM lignes_commande lc
      WHERE lc.produit_id = p.produit_id
  )
GROUP BY p.produit_id, p.nom_produit, cat.nom_categorie, p.prix_vente
ORDER BY p.nom_produit;
```


EXERCICE 32-3 (Moyen) — Clients ayant commandé dans TOUTES les catégories
(utilise NOT EXISTS pour "il n'existe pas de catégorie que ce client n'a pas commandée")
Affiche : client, nombre de catégories différentes commandées.

SOLUTION 32-3 :
```sql
-- Approche : client pour qui il n'existe AUCUNE catégorie qu'il n'ait pas commandée
SELECT cl.prenom || ' ' || cl.nom AS client
FROM clients cl
WHERE NOT EXISTS (
    -- Il n'existe pas de catégorie...
    SELECT 1
    FROM categories cat
    WHERE NOT EXISTS (
        -- ...que ce client n'a pas commandée
        SELECT 1
        FROM commandes c
        JOIN lignes_commande lc ON c.commande_id = lc.commande_id
        JOIN produits p         ON lc.produit_id = p.produit_id
        WHERE c.client_id  = cl.client_id
          AND p.categorie_id = cat.categorie_id
    )
)
ORDER BY cl.nom;
```


EXERCICE 32-4 (Moyen) — Produits commandés par des clients premium mais pas standard
En utilisant IN et NOT IN (avec protection NULL), trouver les produits commandés
par au moins un client "premium" mais par aucun client "standard".

SOLUTION 32-4 :
```sql
SELECT DISTINCT p.nom_produit, p.prix_vente
FROM produits p
WHERE p.produit_id IN (
    -- Produits commandés par des clients premium
    SELECT DISTINCT lc.produit_id
    FROM lignes_commande lc
    JOIN commandes c ON lc.commande_id = c.commande_id
    JOIN clients cl  ON c.client_id = cl.client_id
    WHERE cl.segment = 'premium'
)
AND p.produit_id NOT IN (
    -- Produits commandés par des clients standard (avec protection NULL)
    SELECT DISTINCT lc.produit_id
    FROM lignes_commande lc
    JOIN commandes c ON lc.commande_id = c.commande_id
    JOIN clients cl  ON c.client_id = cl.client_id
    WHERE cl.segment = 'standard'
      AND lc.produit_id IS NOT NULL
)
ORDER BY p.nom_produit;
```


EXERCICE 32-5 (Avancé) — Clients qui ont commandé le même produit plus d'une fois
Un client peut passer plusieurs commandes. Si un client a commandé le même
produit dans des commandes différentes, il doit apparaître dans le résultat.
Affiche : client, produit, nombre de fois commandé.

SOLUTION 32-5 :
```sql
SELECT
    cl.prenom || ' ' || cl.nom AS client,
    p.nom_produit,
    COUNT(lc.ligne_id)          AS fois_commande
FROM clients cl
JOIN commandes c        ON cl.client_id = c.client_id
JOIN lignes_commande lc ON c.commande_id = lc.commande_id
JOIN produits p         ON lc.produit_id = p.produit_id
GROUP BY cl.client_id, cl.prenom, cl.nom, p.produit_id, p.nom_produit
HAVING COUNT(lc.ligne_id) > 1
ORDER BY fois_commande DESC, client;
```


================================================================================
CHAPITRE 33 : SOUS-REQUÊTES CORRÉLÉES ET TABLES DÉRIVÉES (FROM)
================================================================================

────────────────────────────────────────────────────────────────────────────────
33.1 SOUS-REQUÊTES DANS FROM — TABLES DÉRIVÉES
────────────────────────────────────────────────────────────────────────────────

Une table dérivée est une sous-requête utilisée dans la clause FROM comme si
c'était une vraie table. Elle doit toujours avoir un alias.

Syntaxe :
```sql
SELECT *
FROM (
    SELECT colonne1, colonne2
    FROM table
    WHERE condition
) AS alias_table_derivee
WHERE autre_condition;
```

Pourquoi utiliser des tables dérivées ?

Cas 1 : Appliquer un filtre APRÈS une agrégation
```sql
-- Impossible directement (ne peut pas filtrer sur alias dans WHERE)
SELECT client_id, SUM(montant_total) AS total
FROM commandes
WHERE total > 1000  -- [X] ERREUR: "total" n'existe pas encore à l'étape WHERE

-- Solution avec table dérivée :
SELECT client_id, total
FROM (
    SELECT client_id, SUM(montant_total) AS total
    FROM commandes
    GROUP BY client_id
) AS totaux_par_client
WHERE total > 1000
ORDER BY total DESC;
```

Cas 2 : Double agrégation (agrégation d'une agrégation)
```sql
-- Quelle est la moyenne des totaux de commande par client ?
SELECT AVG(total_client) AS moyenne_des_totaux
FROM (
    SELECT client_id, SUM(montant_total) AS total_client
    FROM commandes
    GROUP BY client_id
) AS totaux;
```

Cas 3 : Calcul en plusieurs étapes
```sql
-- Top 3 catégories en CA, avec leur part de marché
SELECT
    nom_categorie,
    chiffre_affaires,
    ROUND(chiffre_affaires / total_global * 100, 1) AS part_marche_pct
FROM (
    -- Étape 1 : CA par catégorie
    SELECT
        cat.nom_categorie,
        SUM(lc.quantite * lc.prix_unitaire) AS chiffre_affaires
    FROM categories cat
    JOIN produits p         ON cat.categorie_id = p.categorie_id
    JOIN lignes_commande lc ON p.produit_id = lc.produit_id
    GROUP BY cat.nom_categorie
) AS ca_par_categorie,
(
    -- Étape 2 : CA total global
    SELECT SUM(quantite * prix_unitaire) AS total_global
    FROM lignes_commande
) AS ca_total
ORDER BY chiffre_affaires DESC
LIMIT 3;
```


────────────────────────────────────────────────────────────────────────────────
33.2 SOUS-REQUÊTES CORRÉLÉES AVANCÉES
────────────────────────────────────────────────────────────────────────────────

Les sous-requêtes corrélées sont des sous-requêtes qui font référence à la
table de la requête externe. Elles s'exécutent pour chaque ligne de la
requête externe.

Exemple : Pour chaque commande, afficher combien de commandes le même client
a passées avant cette commande (historique de commandes) :

```sql
SELECT
    c.numero_commande,
    c.date_commande,
    cl.prenom || ' ' || cl.nom AS client,
    c.montant_total,
    (
        SELECT COUNT(*)
        FROM commandes c2
        WHERE c2.client_id = c.client_id        -- corrélée : même client
          AND c2.date_commande < c.date_commande -- avant la commande actuelle
    ) AS nb_commandes_precedentes
FROM commandes c
JOIN clients cl ON c.client_id = cl.client_id
ORDER BY cl.nom, c.date_commande;
```

Exemple avancé : rang de chaque produit dans sa catégorie par CA :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    SUM(lc.quantite * lc.prix_unitaire) AS ca_produit,
    (
        SELECT COUNT(DISTINCT p2.produit_id) + 1
        FROM produits p2
        JOIN lignes_commande lc2 ON p2.produit_id = lc2.produit_id
        WHERE p2.categorie_id = p.categorie_id  -- même catégorie
        GROUP BY p2.produit_id
        HAVING SUM(lc2.quantite * lc2.prix_unitaire) >
               SUM(lc.quantite * lc.prix_unitaire)
    ) AS rang_dans_categorie
FROM produits p
JOIN categories cat     ON p.categorie_id = cat.categorie_id
JOIN lignes_commande lc ON p.produit_id = lc.produit_id
GROUP BY p.produit_id, p.nom_produit, cat.nom_categorie, cat.categorie_id
ORDER BY cat.nom_categorie, rang_dans_categorie;
```

Note : En pratique, ce type de calcul de rang est beaucoup plus élégant avec
les fonctions de fenêtrage RANK() (voir partie 13). Les sous-requêtes corrélées
pour le rang sont pédagogiquement utiles mais rarement utilisées en production.


────────────────────────────────────────────────────────────────────────────────
33.3 RÉÉCRITURE DE SOUS-REQUÊTES EN JOIN — QUAND ET POURQUOI
────────────────────────────────────────────────────────────────────────────────

De nombreuses sous-requêtes peuvent être réécrites en JOIN et vice-versa.
Voici les équivalences et quand choisir chaque approche.

CAS 1 : Sous-requête IN -> JOIN avec DISTINCT
```sql
-- Avec sous-requête IN
SELECT cl.nom FROM clients cl
WHERE cl.client_id IN (SELECT c.client_id FROM commandes c WHERE c.statut = 'livré');

-- Avec JOIN
SELECT DISTINCT cl.nom FROM clients cl
JOIN commandes c ON cl.client_id = c.client_id
WHERE c.statut = 'livré';
-- Note : DISTINCT est nécessaire car un client peut avoir plusieurs commandes livrées
```

CAS 2 : Sous-requête NOT EXISTS -> LEFT JOIN + IS NULL
```sql
-- Avec NOT EXISTS
SELECT cl.nom FROM clients cl
WHERE NOT EXISTS (SELECT 1 FROM commandes c WHERE c.client_id = cl.client_id);

-- Avec LEFT JOIN + IS NULL
SELECT cl.nom FROM clients cl
LEFT JOIN commandes c ON cl.client_id = c.client_id
WHERE c.commande_id IS NULL;
```

CAS 3 : Sous-requête scalaire dans SELECT -> JOIN avec agrégation
```sql
-- Avec sous-requête dans SELECT (moins efficace)
SELECT p.nom_produit,
       (SELECT COUNT(*) FROM lignes_commande lc WHERE lc.produit_id = p.produit_id)
       AS nb_ventes
FROM produits p;

-- Avec LEFT JOIN + GROUP BY (plus efficace)
SELECT p.nom_produit, COUNT(lc.ligne_id) AS nb_ventes
FROM produits p
LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
GROUP BY p.produit_id, p.nom_produit;
```

Quand choisir sous-requête vs JOIN ?

  Sous-requête -> lisibilité est prioritaire, logique étape par étape,
                  EXISTS/NOT EXISTS pour tester l'existence
  JOIN         -> performance sur grandes tables, besoin des colonnes de la
                  table jointe dans le SELECT


────────────────────────────────────────────────────────────────────────────────
33.4 SOUS-REQUÊTES DANS INSERT, UPDATE, DELETE
────────────────────────────────────────────────────────────────────────────────

Les sous-requêtes ne sont pas limitées au SELECT. On peut les utiliser dans
les instructions de manipulation de données.

SOUS-REQUÊTE DANS INSERT :
```sql
-- Copier les clients VIP dans une table d'archive
INSERT INTO clients_vip_archive (client_id, nom, prenom, email, date_archivage)
SELECT client_id, nom, prenom, email, CURRENT_TIMESTAMP
FROM clients
WHERE client_id IN (
    SELECT client_id
    FROM commandes
    GROUP BY client_id
    HAVING SUM(montant_total) > 5000
);
```

SOUS-REQUÊTE DANS UPDATE :
```sql
-- Mettre à jour le segment des clients en fonction de leur historique d'achats
UPDATE clients
SET segment = 'premium'
WHERE client_id IN (
    SELECT client_id
    FROM commandes
    GROUP BY client_id
    HAVING SUM(montant_total) > 2000
      AND COUNT(commande_id) >= 3
);

-- Mettre à jour le prix de vente selon la dernière commande enregistrée
UPDATE produits p
SET prix_vente = prix_vente * 1.05  -- augmentation de 5%
WHERE p.produit_id IN (
    SELECT DISTINCT lc.produit_id
    FROM lignes_commande lc
    JOIN commandes c ON lc.commande_id = c.commande_id
    WHERE c.date_commande >= '2024-01-01'
      AND c.statut = 'livré'
);
```

SOUS-REQUÊTE DANS DELETE :
```sql
-- Supprimer les avis de produits qui n'existent plus dans le catalogue
DELETE FROM avis
WHERE produit_id NOT IN (
    SELECT produit_id FROM produits WHERE actif = TRUE
);

-- Supprimer les commandes de clients supprimés (normalement géré par FK CASCADE)
DELETE FROM commandes
WHERE client_id NOT IN (
    SELECT client_id FROM clients
);
```

Attention avec DELETE et sous-requêtes :
  Toujours tester avec SELECT avant DELETE :
```sql
-- Test : voir ce qui sera supprimé
SELECT * FROM avis
WHERE produit_id NOT IN (SELECT produit_id FROM produits WHERE actif = TRUE);

-- Quand confirmé correct :
DELETE FROM avis
WHERE produit_id NOT IN (SELECT produit_id FROM produits WHERE actif = TRUE);
```


────────────────────────────────────────────────────────────────────────────────
33.5 EXERCICES TABLES DÉRIVÉES ET SOUS-REQUÊTES CORRÉLÉES
────────────────────────────────────────────────────────────────────────────────

EXERCICE 33-1 (Facile) — Top 3 clients par segment
Pour chaque segment (standard, premium, vip), trouve le client qui a dépensé
le plus. Utilise une table dérivée.
Colonnes : segment, client, total dépensé.

SOLUTION 33-1 :
```sql
SELECT segment, client, total_depense
FROM (
    SELECT
        cl.segment,
        cl.prenom || ' ' || cl.nom           AS client,
        SUM(c.montant_total)                  AS total_depense,
        RANK() OVER (
            PARTITION BY cl.segment
            ORDER BY SUM(c.montant_total) DESC
        ) AS rang
    FROM clients cl
    JOIN commandes c ON cl.client_id = c.client_id
    GROUP BY cl.client_id, cl.prenom, cl.nom, cl.segment
) AS classement
WHERE rang = 1
ORDER BY total_depense DESC;
```

Note : RANK() est une fonction de fenêtrage (voir partie 13).
Alternative sans fenêtrage :
```sql
SELECT DISTINCT ON (cl.segment)
    cl.segment,
    cl.prenom || ' ' || cl.nom AS client,
    SUM(c.montant_total)        AS total_depense
FROM clients cl
JOIN commandes c ON cl.client_id = c.client_id
GROUP BY cl.client_id, cl.prenom, cl.nom, cl.segment
ORDER BY cl.segment, total_depense DESC;
```


EXERCICE 33-2 (Moyen) — Évolution mensuelle du panier moyen
Pour chaque mois de 2024, calcule le panier moyen des commandes.
Affiche aussi la variation par rapport au mois précédent.
Utilise des tables dérivées.

SOLUTION 33-2 :
```sql
SELECT
    mois_actuel,
    panier_moyen,
    panier_mois_prec,
    CASE
        WHEN panier_mois_prec IS NULL THEN NULL
        ELSE ROUND(
            (panier_moyen - panier_mois_prec) / panier_mois_prec * 100,
            1
        )
    END AS evolution_pct
FROM (
    SELECT
        TO_CHAR(DATE_TRUNC('month', date_commande), 'YYYY-MM') AS mois_actuel,
        ROUND(AVG(montant_total), 2)                            AS panier_moyen,
        LAG(ROUND(AVG(montant_total), 2)) OVER (
            ORDER BY DATE_TRUNC('month', date_commande)
        )                                                       AS panier_mois_prec
    FROM commandes
    WHERE date_commande >= '2024-01-01'
      AND date_commande < '2025-01-01'
    GROUP BY DATE_TRUNC('month', date_commande)
) AS paniers_mensuels
ORDER BY mois_actuel;
```


EXERCICE 33-3 (Moyen) — Produits avec leur position relative dans la catégorie
Pour chaque produit, affiche sa position en termes de prix dans sa catégorie
(1 = le plus cher, etc.), sans utiliser de fonctions de fenêtrage.

SOLUTION 33-3 :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    p.prix_vente,
    (
        SELECT COUNT(*) + 1
        FROM produits p2
        WHERE p2.categorie_id = p.categorie_id
          AND p2.prix_vente > p.prix_vente
    ) AS rang_prix_dans_categorie,
    (
        SELECT COUNT(*)
        FROM produits p3
        WHERE p3.categorie_id = p.categorie_id
    ) AS total_produits_categorie
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
ORDER BY cat.nom_categorie, rang_prix_dans_categorie;
```


EXERCICE 33-4 (Avancé) — Détection des clients à risque de churn
Un client est "à risque" si :
  - Il a passé au moins 2 commandes dans le passé
  - Sa dernière commande date de plus de 60 jours
  - Son panier moyen était supérieur à 100€
Affiche : client, date dernière commande, jours depuis commande, panier moyen.

SOLUTION 33-4 :
```sql
SELECT
    client,
    derniere_commande,
    jours_depuis_commande,
    panier_moyen
FROM (
    SELECT
        cl.prenom || ' ' || cl.nom                          AS client,
        MAX(c.date_commande)                                AS derniere_commande,
        CURRENT_DATE - MAX(c.date_commande)                 AS jours_depuis_commande,
        ROUND(AVG(c.montant_total), 2)                      AS panier_moyen,
        COUNT(c.commande_id)                                AS nb_commandes
    FROM clients cl
    JOIN commandes c ON cl.client_id = c.client_id
    GROUP BY cl.client_id, cl.prenom, cl.nom
) AS stats_clients
WHERE nb_commandes >= 2
  AND jours_depuis_commande > 60
  AND panier_moyen > 100
ORDER BY jours_depuis_commande DESC;
```


EXERCICE 33-5 (Avancé) — Rapport de cohérence des données
Utilise des sous-requêtes et FULL OUTER JOIN pour identifier :
  a) Lignes de commande orphelines (commande_id inexistant)
  b) Commandes sans lignes de commande
  c) Produits référencés dans lignes_commande mais supprimés de produits
Affiche un rapport unifié avec type d'anomalie.

SOLUTION 33-5 :
```sql
-- a) Lignes de commande avec commande_id inexistante
SELECT
    'Ligne commande orpheline' AS type_anomalie,
    lc.ligne_id::TEXT          AS id_entite,
    'commande_id: ' || lc.commande_id::TEXT AS detail
FROM lignes_commande lc
WHERE NOT EXISTS (
    SELECT 1 FROM commandes c WHERE c.commande_id = lc.commande_id
)

UNION ALL

-- b) Commandes sans aucune ligne de commande
SELECT
    'Commande sans lignes'     AS type_anomalie,
    c.commande_id::TEXT        AS id_entite,
    'numéro: ' || c.numero_commande AS detail
FROM commandes c
WHERE NOT EXISTS (
    SELECT 1 FROM lignes_commande lc WHERE lc.commande_id = c.commande_id
)

UNION ALL

-- c) Produits dans lignes_commande mais absents de produits
SELECT
    'Produit référencé mais absent' AS type_anomalie,
    lc.produit_id::TEXT              AS id_entite,
    'ligne_id: ' || lc.ligne_id::TEXT AS detail
FROM lignes_commande lc
WHERE NOT EXISTS (
    SELECT 1 FROM produits p WHERE p.produit_id = lc.produit_id
)

ORDER BY type_anomalie, id_entite;
```


================================================================================
SYNTHÈSE DE LA PARTIE 7 — SOUS-REQUÊTES
================================================================================

TABLEAU RÉCAPITULATIF
──────────────────────
  Technique           | Usage principal                           | Risque / Note
  ────────────────────┼───────────────────────────────────────────┼──────────────
  Scalaire (WHERE)    | Comparer à une valeur calculée            | Erreur si > 1 ligne
  Scalaire (SELECT)   | Colonne calculée par ligne                | Perf si non cachée
  IN (sous-requête)   | Valeur dans une liste dynamique           | Pb si NULL possible
  NOT IN              | Valeur hors d'une liste                   | [ATTENTION] NULL détruit tout
  EXISTS              | Tester l'existence, court-circuite        | Toujours sûr
  NOT EXISTS          | Anti-jointure sûre avec NULL              | Recommandé
  Table dérivée (FROM)| Multi-étapes, double agrégation           | Alias obligatoire
  Corrélée            | Référence à la requête externe            | S'exécute N fois
  ANY / ALL           | Comparaison >/</>= avec une liste         | Rare mais utile

RÈGLES D'OR
────────────
  1. NOT IN : toujours ajouter IS NOT NULL dans la sous-requête (protection NULL)
  2. NOT EXISTS : préféré à NOT IN pour les sous-requêtes (sûr avec NULL)
  3. EXISTS utilise SELECT 1 (convention standard)
  4. Table dérivée = toujours lui donner un alias
  5. Tester la sous-requête seule avant de l'imbriquer
  6. Une sous-requête scalaire doit retourner EXACTEMENT 1 ligne (erreur sinon)
  7. Sous-requête corrélée -> penser aux performances (peut s'exécuter N fois)
  8. Pour de gros volumes : réécrire les sous-requêtes en JOIN ou CTE

DÉBOGAGE DES SOUS-REQUÊTES
────────────────────────────
```sql
-- Toujours tester la sous-requête seule d'abord :
SELECT client_id FROM commandes WHERE statut = 'livré';
-- Puis l'imbriquer :
SELECT nom FROM clients WHERE client_id IN (
    SELECT client_id FROM commandes WHERE statut = 'livré'
);
```

================================================================================
FIN DE LA PARTIE 7 — SOUS-REQUÊTES SQL
================================================================================
Chapitres couverts : 31 (scalaires et WHERE), 32 (IN/NOT IN/EXISTS/NOT EXISTS),
                     33 (tables dérivées et sous-requêtes corrélées)
Prochaine partie : PARTIE 8 — MANIPULATION DE DONNÉES (INSERT, UPDATE, DELETE)
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 8
MANIPULATION DE DONNÉES : INSERT, UPDATE, DELETE
Chapitres 34 à 36
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Intermédiaire
================================================================================

TABLE DES MATIÈRES — PARTIE 8
══════════════════════════════
  Chapitre 34 : INSERT — Insérer des données
  Chapitre 35 : UPDATE — Modifier des données
  Chapitre 36 : DELETE et TRUNCATE — Supprimer des données


================================================================================
CHAPITRE 34 : INSERT — INSÉRER DES DONNÉES
================================================================================

────────────────────────────────────────────────────────────────────────────────
34.1 INTRODUCTION — LE CYCLE DE VIE DES DONNÉES
────────────────────────────────────────────────────────────────────────────────

Dans une base de données, les données suivent un cycle de vie :
  Création (INSERT) -> Lecture (SELECT) -> Modification (UPDATE) -> Suppression (DELETE)

Ces quatre opérations forment l'acronyme CRUD (Create, Read, Update, Delete),
qui est la base de toute interaction avec une base de données.

Ce chapitre couvre l'INSERT, c'est-à-dire la création de nouvelles lignes
dans une table.

Qui utilise INSERT en pratique ?
  - Les applications web/mobile à chaque inscription, commande, message
  - Les scripts de migration pour importer des données
  - Les ETL (Extract Transform Load) pour charger des données dans un entrepôt
  - Les outils d'administration pour des insertions manuelles ponctuelles
  - Les tests automatisés pour créer des données de test


────────────────────────────────────────────────────────────────────────────────
34.2 SYNTAXE DE BASE — INSERT INTO ... VALUES
────────────────────────────────────────────────────────────────────────────────

Syntaxe complète :
```sql
INSERT INTO nom_table (colonne1, colonne2, colonne3, ...)
VALUES (valeur1, valeur2, valeur3, ...);
```

Règle fondamentale : toujours spécifier explicitement les colonnes.
Ne jamais utiliser INSERT INTO table VALUES (...) sans liste de colonnes.

Pourquoi c'est important ?
```sql
-- [X] Mauvaise pratique : INSERT sans liste de colonnes
INSERT INTO clients VALUES (11, 'Dubois', 'Marc', 'marc@email.com', ...);
-- Si quelqu'un ajoute une colonne à la table, cette requête cassera.
-- L'ordre des colonnes peut changer selon la version du schéma.

-- [OK] Bonne pratique : toujours lister les colonnes
INSERT INTO clients (nom, prenom, email, telephone, adresse, ville,
                     code_postal, pays, date_inscription, segment)
VALUES ('Dubois', 'Marc', 'marc.dubois@email.com', '0612345678',
        '15 rue de la Paix', 'Paris', '75001', 'France',
        CURRENT_DATE, 'standard');
```

Avantages de la liste explicite des colonnes :
  1. L'ordre dans VALUES peut différer de l'ordre dans la table
  2. Les colonnes avec valeur par défaut peuvent être omises
  3. Le code est auto-documenté (on sait exactement ce qu'on insère)
  4. Résistant aux évolutions du schéma (ajout/réordonnancement de colonnes)


────────────────────────────────────────────────────────────────────────────────
34.3 VALEURS PAR DÉFAUT ET COLONNES OPTIONNELLES
────────────────────────────────────────────────────────────────────────────────

Quand une colonne a une valeur par défaut (DEFAULT) ou accepte NULL,
elle peut être omise de l'INSERT.

```sql
-- Rappel du CREATE TABLE clients
CREATE TABLE clients (
    client_id       SERIAL PRIMARY KEY,        -- Auto-incrémenté, ne pas spécifier
    nom             VARCHAR(100) NOT NULL,
    prenom          VARCHAR(100) NOT NULL,
    email           VARCHAR(200) NOT NULL UNIQUE,
    telephone       VARCHAR(20),               -- Optionnel (NULL accepté)
    adresse         TEXT,                      -- Optionnel
    ville           VARCHAR(100),              -- Optionnel
    code_postal     VARCHAR(10),               -- Optionnel
    pays            VARCHAR(100) DEFAULT 'France', -- Valeur par défaut
    date_inscription DATE DEFAULT CURRENT_DATE,    -- Valeur par défaut
    segment         VARCHAR(50) DEFAULT 'standard' -- Valeur par défaut
);

-- INSERT minimal (colonnes optionnelles omises)
INSERT INTO clients (nom, prenom, email)
VALUES ('Fontaine', 'Lucie', 'lucie.fontaine@email.com');
-- client_id : généré automatiquement par SERIAL
-- telephone, adresse, ville, code_postal : NULL
-- pays : 'France' (défaut)
-- date_inscription : date du jour (défaut)
-- segment : 'standard' (défaut)
```

Utiliser explicitement DEFAULT dans VALUES :
```sql
INSERT INTO clients (nom, prenom, email, pays, segment)
VALUES ('Leblanc', 'Antoine', 'a.leblanc@email.com', DEFAULT, 'premium');
-- DEFAULT -> utilise la valeur par défaut de la colonne 'pays' = 'France'
```


────────────────────────────────────────────────────────────────────────────────
34.4 INSERT MULTIPLE — PLUSIEURS LIGNES EN UNE SEULE REQUÊTE
────────────────────────────────────────────────────────────────────────────────

On peut insérer plusieurs lignes avec un seul INSERT, ce qui est bien plus
efficace que plusieurs INSERT séparés.

```sql
INSERT INTO categories (nom_categorie, description)
VALUES
    ('Jeux vidéo',   'Consoles et accessoires gaming'),
    ('Livres',       'Romans, essais, manuels techniques'),
    ('Sports',       'Équipements et vêtements de sport'),
    ('Maison',       'Décoration et ustensiles de maison'),
    ('Jardin',       'Outillage et plantes de jardin');
```

Pourquoi c'est plus efficace ?
  Avec 5 INSERT séparés :
  - 5 allers-retours réseau avec le serveur
  - 5 analyses syntaxiques de la requête
  - 5 planifications d'exécution
  - 5 transactions (si autocommit)

  Avec 1 INSERT multiple :
  - 1 aller-retour réseau
  - 1 analyse syntaxique
  - 1 planification d'exécution
  - 1 transaction

Pour des insertions massives (milliers de lignes), préférer COPY (PostgreSQL)
ou utiliser des transactions explicites (voir partie 11).


────────────────────────────────────────────────────────────────────────────────
34.5 INSERT INTO ... SELECT — COPIER DES DONNÉES
────────────────────────────────────────────────────────────────────────────────

Cette syntaxe permet d'insérer dans une table les résultats d'une requête SELECT.
C'est extrêmement puissant pour migrer, archiver ou transformer des données.

```sql
-- Créer une table d'archive pour les commandes livrées
CREATE TABLE IF NOT EXISTS commandes_archive (
    commande_id     INTEGER,
    numero_commande VARCHAR(20),
    client_id       INTEGER,
    montant_total   DECIMAL(10,2),
    date_commande   DATE,
    date_archivage  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Copier toutes les commandes livrées dans l'archive
INSERT INTO commandes_archive (commande_id, numero_commande, client_id,
                                montant_total, date_commande)
SELECT commande_id, numero_commande, client_id, montant_total, date_commande
FROM commandes
WHERE statut = 'livré'
  AND date_commande < '2024-01-01';
```

Autre exemple : créer un résumé agrégé dans une nouvelle table
```sql
CREATE TABLE IF NOT EXISTS resume_ventes_mensuel (
    annee            INTEGER,
    mois             INTEGER,
    nb_commandes     INTEGER,
    chiffre_affaires DECIMAL(10,2),
    nb_clients       INTEGER
);

INSERT INTO resume_ventes_mensuel (annee, mois, nb_commandes,
                                    chiffre_affaires, nb_clients)
SELECT
    EXTRACT(YEAR  FROM date_commande) AS annee,
    EXTRACT(MONTH FROM date_commande) AS mois,
    COUNT(*)                           AS nb_commandes,
    SUM(montant_total)                 AS chiffre_affaires,
    COUNT(DISTINCT client_id)          AS nb_clients
FROM commandes
WHERE statut != 'annulé'
GROUP BY annee, mois
ORDER BY annee, mois;
```


────────────────────────────────────────────────────────────────────────────────
34.6 INSERT ON CONFLICT (UPSERT) — INSÉRER OU METTRE À JOUR
────────────────────────────────────────────────────────────────────────────────

L'UPSERT ("update or insert") est une opération atomique qui :
  - Insère la ligne si elle n'existe pas
  - Met à jour la ligne si elle existe déjà (conflit sur clé unique)

PostgreSQL implémente cette fonctionnalité avec ON CONFLICT.

Syntaxe :
```sql
INSERT INTO table (col1, col2, col3)
VALUES (val1, val2, val3)
ON CONFLICT (colonne_unique)
DO UPDATE SET col2 = EXCLUDED.col2, col3 = EXCLUDED.col3;
-- EXCLUDED représente la ligne qu'on tentait d'insérer
```

Exemple concret : mise à jour du stock d'un entrepôt
```sql
-- Si le stock n'existe pas -> INSERT
-- Si le stock existe déjà -> UPDATE de la quantité
INSERT INTO stocks_entrepot (produit_id, entrepot_id, quantite_disponible)
VALUES (1, 1, 50)
ON CONFLICT (produit_id, entrepot_id)
DO UPDATE SET
    quantite_disponible = stocks_entrepot.quantite_disponible + EXCLUDED.quantite_disponible,
    date_mise_a_jour = CURRENT_TIMESTAMP;
```

Ignorer silencieusement le conflit (ON CONFLICT DO NOTHING) :
```sql
-- Tenter d'insérer un email, ignorer si déjà existant
INSERT INTO clients (nom, prenom, email)
VALUES ('Dupont', 'Marie', 'marie.dupont@email.com')
ON CONFLICT (email) DO NOTHING;
-- Si l'email existe déjà : aucune action, aucune erreur
```

Cas d'usage typiques de l'UPSERT :
  - Synchronisation de données (mettre à jour si existe, créer si nouveau)
  - Compteurs (incrementer si existe, initialiser si nouveau)
  - Import de données avec doublons possibles
  - APIs idempotentes (même résultat si appelé plusieurs fois)


────────────────────────────────────────────────────────────────────────────────
34.7 RETURNING — RÉCUPÉRER LES VALEURS INSÉRÉES
────────────────────────────────────────────────────────────────────────────────

La clause RETURNING (spécifique à PostgreSQL) permet de récupérer les valeurs
des lignes insérées, notamment les valeurs générées automatiquement (SERIAL, UUID).

```sql
-- Récupérer l'ID généré après INSERT
INSERT INTO clients (nom, prenom, email, segment)
VALUES ('Rousseau', 'Emma', 'emma.rousseau@email.com', 'standard')
RETURNING client_id, date_inscription;
-- Résultat : client_id=12, date_inscription=2024-03-15

-- Récupérer plusieurs colonnes
INSERT INTO commandes (client_id, montant_total, statut)
VALUES (12, 299.99, 'en_attente')
RETURNING commande_id, numero_commande, date_commande;
```

Cas d'usage dans une application :
  Quand une application insère un client et doit immédiatement créer une
  commande pour ce client, elle a besoin du `client_id` généré. RETURNING
  permet de récupérer cet ID sans faire une deuxième requête SELECT.

RETURNING avec INSERT ... SELECT :
```sql
-- Insérer et récupérer tous les IDs insérés
INSERT INTO commandes_archive (commande_id, numero_commande, client_id, montant_total)
SELECT commande_id, numero_commande, client_id, montant_total
FROM commandes
WHERE statut = 'annulé'
RETURNING commande_id;
-- Retourne la liste des commande_id archivés
```


────────────────────────────────────────────────────────────────────────────────
34.8 CONTRAINTES ET ERREURS COURANTES À L'INSERT
────────────────────────────────────────────────────────────────────────────────

Les contraintes de la table peuvent déclencher des erreurs lors de l'INSERT.

VIOLATION DE CLÉ PRIMAIRE :
```sql
-- [X] Erreur si client_id=1 existe déjà
INSERT INTO clients (client_id, nom, prenom, email)
VALUES (1, 'Test', 'Test', 'test@test.com');
-- ERROR: duplicate key value violates unique constraint "clients_pkey"

-- [OK] Solution : laisser SERIAL générer automatiquement
INSERT INTO clients (nom, prenom, email)
VALUES ('Test', 'Test', 'test@test.com');
```

VIOLATION DE CLÉ ÉTRANGÈRE :
```sql
-- [X] Erreur si le client_id 999 n'existe pas dans la table clients
INSERT INTO commandes (client_id, montant_total, statut)
VALUES (999, 100.00, 'en_attente');
-- ERROR: insert or update on table "commandes" violates foreign key constraint
-- DETAIL: Key (client_id)=(999) is not present in table "clients"

-- [OK] Solution : s'assurer que le client existe avant d'insérer la commande
```

VIOLATION DE CONTRAINTE NOT NULL :
```sql
-- [X] Erreur si nom est NULL
INSERT INTO clients (prenom, email)
VALUES ('Pierre', 'pierre@email.com');
-- ERROR: null value in column "nom" violates not-null constraint

-- [OK] Solution : fournir toutes les colonnes NOT NULL
INSERT INTO clients (nom, prenom, email)
VALUES ('Leroy', 'Pierre', 'pierre.leroy@email.com');
```

VIOLATION DE CONTRAINTE UNIQUE :
```sql
-- [X] Erreur si cet email est déjà dans la table
INSERT INTO clients (nom, prenom, email)
VALUES ('Autre', 'Nom', 'jean.martin@email.com');
-- ERROR: duplicate key value violates unique constraint "clients_email_key"

-- [OK] Solutions :
-- 1. Vérifier avant d'insérer (mais risque de race condition)
-- 2. Utiliser ON CONFLICT DO NOTHING ou DO UPDATE
INSERT INTO clients (nom, prenom, email)
VALUES ('Autre', 'Nom', 'jean.martin@email.com')
ON CONFLICT (email) DO NOTHING;
```


────────────────────────────────────────────────────────────────────────────────
34.9 EXERCICES INSERT
────────────────────────────────────────────────────────────────────────────────

EXERCICE 34-1 (Facile) — Ajouter un nouveau client
Insère un nouveau client avec les informations suivantes :
Nom: Renard, Prénom: Camille, Email: camille.renard@email.com,
Téléphone: 0645678901, Ville: Lyon, Code postal: 69001, Segment: premium.

SOLUTION 34-1 :
```sql
INSERT INTO clients (nom, prenom, email, telephone, ville, code_postal, segment)
VALUES ('Renard', 'Camille', 'camille.renard@email.com',
        '0645678901', 'Lyon', '69001', 'premium')
RETURNING client_id, date_inscription;
```


EXERCICE 34-2 (Facile) — Ajouter plusieurs produits en une requête
Insère 3 nouveaux produits dans la catégorie Informatique (categorie_id=1) :
  1. Webcam HD Pro, référence: CAM-001, prix achat: 45€, prix vente: 89€, fournisseur_id=1
  2. Tapis de souris XL, référence: MAT-001, prix achat: 12€, prix vente: 24€, fournisseur_id=2
  3. Hub USB-C 7 ports, référence: HUB-001, prix achat: 28€, prix vente: 59€, fournisseur_id=1

SOLUTION 34-2 :
```sql
INSERT INTO produits (nom_produit, reference, categorie_id, fournisseur_id,
                       prix_achat, prix_vente, actif)
VALUES
    ('Webcam HD Pro',     'CAM-001', 1, 1, 45.00,  89.00, TRUE),
    ('Tapis de souris XL','MAT-001', 1, 2, 12.00,  24.00, TRUE),
    ('Hub USB-C 7 ports', 'HUB-001', 1, 1, 28.00,  59.00, TRUE)
RETURNING produit_id, nom_produit;
```


EXERCICE 34-3 (Moyen) — UPSERT sur les stocks
Un réapprovisionnement arrive à l'entrepôt 1 :
  - Produit 1 : +25 unités
  - Produit 2 : +10 unités
  - Produit 99 (nouveau produit au stock) : +50 unités
Si le stock existe, ajouter la quantité. Sinon, créer l'entrée.

SOLUTION 34-3 :
```sql
INSERT INTO stocks_entrepot (produit_id, entrepot_id, quantite_disponible)
VALUES
    (1,  1, 25),
    (2,  1, 10),
    (99, 1, 50)
ON CONFLICT (produit_id, entrepot_id)
DO UPDATE SET
    quantite_disponible = stocks_entrepot.quantite_disponible + EXCLUDED.quantite_disponible;
```


EXERCICE 34-4 (Avancé) — Créer une table de rapport et la remplir
Crée une table `rapport_produits_2024` et insère via INSERT...SELECT un
résumé par produit : nom, catégorie, fournisseur, CA 2024, nb commandes,
note moyenne, stock actuel total.

SOLUTION 34-4 :
```sql
CREATE TABLE IF NOT EXISTS rapport_produits_2024 (
    produit_id       INTEGER PRIMARY KEY,
    nom_produit      VARCHAR(200),
    nom_categorie    VARCHAR(100),
    nom_fournisseur  VARCHAR(200),
    ca_2024          DECIMAL(12,2),
    nb_commandes     INTEGER,
    note_moyenne     DECIMAL(3,1),
    stock_total      INTEGER
);

INSERT INTO rapport_produits_2024
SELECT
    p.produit_id,
    p.nom_produit,
    cat.nom_categorie,
    f.nom_entreprise                                    AS nom_fournisseur,
    COALESCE(SUM(lc.quantite * lc.prix_unitaire), 0)   AS ca_2024,
    COUNT(DISTINCT c.commande_id)                       AS nb_commandes,
    ROUND(AVG(a.note), 1)                               AS note_moyenne,
    COALESCE(SUM(DISTINCT se.quantite_disponible), 0)  AS stock_total
FROM produits p
JOIN categories cat     ON p.categorie_id = cat.categorie_id
JOIN fournisseurs f     ON p.fournisseur_id = f.fournisseur_id
LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
LEFT JOIN commandes c   ON lc.commande_id = c.commande_id
                        AND c.date_commande >= '2024-01-01'
                        AND c.date_commande < '2025-01-01'
LEFT JOIN avis a        ON p.produit_id = a.produit_id
LEFT JOIN stocks_entrepot se ON p.produit_id = se.produit_id
GROUP BY p.produit_id, p.nom_produit, cat.nom_categorie, f.nom_entreprise
ON CONFLICT (produit_id) DO UPDATE SET
    ca_2024       = EXCLUDED.ca_2024,
    nb_commandes  = EXCLUDED.nb_commandes,
    note_moyenne  = EXCLUDED.note_moyenne,
    stock_total   = EXCLUDED.stock_total;
```


================================================================================
CHAPITRE 35 : UPDATE — MODIFIER DES DONNÉES
================================================================================

────────────────────────────────────────────────────────────────────────────────
35.1 SYNTAXE DE BASE — UPDATE
────────────────────────────────────────────────────────────────────────────────

Syntaxe complète :
```sql
UPDATE nom_table
SET colonne1 = valeur1,
    colonne2 = valeur2,
    ...
WHERE condition;
```

RÈGLE ABSOLUE : TOUJOURS inclure une clause WHERE dans un UPDATE.
Un UPDATE sans WHERE modifie TOUTES les lignes de la table.

```sql
-- [X] CATASTROPHE : met à jour TOUTES les commandes
UPDATE commandes SET statut = 'annulé';
-- Toutes vos commandes sont maintenant annulées !

-- [OK] Correct : toujours filtrer
UPDATE commandes
SET statut = 'annulé'
WHERE commande_id = 5;
```

Conseil de sécurité professionnelle :
  Avant tout UPDATE, écris d'abord le SELECT correspondant pour vérifier
  combien de lignes seront affectées :
```sql
-- Étape 1 : vérifier les lignes qui seront modifiées
SELECT commande_id, statut FROM commandes WHERE commande_id = 5;

-- Étape 2 : si correct, exécuter l'UPDATE
UPDATE commandes SET statut = 'annulé' WHERE commande_id = 5;
```


────────────────────────────────────────────────────────────────────────────────
35.2 UPDATE DE PLUSIEURS COLONNES
────────────────────────────────────────────────────────────────────────────────

On peut modifier plusieurs colonnes en une seule instruction UPDATE.

```sql
-- Mise à jour de l'adresse d'un client
UPDATE clients
SET
    adresse      = '25 avenue des Champs-Élysées',
    ville        = 'Paris',
    code_postal  = '75008',
    pays         = 'France'
WHERE client_id = 3;

-- Mise à jour d'un produit avec recalcul du prix
UPDATE produits
SET
    prix_vente        = prix_achat * 1.4,  -- marge de 40%
    date_modification = CURRENT_TIMESTAMP,
    actif             = TRUE
WHERE produit_id = 7;
```

Utiliser des expressions dans UPDATE :
```sql
-- Augmenter tous les prix de la catégorie informatique de 5%
UPDATE produits
SET prix_vente = ROUND(prix_vente * 1.05, 2)
WHERE categorie_id = (SELECT categorie_id FROM categories
                      WHERE nom_categorie = 'Informatique');

-- Incrémenter un compteur
UPDATE produits
SET nb_ventes = nb_ventes + 1
WHERE produit_id = 3;
```


────────────────────────────────────────────────────────────────────────────────
35.3 UPDATE AVEC SOUS-REQUÊTE — MODIFICATION BASÉE SUR UNE AUTRE TABLE
────────────────────────────────────────────────────────────────────────────────

Souvent, la valeur de mise à jour provient d'une autre table.

Exemple : mettre à jour le segment des clients selon leur historique d'achats
```sql
-- Clients avec plus de 2000€ d'achats -> premium
UPDATE clients
SET segment = 'premium'
WHERE client_id IN (
    SELECT client_id
    FROM commandes
    GROUP BY client_id
    HAVING SUM(montant_total) > 2000
);

-- Clients avec moins de 200€ -> standard
UPDATE clients
SET segment = 'standard'
WHERE client_id IN (
    SELECT client_id
    FROM commandes
    GROUP BY client_id
    HAVING SUM(montant_total) <= 200
)
AND segment != 'standard';  -- Éviter les mises à jour inutiles
```

UPDATE avec FROM (syntaxe PostgreSQL) — plus lisible et efficace :
```sql
-- Syntaxe PostgreSQL : UPDATE ... FROM ... (équivalent à UPDATE avec JOIN)
UPDATE clients c
SET segment = CASE
    WHEN totaux.total > 2000 THEN 'premium'
    WHEN totaux.total > 500  THEN 'fidele'
    ELSE 'standard'
END
FROM (
    SELECT client_id, SUM(montant_total) AS total
    FROM commandes
    WHERE statut != 'annulé'
    GROUP BY client_id
) AS totaux
WHERE c.client_id = totaux.client_id;
```

UPDATE avec JOIN (syntaxe MySQL) — différente de PostgreSQL :
```sql
-- MySQL uniquement
UPDATE clients c
JOIN (
    SELECT client_id, SUM(montant_total) AS total
    FROM commandes
    GROUP BY client_id
) totaux ON c.client_id = totaux.client_id
SET c.segment = CASE WHEN totaux.total > 2000 THEN 'premium' ELSE 'standard' END;
```


────────────────────────────────────────────────────────────────────────────────
35.4 UPDATE AVEC RETURNING — VOIR CE QUI A CHANGÉ
────────────────────────────────────────────────────────────────────────────────

Comme INSERT, UPDATE supporte RETURNING pour voir les lignes modifiées.

```sql
-- Mettre à jour le statut et récupérer les commandes affectées
UPDATE commandes
SET
    statut            = 'expédié',
    date_modification = CURRENT_TIMESTAMP
WHERE statut = 'en_traitement'
  AND date_commande < CURRENT_DATE - INTERVAL '2 days'
RETURNING commande_id, numero_commande, client_id, statut;
```

C'est très utile pour :
  - Loguer les modifications dans une table d'audit
  - Confirmer dans l'application quelles lignes ont été modifiées
  - Déclencher des actions sur les lignes modifiées


────────────────────────────────────────────────────────────────────────────────
35.5 MISE À JOUR CONDITIONNELLE AVEC CASE WHEN
────────────────────────────────────────────────────────────────────────────────

On peut utiliser CASE WHEN dans SET pour appliquer différentes valeurs selon
des conditions.

```sql
-- Mise à jour du statut selon les règles métier
UPDATE commandes
SET statut = CASE
    WHEN montant_total = 0        THEN 'annulé'
    WHEN date_commande < CURRENT_DATE - INTERVAL '30 days'
         AND statut = 'en_attente' THEN 'annulé'
    WHEN statut = 'en_traitement'
         AND date_commande < CURRENT_DATE - INTERVAL '3 days'
                              THEN 'expédié'
    ELSE statut  -- Pas de changement pour les autres cas
END
WHERE statut NOT IN ('livré', 'annulé');  -- Ne pas modifier les statuts finaux
```

Mise à jour avec calcul conditionnel :
```sql
-- Appliquer des remises différentes selon le segment client
UPDATE produits p
SET prix_vente = prix_vente * 0.90  -- Soldes : -10%
WHERE categorie_id IN (
    SELECT categorie_id
    FROM categories
    WHERE nom_categorie IN ('Mode', 'Sports')
)
AND actif = TRUE;
```


────────────────────────────────────────────────────────────────────────────────
35.6 EXERCICES UPDATE
────────────────────────────────────────────────────────────────────────────────

EXERCICE 35-1 (Facile) — Mettre à jour le téléphone d'un client
Mettre à jour le téléphone du client avec email 'jean.martin@email.com'
vers le numéro '0698765432'.

SOLUTION 35-1 :
```sql
-- Étape 1 : vérifier
SELECT client_id, nom, prenom, telephone
FROM clients WHERE email = 'jean.martin@email.com';

-- Étape 2 : mettre à jour
UPDATE clients
SET telephone = '0698765432'
WHERE email = 'jean.martin@email.com'
RETURNING client_id, nom, prenom, telephone;
```


EXERCICE 35-2 (Facile) — Désactiver les produits sans stock
Désactiver (actif = FALSE) tous les produits dont le stock total est 0.

SOLUTION 35-2 :
```sql
-- Vérifier d'abord
SELECT p.produit_id, p.nom_produit, p.actif,
       COALESCE(SUM(se.quantite_disponible), 0) AS stock_total
FROM produits p
LEFT JOIN stocks_entrepot se ON p.produit_id = se.produit_id
GROUP BY p.produit_id, p.nom_produit, p.actif
HAVING COALESCE(SUM(se.quantite_disponible), 0) = 0
  AND p.actif = TRUE;

-- Mettre à jour
UPDATE produits p
SET actif = FALSE
WHERE NOT EXISTS (
    SELECT 1 FROM stocks_entrepot se
    WHERE se.produit_id = p.produit_id
      AND se.quantite_disponible > 0
)
AND p.actif = TRUE
RETURNING produit_id, nom_produit, actif;
```


EXERCICE 35-3 (Moyen) — Recalcul automatique du segment clients
Mettre à jour le segment de tous les clients selon leurs achats :
  - > 3000€ ou >= 5 commandes -> 'vip'
  - > 1000€ ou >= 3 commandes -> 'premium'
  - Sinon -> 'standard'
Clients sans commande restent 'standard'.

SOLUTION 35-3 :
```sql
UPDATE clients c
SET segment = CASE
    WHEN stats.total_achats > 3000 OR stats.nb_commandes >= 5 THEN 'vip'
    WHEN stats.total_achats > 1000 OR stats.nb_commandes >= 3 THEN 'premium'
    ELSE 'standard'
END
FROM (
    SELECT
        client_id,
        COALESCE(SUM(montant_total), 0) AS total_achats,
        COUNT(commande_id)               AS nb_commandes
    FROM commandes
    WHERE statut != 'annulé'
    GROUP BY client_id
) AS stats
WHERE c.client_id = stats.client_id;

-- Les clients sans commande (absents du sous-SELECT) gardent leur segment actuel.
-- Pour les forcer à 'standard' :
UPDATE clients
SET segment = 'standard'
WHERE client_id NOT IN (
    SELECT DISTINCT client_id FROM commandes WHERE client_id IS NOT NULL
);
```


EXERCICE 35-4 (Avancé) — Mise à jour en cascade avec log
Annuler toutes les commandes 'en_attente' de plus de 7 jours.
Insérer une ligne dans une table `log_modifications` pour chaque commande annulée.

SOLUTION 35-4 :
```sql
-- Créer la table de log si elle n'existe pas
CREATE TABLE IF NOT EXISTS log_modifications (
    log_id        SERIAL PRIMARY KEY,
    table_name    VARCHAR(50),
    operation     VARCHAR(20),
    record_id     INTEGER,
    ancien_statut VARCHAR(50),
    nouveau_statut VARCHAR(50),
    motif         TEXT,
    modifie_le    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Mettre à jour ET récupérer les lignes modifiées
WITH commandes_annulees AS (
    UPDATE commandes
    SET
        statut            = 'annulé',
        date_modification = CURRENT_TIMESTAMP
    WHERE statut = 'en_attente'
      AND date_commande < CURRENT_DATE - INTERVAL '7 days'
    RETURNING commande_id, 'en_attente' AS ancien_statut, statut AS nouveau_statut
)
INSERT INTO log_modifications (table_name, operation, record_id,
                                ancien_statut, nouveau_statut, motif)
SELECT
    'commandes',
    'UPDATE',
    commande_id,
    ancien_statut,
    nouveau_statut,
    'Annulation automatique après 7 jours sans traitement'
FROM commandes_annulees;
```


================================================================================
CHAPITRE 36 : DELETE ET TRUNCATE — SUPPRIMER DES DONNÉES
================================================================================

────────────────────────────────────────────────────────────────────────────────
36.1 SYNTAXE DE BASE — DELETE
────────────────────────────────────────────────────────────────────────────────

Syntaxe :
```sql
DELETE FROM nom_table
WHERE condition;
```

RÈGLE ABSOLUE : TOUJOURS inclure une clause WHERE dans un DELETE.
Un DELETE sans WHERE supprime TOUTES les lignes de la table.

```sql
-- [X] CATASTROPHE : supprime TOUS les clients
DELETE FROM clients;

-- [OK] Correct : toujours filtrer
DELETE FROM clients WHERE client_id = 15;
```

Vérification avant suppression (même conseil que pour UPDATE) :
```sql
-- Étape 1 : voir ce qui sera supprimé
SELECT * FROM commandes WHERE statut = 'annulé' AND date_commande < '2023-01-01';

-- Étape 2 : supprimer (si résultat correct)
DELETE FROM commandes WHERE statut = 'annulé' AND date_commande < '2023-01-01';
```


────────────────────────────────────────────────────────────────────────────────
36.2 DELETE AVEC SOUS-REQUÊTE
────────────────────────────────────────────────────────────────────────────────

Supprimer des lignes basées sur des conditions dans une autre table.

```sql
-- Supprimer les avis des clients inactifs (supprimés de la base)
DELETE FROM avis
WHERE client_id NOT IN (
    SELECT client_id FROM clients
);

-- Supprimer les lignes de commande des commandes annulées de 2022
DELETE FROM lignes_commande
WHERE commande_id IN (
    SELECT commande_id
    FROM commandes
    WHERE statut = 'annulé'
      AND EXTRACT(YEAR FROM date_commande) = 2022
);
```

DELETE avec USING (syntaxe PostgreSQL, équivalent à JOIN) :
```sql
-- PostgreSQL : supprimer les commandes en utilisant une jointure
DELETE FROM lignes_commande lc
USING commandes c
WHERE lc.commande_id = c.commande_id
  AND c.statut = 'annulé'
  AND c.date_commande < '2023-01-01';
```


────────────────────────────────────────────────────────────────────────────────
36.3 CONTRAINTES DE CLÉ ÉTRANGÈRE ET SUPPRESSION
────────────────────────────────────────────────────────────────────────────────

Les clés étrangères peuvent bloquer la suppression si des lignes dépendantes
existent dans d'autres tables.

```sql
-- [X] Erreur : des commandes référencent ce client
DELETE FROM clients WHERE client_id = 1;
-- ERROR: update or delete on table "clients" violates foreign key constraint
-- DETAIL: Key (client_id)=(1) is still referenced from table "commandes"
```

Solutions :

SOLUTION 1 — Supprimer les dépendances d'abord (ordre correct)
```sql
-- Supprimer dans l'ordre : dépendants d'abord, parents ensuite
DELETE FROM avis          WHERE client_id = 1;
DELETE FROM paiements     WHERE commande_id IN (SELECT commande_id FROM commandes WHERE client_id = 1);
DELETE FROM lignes_commande WHERE commande_id IN (SELECT commande_id FROM commandes WHERE client_id = 1);
DELETE FROM commandes     WHERE client_id = 1;
DELETE FROM clients       WHERE client_id = 1;
```

SOLUTION 2 — ON DELETE CASCADE (géré automatiquement par la FK)
```sql
-- Si la FK est définie avec CASCADE :
ALTER TABLE commandes
    DROP CONSTRAINT IF EXISTS commandes_client_id_fkey,
    ADD CONSTRAINT commandes_client_id_fkey
        FOREIGN KEY (client_id)
        REFERENCES clients(client_id)
        ON DELETE CASCADE;

-- Maintenant, supprimer un client supprime aussi ses commandes automatiquement
DELETE FROM clients WHERE client_id = 1;
-- Les commandes et lignes_commande associées sont supprimées en cascade
```

SOLUTION 3 — Soft delete (préféré en production)
```sql
-- Ne pas supprimer réellement. Marquer comme supprimé.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS supprime BOOLEAN DEFAULT FALSE;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS date_suppression TIMESTAMP;

UPDATE clients
SET supprime = TRUE, date_suppression = CURRENT_TIMESTAMP
WHERE client_id = 1;

-- Toutes les requêtes filtrent les supprimés
SELECT * FROM clients WHERE supprime = FALSE;
```

Le soft delete est largement préféré en production car :
  - Possibilité de restauration (accident, recours légal)
  - Historique complet conservé
  - Conformité RGPD (avec possibilité de purge planifiée)
  - Facilite l'audit des données


────────────────────────────────────────────────────────────────────────────────
36.4 DELETE AVEC RETURNING
────────────────────────────────────────────────────────────────────────────────

```sql
-- Supprimer et récupérer les lignes supprimées
DELETE FROM commandes
WHERE statut = 'annulé'
  AND date_commande < '2023-01-01'
RETURNING commande_id, numero_commande, client_id, montant_total;
```

Utilisation classique : archiver avant de supprimer
```sql
-- Archiver et supprimer en une transaction
BEGIN;

-- Archiver
INSERT INTO commandes_archive
SELECT *, CURRENT_TIMESTAMP AS date_archivage
FROM commandes
WHERE statut = 'annulé' AND date_commande < '2023-01-01';

-- Vérifier le nombre de lignes archivées
-- (dans une application, vérifier que les counts correspondent)

-- Supprimer les originaux
DELETE FROM commandes
WHERE statut = 'annulé' AND date_commande < '2023-01-01';

COMMIT;
```


────────────────────────────────────────────────────────────────────────────────
36.5 TRUNCATE — SUPPRESSION RAPIDE DE TOUTES LES LIGNES
────────────────────────────────────────────────────────────────────────────────

TRUNCATE supprime TOUTES les lignes d'une table en une seule opération très
rapide. Contrairement à DELETE, TRUNCATE ne lit pas les lignes une par une.

```sql
-- Vider complètement une table
TRUNCATE TABLE commandes_archive;

-- TRUNCATE avec CASCADE (pour les tables avec FK)
TRUNCATE TABLE clients CASCADE;
-- Supprime aussi les commandes, lignes_commande, etc. référençant clients

-- TRUNCATE avec RESTART IDENTITY (remet les séquences SERIAL à 1)
TRUNCATE TABLE produits RESTART IDENTITY;
-- Les prochains INSERT auront produit_id = 1
```

Différences fondamentales TRUNCATE vs DELETE :

  Critère              | DELETE                      | TRUNCATE
  ─────────────────────┼─────────────────────────────┼──────────────────────
  Vitesse              | Lent sur grande table        | Très rapide
  WHERE                | Oui                          | Non (tout ou rien)
  RETURNING            | Oui                          | Non
  Déclenche triggers   | Oui (ROW-level triggers)     | Non (sauf stmt level)
  ROLLBACK possible    | Oui (dans transaction)       | Oui (dans transaction)
  Réinitialise SERIAL  | Non (par défaut)             | Avec RESTART IDENTITY
  Vérifie FK           | Oui                          | Oui (sauf TRUNCATE ... CASCADE)

Quand utiliser TRUNCATE :
  - Vider des tables de staging avant un rechargement de données
  - Réinitialiser des tables de cache ou de log
  - Environnements de test/développement (reset entre tests)

TRUNCATE ne doit JAMAIS être utilisé sur des tables de production avec des
données importantes sans backup préalable.


────────────────────────────────────────────────────────────────────────────────
36.6 STRATÉGIES DE PURGE DE DONNÉES — BONNES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

En production, supprimer des données n'est pas anodin. Voici les stratégies.

STRATÉGIE 1 — Soft Delete (recommandé)
```sql
-- Ajouter un flag supprime à la table
ALTER TABLE commandes ADD COLUMN supprime BOOLEAN DEFAULT FALSE;

-- Supprimer = marquer
UPDATE commandes SET supprime = TRUE WHERE commande_id = 5;

-- Vue pour masquer les supprimés
CREATE VIEW commandes_actives AS
SELECT * FROM commandes WHERE supprime = FALSE;
```

STRATÉGIE 2 — Archivage et purge
```sql
-- Archiver les vieilles données dans une table d'historique
INSERT INTO commandes_historique SELECT * FROM commandes
WHERE date_commande < CURRENT_DATE - INTERVAL '2 years';

-- Purger la table principale
DELETE FROM commandes
WHERE date_commande < CURRENT_DATE - INTERVAL '2 years';
```

STRATÉGIE 3 — Partitionnement par date (avancé)
Pour les très grandes tables, on peut partitionner par date.
Supprimer une partition entière est instantané et ne fragmente pas la table.
(Voir partie 12 pour les détails sur le partitionnement)

STRATÉGIE 4 — RGPD et droit à l'oubli
```sql
-- Anonymiser au lieu de supprimer (conserve les statistiques)
UPDATE clients
SET
    nom         = 'ANONYME',
    prenom      = 'ANONYME',
    email       = 'anonyme_' || client_id || '@supprime.invalid',
    telephone   = NULL,
    adresse     = NULL
WHERE client_id = 5;
-- Les commandes restent intactes pour les statistiques, le client est anonymisé
```


────────────────────────────────────────────────────────────────────────────────
36.7 EXERCICES DELETE
────────────────────────────────────────────────────────────────────────────────

EXERCICE 36-1 (Facile) — Supprimer les avis avec une note de 1 étoile
avant une date donnée. Vérifier d'abord avec SELECT.

SOLUTION 36-1 :
```sql
-- Vérification
SELECT avis_id, produit_id, note, commentaire, date_avis
FROM avis
WHERE note = 1 AND date_avis < '2024-01-01';

-- Suppression si OK
DELETE FROM avis
WHERE note = 1 AND date_avis < '2024-01-01'
RETURNING avis_id, produit_id, note;
```


EXERCICE 36-2 (Moyen) — Implémenter le soft delete sur les produits
Ajouter une colonne `supprime` et `date_suppression` à la table produits.
Puis "supprimer" tous les produits inactifs jamais commandés.

SOLUTION 36-2 :
```sql
-- Ajouter les colonnes
ALTER TABLE produits
    ADD COLUMN IF NOT EXISTS supprime BOOLEAN DEFAULT FALSE,
    ADD COLUMN IF NOT EXISTS date_suppression TIMESTAMP;

-- Soft delete des produits inactifs jamais commandés
UPDATE produits
SET
    supprime = TRUE,
    date_suppression = CURRENT_TIMESTAMP
WHERE actif = FALSE
  AND supprime = FALSE
  AND NOT EXISTS (
      SELECT 1 FROM lignes_commande lc WHERE lc.produit_id = produits.produit_id
  )
RETURNING produit_id, nom_produit, date_suppression;
```


EXERCICE 36-3 (Avancé) — Script de purge complète avec archivage
Écrire un script qui :
  1. Archive les commandes annulées avant 2024 dans `commandes_archive`
  2. Supprime les lignes de commande correspondantes
  3. Supprime les commandes archivées
  4. Log le résultat dans `log_modifications`

SOLUTION 36-3 :
```sql
BEGIN;

-- Étape 1 : Créer la table d'archive si nécessaire
CREATE TABLE IF NOT EXISTS commandes_archive (
    commande_id     INTEGER,
    numero_commande VARCHAR(20),
    client_id       INTEGER,
    montant_total   DECIMAL(10,2),
    statut          VARCHAR(50),
    date_commande   DATE,
    date_archivage  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Étape 2 : Archiver les commandes annulées avant 2024
INSERT INTO commandes_archive
    (commande_id, numero_commande, client_id, montant_total, statut, date_commande)
SELECT commande_id, numero_commande, client_id, montant_total, statut, date_commande
FROM commandes
WHERE statut = 'annulé'
  AND date_commande < '2024-01-01';

-- Étape 3 : Supprimer les lignes de commande associées
WITH ids AS (
    SELECT commande_id FROM commandes
    WHERE statut = 'annulé' AND date_commande < '2024-01-01'
)
DELETE FROM lignes_commande WHERE commande_id IN (SELECT commande_id FROM ids);

-- Étape 4 : Supprimer les commandes
WITH supprimees AS (
    DELETE FROM commandes
    WHERE statut = 'annulé' AND date_commande < '2024-01-01'
    RETURNING commande_id
)
INSERT INTO log_modifications (table_name, operation, record_id, motif)
SELECT 'commandes', 'DELETE', commande_id, 'Purge annuelle commandes annulées avant 2024'
FROM supprimees;

COMMIT;
```


================================================================================
SYNTHÈSE DE LA PARTIE 8 — MANIPULATION DE DONNÉES
================================================================================

TABLEAU RÉCAPITULATIF DES INSTRUCTIONS DML
────────────────────────────────────────────
  Instruction | Usage                        | RETURNING | WHERE obligatoire ?
  ────────────┼──────────────────────────────┼───────────┼────────────────────
  INSERT      | Créer de nouvelles lignes    | Oui       | Non (insert tout)
  UPDATE      | Modifier des lignes          | Oui       | OUI — sinon tout !
  DELETE      | Supprimer des lignes         | Oui       | OUI — sinon tout !
  TRUNCATE    | Vider une table entièrement  | Non       | N/A (tout toujours)

RÈGLES D'OR DML
────────────────
  1. TOUJOURS lister les colonnes dans INSERT INTO (...) VALUES
  2. TOUJOURS écrire le SELECT avant un UPDATE ou DELETE pour vérifier
  3. JAMAIS d'UPDATE ou DELETE sans WHERE (sauf si intentionnel et documenté)
  4. Utiliser ON CONFLICT pour les UPSERT (atomique, sans race condition)
  5. RETURNING récupère les valeurs insérées/modifiées/supprimées
  6. Soft delete préféré en production (audit, restauration, RGPD)
  7. Archiver avant de supprimer les données de valeur
  8. Toujours utiliser des transactions pour les opérations multi-tables
  9. Tester dans une transaction et ROLLBACK si résultat inattendu :
```sql
BEGIN;
UPDATE commandes SET statut = 'annulé' WHERE ...;
SELECT * FROM commandes WHERE ...; -- vérifier
ROLLBACK;  -- annuler si résultat incorrect
-- ou COMMIT si correct
```

ORDRE DE SUPPRESSION POUR LES FK
──────────────────────────────────
  Dans ShopFlow, pour supprimer un client proprement :
  1. avis (client_id -> clients)
  2. paiements (commande_id -> commandes -> client_id)
  3. lignes_commande (commande_id -> commandes -> client_id)
  4. commandes (client_id -> clients)
  5. clients

================================================================================
FIN DE LA PARTIE 8 — MANIPULATION DE DONNÉES
================================================================================
Chapitres couverts : 34 (INSERT), 35 (UPDATE), 36 (DELETE et TRUNCATE)
Prochaine partie : PARTIE 9 — FONCTIONS SQL (numériques, texte, dates)
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 9
FONCTIONS SQL : NUMÉRIQUES, TEXTE, DATES
Chapitres 37 à 39
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Intermédiaire
================================================================================

TABLE DES MATIÈRES — PARTIE 9
══════════════════════════════
  Chapitre 37 : Fonctions numériques
  Chapitre 38 : Fonctions de texte
  Chapitre 39 : Fonctions de date et heure


================================================================================
CHAPITRE 37 : FONCTIONS NUMÉRIQUES
================================================================================

────────────────────────────────────────────────────────────────────────────────
37.1 INTRODUCTION — FONCTIONS SCALAIRES VS AGRÉGATS
────────────────────────────────────────────────────────────────────────────────

Rappel important avant de commencer :

FONCTIONS SCALAIRES (ce chapitre) :
  Opèrent sur UNE valeur et retournent UNE valeur.
  S'appliquent ligne par ligne.
  Exemple : ROUND(14.567, 2) -> 14.57

FONCTIONS D'AGRÉGATION (partie 5) :
  Opèrent sur UN GROUPE de valeurs et retournent UNE valeur.
  Requièrent souvent GROUP BY.
  Exemple : SUM(montant_total) -> une somme pour tout le groupe

Les fonctions de ce chapitre (37, 38, 39) sont toutes des fonctions scalaires.
Elles peuvent être utilisées dans SELECT, WHERE, ORDER BY, CASE WHEN, etc.


────────────────────────────────────────────────────────────────────────────────
37.2 FONCTIONS D'ARRONDI ET DE TRONCATURE
────────────────────────────────────────────────────────────────────────────────

ROUND(nombre, decimales) — Arrondir à N décimales
```sql
SELECT
    ROUND(14.567, 2)   AS deux_decimales,   -- 14.57
    ROUND(14.567, 1)   AS une_decimale,     -- 14.6
    ROUND(14.567, 0)   AS zero_decimale,    -- 15
    ROUND(14.4,   0)   AS arrondi_inf,      -- 14 (arrondi au plus proche)
    ROUND(14.5,   0)   AS arrondi_sup;      -- 15 (arrondi au plus proche)
```

Application ShopFlow :
```sql
SELECT
    nom_produit,
    prix_vente,
    prix_achat,
    ROUND(prix_vente - prix_achat, 2)                          AS marge_euro,
    ROUND((prix_vente - prix_achat) / prix_achat * 100, 1)    AS marge_pct
FROM produits
ORDER BY marge_pct DESC;
```

CEIL(nombre) — Arrondir AU SUPÉRIEUR (plafond)
CEILING est un alias de CEIL.
```sql
SELECT
    CEIL(14.1)    AS plafond_14_1,   -- 15
    CEIL(14.9)    AS plafond_14_9,   -- 15
    CEIL(-14.1)   AS plafond_neg,    -- -14 (vers le haut = vers 0)
    CEIL(14.0)    AS plafond_exact;  -- 14
```

FLOOR(nombre) — Arrondir AU INFÉRIEUR (plancher)
```sql
SELECT
    FLOOR(14.9)   AS plancher_14_9,  -- 14
    FLOOR(14.1)   AS plancher_14_1,  -- 14
    FLOOR(-14.1)  AS plancher_neg,   -- -15 (vers le bas = vers -∞)
    FLOOR(14.0)   AS plancher_exact; -- 14
```

TRUNC(nombre, decimales) — Tronquer (couper sans arrondir)
```sql
SELECT
    TRUNC(14.999, 2)   AS tronque_2,     -- 14.99 (pas arrondi, juste coupé)
    TRUNC(14.999, 1)   AS tronque_1,     -- 14.9
    TRUNC(14.999, 0)   AS tronque_0,     -- 14
    TRUNC(-14.999, 2)  AS tronque_neg;   -- -14.99
```

Différence ROUND vs TRUNC :
```sql
SELECT
    ROUND(14.999, 2)   AS arrondi,  -- 15.00 (arrondi vers le haut)
    TRUNC(14.999, 2)   AS tronque;  -- 14.99 (coupé sans arrondir)
```


────────────────────────────────────────────────────────────────────────────────
37.3 FONCTIONS MATHÉMATIQUES
────────────────────────────────────────────────────────────────────────────────

ABS(nombre) — Valeur absolue
```sql
SELECT
    ABS(15)    AS abs_positif,   -- 15
    ABS(-15)   AS abs_negatif,   -- 15
    ABS(0)     AS abs_zero;      -- 0

-- Application : trouver les produits dont le prix s'écarte de plus de 50€
-- de la moyenne (dans n'importe quel sens)
SELECT nom_produit, prix_vente,
       ABS(prix_vente - (SELECT AVG(prix_vente) FROM produits)) AS ecart_abs
FROM produits
WHERE ABS(prix_vente - (SELECT AVG(prix_vente) FROM produits)) > 50
ORDER BY ecart_abs DESC;
```

POWER(base, exposant) ou base^exposant — Puissance
```sql
SELECT
    POWER(2, 10)    AS deux_puissance_10,   -- 1024
    POWER(10, 3)    AS dix_cube,            -- 1000
    2^8             AS notation_pg;         -- 256 (syntaxe PostgreSQL)
```

SQRT(nombre) — Racine carrée
```sql
SELECT SQRT(144)   AS racine;   -- 12
```

MOD(dividende, diviseur) ou dividende % diviseur — Modulo (reste de division)
```sql
SELECT
    MOD(17, 5)    AS modulo,         -- 2  (17 = 3×5 + 2)
    17 % 5        AS modulo_syntaxe, -- 2
    MOD(10, 2)    AS pair_test;      -- 0  (pair si 0, impair si 1)

-- Application : numéroter les lignes et distinguer pair/impair
SELECT
    commande_id,
    numero_commande,
    MOD(ROW_NUMBER() OVER (ORDER BY commande_id), 2) AS pair_impair
FROM commandes;
```

DIV(dividende, diviseur) — Division entière (quotient sans reste)
```sql
SELECT DIV(17, 5)  AS division_entiere;  -- 3 (pas de décimales)
```

SIGN(nombre) — Signe d'un nombre (-1, 0, 1)
```sql
SELECT
    SIGN(-42)  AS negatif,   -- -1
    SIGN(0)    AS zero,       -- 0
    SIGN(42)   AS positif;    -- 1
```

EXP(n) — Exponentielle (e^n)
LN(n) — Logarithme naturel
LOG(base, n) — Logarithme en base quelconque
```sql
SELECT
    EXP(1)          AS e,              -- 2.718...
    LN(EXP(1))      AS ln_de_e,        -- 1
    LOG(10, 1000)   AS log10_1000;     -- 3
```

PI() — Valeur de Pi
```sql
SELECT PI();  -- 3.14159265358979...
```

RANDOM() — Nombre aléatoire entre 0 et 1
```sql
SELECT RANDOM();  -- Ex: 0.7823...

-- Application : sélectionner 3 clients au hasard pour un échantillon
SELECT nom, prenom, email
FROM clients
ORDER BY RANDOM()
LIMIT 3;
```


────────────────────────────────────────────────────────────────────────────────
37.4 FONCTIONS NUMÉRIQUES APPLIQUÉES À SHOPFLOW
────────────────────────────────────────────────────────────────────────────────

Rapport de marges et rentabilité par produit :
```sql
SELECT
    p.nom_produit,
    cat.nom_categorie,
    p.prix_achat,
    p.prix_vente,
    ROUND(p.prix_vente - p.prix_achat, 2)                           AS marge_brute,
    ROUND((p.prix_vente - p.prix_achat) / p.prix_achat * 100, 1)   AS taux_marge_pct,
    ROUND(p.prix_vente * 0.20, 2)                                   AS tva_20pct,
    ROUND(p.prix_vente / 1.20, 2)                                   AS prix_ht,
    CASE
        WHEN (p.prix_vente - p.prix_achat) / p.prix_achat > 0.5 THEN 'Forte marge'
        WHEN (p.prix_vente - p.prix_achat) / p.prix_achat > 0.3 THEN 'Bonne marge'
        WHEN (p.prix_vente - p.prix_achat) / p.prix_achat > 0.1 THEN 'Marge correcte'
        ELSE 'Marge faible'
    END AS categorie_marge
FROM produits p
JOIN categories cat ON p.categorie_id = cat.categorie_id
WHERE p.actif = TRUE
ORDER BY taux_marge_pct DESC;
```

Simulation de remises dégressive :
```sql
SELECT
    nom_produit,
    prix_vente                                  AS prix_normal,
    ROUND(prix_vente * 0.95, 2)                AS prix_5pct,
    ROUND(prix_vente * 0.90, 2)                AS prix_10pct,
    ROUND(prix_vente * 0.80, 2)                AS prix_20pct,
    ROUND(prix_vente * 0.70, 2)                AS prix_30pct,
    ROUND(CEIL(prix_vente * 0.95), 2)          AS prix_5pct_arrondi_sup
FROM produits
WHERE actif = TRUE
ORDER BY prix_vente DESC;
```


────────────────────────────────────────────────────────────────────────────────
37.5 EXERCICES FONCTIONS NUMÉRIQUES
────────────────────────────────────────────────────────────────────────────────

EXERCICE 37-1 (Facile) — Calcul de prix TTC et HT
Pour chaque produit, calculer le prix HT (sans TVA), la TVA (20%), et le prix TTC.
Supposer que le prix_vente est déjà HT.

SOLUTION 37-1 :
```sql
SELECT
    nom_produit,
    ROUND(prix_vente, 2)               AS prix_ht,
    ROUND(prix_vente * 0.20, 2)        AS tva,
    ROUND(prix_vente * 1.20, 2)        AS prix_ttc
FROM produits
WHERE actif = TRUE
ORDER BY prix_ttc DESC;
```


EXERCICE 37-2 (Moyen) — Statistiques de dispersion des prix
Calculer pour chaque catégorie : prix min, max, moyen, et coefficient de
variation (écart-type / moyenne × 100).

SOLUTION 37-2 :
```sql
SELECT
    cat.nom_categorie,
    COUNT(p.produit_id)                                    AS nb_produits,
    ROUND(MIN(p.prix_vente), 2)                            AS prix_min,
    ROUND(MAX(p.prix_vente), 2)                            AS prix_max,
    ROUND(AVG(p.prix_vente), 2)                            AS prix_moyen,
    ROUND(STDDEV(p.prix_vente), 2)                         AS ecart_type,
    ROUND(STDDEV(p.prix_vente) / NULLIF(AVG(p.prix_vente), 0) * 100, 1)
                                                           AS coeff_variation_pct
FROM categories cat
JOIN produits p ON cat.categorie_id = p.categorie_id
WHERE p.actif = TRUE
GROUP BY cat.categorie_id, cat.nom_categorie
ORDER BY coeff_variation_pct DESC;
```


================================================================================
CHAPITRE 38 : FONCTIONS DE TEXTE
================================================================================

────────────────────────────────────────────────────────────────────────────────
38.1 FONCTIONS DE CAS (MAJUSCULES/MINUSCULES)
────────────────────────────────────────────────────────────────────────────────

UPPER(texte) — Convertir en MAJUSCULES
LOWER(texte) — Convertir en minuscules
INITCAP(texte) — Première lettre de chaque mot en majuscule

```sql
SELECT
    UPPER('bonjour monde')   AS majuscules,    -- BONJOUR MONDE
    LOWER('BONJOUR MONDE')   AS minuscules,    -- bonjour monde
    INITCAP('bonjour monde') AS titre;         -- Bonjour Monde

-- Application : normaliser les noms à l'affichage
SELECT
    client_id,
    UPPER(nom)           AS nom_formate,
    INITCAP(LOWER(prenom)) AS prenom_formate,
    LOWER(email)         AS email_normalise
FROM clients;
```

Utilité de LOWER/UPPER dans les recherches insensibles à la casse :
```sql
-- Recherche insensible à la casse
SELECT * FROM clients
WHERE LOWER(nom) = LOWER('martin');
-- Trouve 'Martin', 'MARTIN', 'martin', etc.

-- En PostgreSQL, ILIKE est souvent préférable (plus efficace) :
SELECT * FROM clients WHERE nom ILIKE 'martin';
```


────────────────────────────────────────────────────────────────────────────────
38.2 FONCTIONS DE LONGUEUR ET DE DÉCOUPE
────────────────────────────────────────────────────────────────────────────────

LENGTH(texte) — Nombre de caractères
CHAR_LENGTH est un alias de LENGTH.
```sql
SELECT
    LENGTH('Bonjour')          AS longueur,         -- 7
    LENGTH('  texte  ')        AS avec_espaces,      -- 9
    LENGTH('')                 AS vide,              -- 0
    LENGTH(NULL)               AS null_val;          -- NULL

-- Application : trouver les produits avec un nom très long
SELECT nom_produit, LENGTH(nom_produit) AS longueur
FROM produits
ORDER BY longueur DESC;
```

SUBSTRING(texte, debut, longueur) / SUBSTR — Extraire une sous-chaîne
```sql
SELECT
    SUBSTRING('Bonjour Monde', 9, 5)   AS extrait,    -- Monde
    SUBSTRING('Bonjour Monde', 1, 7)   AS debut,      -- Bonjour
    SUBSTR('Bonjour Monde', 9)         AS depuis_9;   -- Monde (jusqu'à la fin)

-- Application : extraire le code pays de l'email
SELECT
    email,
    SUBSTRING(email FROM POSITION('@' IN email) + 1) AS domaine
FROM clients;
```

LEFT(texte, n) — Les n premiers caractères
RIGHT(texte, n) — Les n derniers caractères
```sql
SELECT
    LEFT('ABCDEF', 3)    AS gauche,    -- ABC
    RIGHT('ABCDEF', 3)   AS droite,    -- DEF
    LEFT('CMD-0001', 3)  AS prefix;    -- CMD

-- Application : extraire le code de la référence produit
SELECT
    reference,
    LEFT(reference, 3)             AS code_categorie,
    RIGHT(reference, 4)            AS numero
FROM produits;
```

POSITION(sous_chaine IN texte) — Position d'une sous-chaîne (0 si absente)
STRPOS(texte, sous_chaine) — Identique (syntaxe alternative PostgreSQL)
```sql
SELECT
    POSITION('@' IN 'user@example.com')     AS pos_arobase,    -- 5
    STRPOS('user@example.com', '@')          AS pos_arobase2,   -- 5
    POSITION('xyz' IN 'user@example.com')   AS absent;          -- 0
```


────────────────────────────────────────────────────────────────────────────────
38.3 FONCTIONS DE SUPPRESSION D'ESPACES
────────────────────────────────────────────────────────────────────────────────

TRIM(texte) — Supprimer espaces en début ET fin
LTRIM(texte) — Supprimer espaces au début (gauche)
RTRIM(texte) — Supprimer espaces à la fin (droite)
```sql
SELECT
    TRIM('  bonjour  ')    AS trim,    -- 'bonjour'
    LTRIM('  bonjour  ')   AS ltrim,   -- 'bonjour  '
    RTRIM('  bonjour  ')   AS rtrim;   -- '  bonjour'

-- TRIM peut aussi supprimer des caractères spécifiques :
SELECT TRIM(BOTH '0' FROM '00042000')  AS sans_zeros;  -- '42'

-- Application : nettoyer les données importées avec des espaces parasites
UPDATE clients
SET
    nom    = TRIM(nom),
    prenom = TRIM(prenom),
    email  = TRIM(LOWER(email))
WHERE nom != TRIM(nom)
   OR prenom != TRIM(prenom)
   OR email != TRIM(LOWER(email));
```


────────────────────────────────────────────────────────────────────────────────
38.4 FONCTIONS DE REMPLACEMENT ET RÉPÉTITION
────────────────────────────────────────────────────────────────────────────────

REPLACE(texte, recherche, remplacement) — Remplacer toutes les occurrences
```sql
SELECT
    REPLACE('Jean-Pierre Dupont', '-', ' ')   AS sans_tiret,   -- Jean Pierre Dupont
    REPLACE('0033612345678', '0033', '0')     AS format_fr;    -- 0612345678

-- Application : normaliser les numéros de téléphone
SELECT REPLACE(REPLACE(REPLACE(telephone, ' ', ''), '-', ''), '.', '')
FROM clients;
```

REGEXP_REPLACE(texte, regex, remplacement) — Remplacement par expression régulière
```sql
-- Supprimer tous les espaces, tirets, points dans un téléphone
SELECT REGEXP_REPLACE('06 12.34-56.78', '[^0-9]', '', 'g') AS tel_normalise;
-- Résultat : '0612345678'
-- 'g' = global (remplacer toutes les occurrences)
```

REPEAT(texte, n) — Répéter une chaîne n fois
```sql
SELECT
    REPEAT('*', 5)      AS cinq_etoiles,   -- *****
    REPEAT('AB', 3)     AS repetition;     -- ABABAB

-- Application : afficher une barre de progression ASCII
SELECT
    nom_produit,
    ROUND((note_moyenne / 5.0) * 20) AS score_sur_20,
    REPEAT('█', ROUND((note_moyenne / 5.0) * 20)::INT)  AS barre
FROM (
    SELECT p.nom_produit, AVG(a.note) AS note_moyenne
    FROM produits p
    JOIN avis a ON p.produit_id = a.produit_id
    GROUP BY p.nom_produit
) stats;
```

LPAD(texte, longueur, caractere) — Compléter à gauche avec un caractère
RPAD(texte, longueur, caractere) — Compléter à droite avec un caractère
```sql
SELECT
    LPAD('42', 8, '0')     AS zero_padded,   -- 00000042
    LPAD('HELLO', 10, '-') AS tirets_gauche, -- -----HELLO
    RPAD('HELLO', 10, '.')  AS points_droite; -- HELLO.....

-- Application : formater les numéros de commande
SELECT LPAD(commande_id::TEXT, 6, '0') AS numero_formate
FROM commandes;
-- 1 -> 000001, 42 -> 000042, 1234 -> 001234
```


────────────────────────────────────────────────────────────────────────────────
38.5 FONCTIONS DE CONCATÉNATION
────────────────────────────────────────────────────────────────────────────────

Opérateur || — Concaténation (NULL + texte = NULL)
```sql
SELECT
    'Bonjour ' || 'Monde'           AS concat_base,   -- Bonjour Monde
    'Jean' || ' ' || 'Martin'       AS prenom_nom,     -- Jean Martin
    NULL || 'test'                  AS null_concat;    -- NULL !
```

CONCAT(val1, val2, ...) — Concaténation sûre (NULL ignoré)
```sql
SELECT
    CONCAT('Bonjour ', 'Monde')      AS concat,        -- Bonjour Monde
    CONCAT(NULL, 'test')             AS null_safe;      -- test (NULL ignoré)

-- Application
SELECT CONCAT(prenom, ' ', nom) AS nom_complet FROM clients;
```

CONCAT_WS(separateur, val1, val2, ...) — Concaténation avec séparateur
(WS = With Separator). NULL ignoré ET le séparateur n'est pas ajouté
autour des NULL.
```sql
SELECT
    CONCAT_WS(', ', 'Jean', 'Martin', NULL, 'Paris')
    AS concat_ws;  -- Jean, Martin, Paris (NULL sauté, pas de ",," )

-- Application : adresse complète sur une ligne
SELECT
    CONCAT_WS(', ',
        adresse,
        ville,
        code_postal,
        pays
    ) AS adresse_complete
FROM clients;
```

FORMAT(patron, val1, val2, ...) — Formatage à la printf
```sql
SELECT FORMAT('Produit: %s, Prix: %.2f €', nom_produit, prix_vente)
FROM produits
LIMIT 3;
-- Ex: 'Produit: Laptop ProBook 15, Prix: 1299.00 €'
```


────────────────────────────────────────────────────────────────────────────────
38.6 FONCTIONS DE RECHERCHE ET EXPRESSIONS RÉGULIÈRES
────────────────────────────────────────────────────────────────────────────────

LIKE et ILIKE (déjà vus en partie 4, rappel) :
```sql
-- LIKE : sensible à la casse
SELECT * FROM produits WHERE nom_produit LIKE 'Laptop%';

-- ILIKE : insensible à la casse (PostgreSQL)
SELECT * FROM produits WHERE nom_produit ILIKE '%pro%';
```

SIMILAR TO — Expression régulière SQL standard
```sql
SELECT * FROM clients
WHERE telephone SIMILAR TO '06[0-9]{8}';
-- Numéros commençant par 06 suivis de 8 chiffres
```

~ (tilde) — Expression régulière Perl (POSIX)
~* — Insensible à la casse
!~ — Ne correspond PAS
```sql
-- Emails valides
SELECT email FROM clients WHERE email ~ '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$';

-- Produits dont le nom contient un nombre
SELECT nom_produit FROM produits WHERE nom_produit ~ '[0-9]+';

-- Correspondance insensible à la casse
SELECT nom_produit FROM produits WHERE nom_produit ~* 'laptop|notebook|pc';
```

REGEXP_MATCHES(texte, regex, flags) — Extraire des correspondances
```sql
-- Extraire le domaine des emails
SELECT
    email,
    (REGEXP_MATCHES(email, '@(.+)$'))[1] AS domaine
FROM clients;
```


────────────────────────────────────────────────────────────────────────────────
38.7 FONCTIONS DE CONVERSION
────────────────────────────────────────────────────────────────────────────────

CAST(valeur AS type) — Conversion de type
```sql
SELECT
    CAST('42' AS INTEGER)           AS texte_vers_entier,    -- 42
    CAST(42 AS TEXT)                AS entier_vers_texte,    -- '42'
    CAST('3.14' AS DECIMAL(5,2))    AS texte_vers_decimal,   -- 3.14
    CAST('2024-03-15' AS DATE)      AS texte_vers_date;      -- 2024-03-15
```

Syntaxe courte PostgreSQL :: (double deux points)
```sql
SELECT
    '42'::INTEGER    AS conversion,
    42::TEXT         AS vers_texte,
    NOW()::DATE      AS maintenant_date;
```

TO_CHAR(valeur, format) — Conversion vers texte formaté
TO_NUMBER(texte, format) — Conversion texte vers nombre
```sql
SELECT
    TO_CHAR(1299.99, 'FM999G999D00')   AS prix_formate,    -- 1,299.99
    TO_CHAR(1299.99, 'L999G999D00')    AS prix_devise,      -- $1,299.99 (selon locale)
    TO_NUMBER('1.299,99', '9G999D99')  AS retour_nombre;    -- 1299.99
```


────────────────────────────────────────────────────────────────────────────────
38.8 EXERCICES FONCTIONS DE TEXTE
────────────────────────────────────────────────────────────────────────────────

EXERCICE 38-1 (Facile) — Normaliser les données clients
Écrire une requête SELECT qui affiche les données normalisées :
nom en majuscules, prénom en titre, email en minuscules, longueur de l'email.

SOLUTION 38-1 :
```sql
SELECT
    client_id,
    UPPER(nom)                        AS nom_normalise,
    INITCAP(LOWER(prenom))            AS prenom_normalise,
    LOWER(email)                      AS email_normalise,
    LENGTH(email)                     AS longueur_email,
    CONCAT_WS(', ', adresse, ville, code_postal, pays) AS adresse_complete
FROM clients
ORDER BY nom_normalise;
```


EXERCICE 38-2 (Moyen) — Générer des références produits
Pour chaque produit, générer une référence au format :
CAT-FOUR-NNNN où CAT = 3 premières lettres catégorie, FOUR = 3 premières
lettres fournisseur, NNNN = produit_id sur 4 chiffres avec zéros.

SOLUTION 38-2 :
```sql
SELECT
    p.nom_produit,
    UPPER(LEFT(cat.nom_categorie, 3)) || '-' ||
    UPPER(LEFT(f.nom_entreprise, 4))  || '-' ||
    LPAD(p.produit_id::TEXT, 4, '0')   AS reference_generee,
    p.reference                         AS reference_actuelle
FROM produits p
JOIN categories cat   ON p.categorie_id = cat.categorie_id
JOIN fournisseurs f   ON p.fournisseur_id = f.fournisseur_id
ORDER BY p.produit_id;
```


EXERCICE 38-3 (Avancé) — Analyse des emails et extraction de domaines
Extraire et analyser les domaines d'email des clients :
Afficher le domaine, le nombre de clients par domaine, trier par fréquence.

SOLUTION 38-3 :
```sql
SELECT
    LOWER(SUBSTRING(email FROM POSITION('@' IN email) + 1)) AS domaine,
    COUNT(*) AS nb_clients
FROM clients
GROUP BY domaine
ORDER BY nb_clients DESC;
```


================================================================================
CHAPITRE 39 : FONCTIONS DE DATE ET HEURE
================================================================================

────────────────────────────────────────────────────────────────────────────────
39.1 FONCTIONS DE DATE COURANTES
────────────────────────────────────────────────────────────────────────────────

CURRENT_DATE — Date actuelle (sans heure)
CURRENT_TIME — Heure actuelle (sans date)
CURRENT_TIMESTAMP ou NOW() — Date ET heure actuelle
```sql
SELECT
    CURRENT_DATE                AS auj_date,       -- 2024-03-15
    CURRENT_TIME                AS heure_actuelle,  -- 14:32:07.123456+01
    CURRENT_TIMESTAMP           AS maintenant,      -- 2024-03-15 14:32:07.123+01
    NOW()                       AS now_alias;       -- 2024-03-15 14:32:07.123+01
```

Différence entre CURRENT_TIMESTAMP et NOW() :
  - CURRENT_TIMESTAMP : valeur figée au début de la transaction
  - NOW() : identique à CURRENT_TIMESTAMP (même comportement dans PostgreSQL)
  - CLOCK_TIMESTAMP() : valeur réelle au moment de l'appel (change pendant une transaction)

```sql
-- Utiliser CURRENT_DATE dans les requêtes
SELECT * FROM commandes
WHERE date_commande = CURRENT_DATE;          -- commandes aujourd'hui

SELECT * FROM commandes
WHERE date_commande >= CURRENT_DATE - 7;     -- 7 derniers jours
```


────────────────────────────────────────────────────────────────────────────────
39.2 EXTRACT — EXTRAIRE UNE PARTIE D'UNE DATE
────────────────────────────────────────────────────────────────────────────────

EXTRACT(partie FROM date) retourne un nombre (FLOAT).

Parties disponibles : YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW (day of week),
DOY (day of year), WEEK, QUARTER, EPOCH, TIMEZONE_HOUR, etc.

```sql
SELECT
    EXTRACT(YEAR    FROM '2024-03-15 14:30:00'::TIMESTAMP) AS annee,    -- 2024
    EXTRACT(MONTH   FROM '2024-03-15 14:30:00'::TIMESTAMP) AS mois,     -- 3
    EXTRACT(DAY     FROM '2024-03-15 14:30:00'::TIMESTAMP) AS jour,     -- 15
    EXTRACT(HOUR    FROM '2024-03-15 14:30:00'::TIMESTAMP) AS heure,    -- 14
    EXTRACT(MINUTE  FROM '2024-03-15 14:30:00'::TIMESTAMP) AS minute,   -- 30
    EXTRACT(DOW     FROM '2024-03-15 14:30:00'::TIMESTAMP) AS jour_sem, -- 5 (vendredi, 0=dimanche)
    EXTRACT(DOY     FROM '2024-03-15 14:30:00'::TIMESTAMP) AS jour_an,  -- 75
    EXTRACT(WEEK    FROM '2024-03-15 14:30:00'::TIMESTAMP) AS semaine,  -- 11
    EXTRACT(QUARTER FROM '2024-03-15 14:30:00'::TIMESTAMP) AS trimestre; -- 1
```

Application : analyse des ventes par période
```sql
SELECT
    EXTRACT(YEAR  FROM date_commande)::INTEGER AS annee,
    EXTRACT(MONTH FROM date_commande)::INTEGER AS mois,
    EXTRACT(DOW   FROM date_commande)::INTEGER AS jour_semaine,
    COUNT(*)                                    AS nb_commandes,
    SUM(montant_total)                          AS ca
FROM commandes
WHERE statut != 'annulé'
GROUP BY annee, mois, jour_semaine
ORDER BY annee, mois, jour_semaine;
```

DATE_PART(partie, date) — Équivalent de EXTRACT (syntaxe alternative)
```sql
SELECT DATE_PART('year', CURRENT_DATE);  -- Identique à EXTRACT(YEAR FROM ...)
```


────────────────────────────────────────────────────────────────────────────────
39.3 DATE_TRUNC — TRONQUER UNE DATE
────────────────────────────────────────────────────────────────────────────────

DATE_TRUNC(precision, date) retourne la date tronquée à la précision demandée.
Très utile pour grouper par période (mois, semaine, trimestre, etc.).

```sql
SELECT
    DATE_TRUNC('year',    '2024-03-15 14:30:00'::TIMESTAMP) AS debut_annee,   -- 2024-01-01 00:00:00
    DATE_TRUNC('quarter', '2024-03-15 14:30:00'::TIMESTAMP) AS debut_trim,    -- 2024-01-01 00:00:00
    DATE_TRUNC('month',   '2024-03-15 14:30:00'::TIMESTAMP) AS debut_mois,    -- 2024-03-01 00:00:00
    DATE_TRUNC('week',    '2024-03-15 14:30:00'::TIMESTAMP) AS debut_sem,     -- 2024-03-11 00:00:00
    DATE_TRUNC('day',     '2024-03-15 14:30:00'::TIMESTAMP) AS debut_jour;    -- 2024-03-15 00:00:00
```

Utilisation typique : rapport mensuel des ventes
```sql
SELECT
    DATE_TRUNC('month', date_commande) AS mois,
    COUNT(*)                            AS nb_commandes,
    SUM(montant_total)                  AS chiffre_affaires,
    ROUND(AVG(montant_total), 2)        AS panier_moyen
FROM commandes
WHERE statut != 'annulé'
GROUP BY DATE_TRUNC('month', date_commande)
ORDER BY mois;
```

Rapport par trimestre :
```sql
SELECT
    EXTRACT(YEAR    FROM date_commande)::INTEGER    AS annee,
    EXTRACT(QUARTER FROM date_commande)::INTEGER    AS trimestre,
    'T' || EXTRACT(QUARTER FROM date_commande)::INTEGER AS libelle,
    COUNT(*)                                         AS nb_commandes,
    SUM(montant_total)                               AS ca
FROM commandes
WHERE statut != 'annulé'
GROUP BY annee, trimestre
ORDER BY annee, trimestre;
```

Rapport hebdomadaire :
```sql
SELECT
    DATE_TRUNC('week', date_commande)::DATE  AS semaine_du,
    COUNT(*)                                  AS nb_commandes,
    SUM(montant_total)                        AS ca_semaine
FROM commandes
GROUP BY semaine_du
ORDER BY semaine_du DESC;
```


────────────────────────────────────────────────────────────────────────────────
39.4 ARITHMÉTIQUE SUR LES DATES
────────────────────────────────────────────────────────────────────────────────

Ajouter ou soustraire des intervalles :
```sql
SELECT
    CURRENT_DATE + 30                               AS dans_30_jours,
    CURRENT_DATE - 7                                AS il_y_a_7_jours,
    CURRENT_DATE + INTERVAL '3 months'              AS dans_3_mois,
    CURRENT_DATE - INTERVAL '1 year'                AS il_y_a_1_an,
    CURRENT_TIMESTAMP + INTERVAL '2 hours 30 min'   AS dans_2h30,
    '2024-03-15'::DATE + INTERVAL '1 month'         AS mois_suivant;
```

Syntaxe de l'INTERVAL : '1 year', '2 months', '3 days', '4 hours',
'5 minutes', '6 seconds', '1 year 2 months 3 days', etc.

Différence entre deux dates :
```sql
SELECT
    '2024-12-31'::DATE - '2024-01-01'::DATE         AS nb_jours,        -- 365
    AGE('2024-12-31'::DATE, '2024-01-01'::DATE)      AS age_interval,    -- 11 mons 30 days
    AGE('1990-05-15'::DATE)                          AS age_depuis_naissance; -- xx years...
```

Extraire la durée en jours / heures :
```sql
SELECT
    EXTRACT(EPOCH FROM ('2024-03-15'::DATE - '2024-01-01'::DATE)) AS en_secondes,
    ('2024-03-15'::DATE - '2024-01-01'::DATE) AS en_jours;
```

Application : délai de traitement des commandes
```sql
SELECT
    numero_commande,
    date_commande,
    date_livraison,
    date_livraison - date_commande  AS delai_jours,
    CASE
        WHEN date_livraison - date_commande <= 3  THEN 'Express (≤3j)'
        WHEN date_livraison - date_commande <= 7  THEN 'Standard (4-7j)'
        WHEN date_livraison - date_commande <= 14 THEN 'Lent (8-14j)'
        ELSE 'Très lent (>14j)'
    END AS categorie_delai
FROM commandes
WHERE statut = 'livré'
  AND date_livraison IS NOT NULL
ORDER BY delai_jours DESC;
```


────────────────────────────────────────────────────────────────────────────────
39.5 TO_CHAR — FORMATER DES DATES EN TEXTE
────────────────────────────────────────────────────────────────────────────────

TO_CHAR(date, format) convertit une date en chaîne de caractères formatée.

Codes de format principaux :
  YYYY  = année sur 4 chiffres (2024)
  YY    = année sur 2 chiffres (24)
  MM    = mois sur 2 chiffres (03)
  MON   = mois abrégé (MAR)
  MONTH = mois complet (MARCH)
  DD    = jour sur 2 chiffres (15)
  DY    = jour de la semaine abrégé (FRI)
  DAY   = jour de la semaine complet (FRIDAY)
  D     = numéro du jour de la semaine (1=dimanche)
  HH24  = heure sur 24h (14)
  HH12  = heure sur 12h (02)
  MI    = minutes (30)
  SS    = secondes (45)
  MS    = millisecondes
  AM/PM = indicateur AM/PM
  Q     = trimestre (1-4)
  WW    = numéro de semaine dans l'année
  TZ    = fuseau horaire

```sql
SELECT
    TO_CHAR(CURRENT_TIMESTAMP, 'DD/MM/YYYY')            AS format_fr,
    TO_CHAR(CURRENT_TIMESTAMP, 'YYYY-MM-DD')            AS format_iso,
    TO_CHAR(CURRENT_TIMESTAMP, 'DD Month YYYY')         AS format_long,
    TO_CHAR(CURRENT_TIMESTAMP, 'Day DD Mon YYYY')       AS format_complet,
    TO_CHAR(CURRENT_TIMESTAMP, 'HH24:MI:SS')            AS format_heure,
    TO_CHAR(CURRENT_TIMESTAMP, 'DD/MM/YYYY HH24:MI')    AS format_datetime,
    TO_CHAR(CURRENT_TIMESTAMP, '"Semaine" WW de YYYY')  AS format_semaine,
    TO_CHAR(CURRENT_TIMESTAMP, '"T"Q YYYY')             AS format_trimestre;
```

Application : rapport mensuel avec format lisible
```sql
SELECT
    TO_CHAR(DATE_TRUNC('month', date_commande), 'Month YYYY') AS periode,
    COUNT(*)                                                    AS nb_commandes,
    TO_CHAR(SUM(montant_total), 'FM999G999G990D00 €')          AS ca_formate
FROM commandes
WHERE statut != 'annulé'
GROUP BY DATE_TRUNC('month', date_commande)
ORDER BY DATE_TRUNC('month', date_commande);
```


────────────────────────────────────────────────────────────────────────────────
39.6 TO_DATE ET TO_TIMESTAMP — CONVERTIR DU TEXTE EN DATE
────────────────────────────────────────────────────────────────────────────────

TO_DATE(texte, format) — Convertir une chaîne en DATE
TO_TIMESTAMP(texte, format) — Convertir une chaîne en TIMESTAMP

```sql
SELECT
    TO_DATE('15/03/2024', 'DD/MM/YYYY')              AS date_fr,
    TO_DATE('March 15 2024', 'Month DD YYYY')        AS date_en,
    TO_DATE('20240315', 'YYYYMMDD')                  AS date_compact,
    TO_TIMESTAMP('15/03/2024 14:30', 'DD/MM/YYYY HH24:MI') AS datetime;
```

Application : importer des données avec format de date non standard
```sql
-- Données importées avec format DD/MM/YYYY
UPDATE commandes
SET date_commande = TO_DATE(date_import_texte, 'DD/MM/YYYY')
WHERE date_import_texte IS NOT NULL;
```


────────────────────────────────────────────────────────────────────────────────
39.7 FONCTIONS DE DATE AVANCÉES
────────────────────────────────────────────────────────────────────────────────

MAKE_DATE(annee, mois, jour) — Construire une date à partir de composants
```sql
SELECT MAKE_DATE(2024, 3, 15);  -- 2024-03-15
```

GENERATE_SERIES — Générer une série de dates
```sql
-- Générer tous les mois de 2024
SELECT generate_series(
    '2024-01-01'::DATE,
    '2024-12-01'::DATE,
    INTERVAL '1 month'
)::DATE AS mois_2024;

-- Application : rapport avec tous les jours, même ceux sans commandes
WITH tous_jours AS (
    SELECT generate_series(
        '2024-01-01'::DATE,
        '2024-03-31'::DATE,
        INTERVAL '1 day'
    )::DATE AS jour
)
SELECT
    tj.jour,
    COUNT(c.commande_id)             AS nb_commandes,
    COALESCE(SUM(c.montant_total), 0) AS ca_jour
FROM tous_jours tj
LEFT JOIN commandes c ON c.date_commande::DATE = tj.jour
                      AND c.statut != 'annulé'
GROUP BY tj.jour
ORDER BY tj.jour;
```

GREATEST / LEAST sur les dates :
```sql
SELECT
    GREATEST('2024-01-01'::DATE, '2024-06-15'::DATE, '2024-03-20'::DATE) AS la_plus_recente,
    LEAST('2024-01-01'::DATE, '2024-06-15'::DATE, '2024-03-20'::DATE)   AS la_plus_ancienne;
```

JUSTIFY_DAYS et JUSTIFY_HOURS — Normaliser les intervalles :
```sql
SELECT
    JUSTIFY_DAYS(INTERVAL '40 days')       AS jours_normalises,  -- 1 mon 10 days
    JUSTIFY_HOURS(INTERVAL '36 hours')     AS heures_normalisees; -- 1 day 12 hours
```


────────────────────────────────────────────────────────────────────────────────
39.8 GESTION DES FUSEAUX HORAIRES
────────────────────────────────────────────────────────────────────────────────

PostgreSQL distingue TIMESTAMP (sans fuseau) et TIMESTAMPTZ (avec fuseau).

```sql
-- Voir le fuseau horaire courant
SHOW timezone;

-- Convertir un timestamp vers un autre fuseau
SELECT
    CURRENT_TIMESTAMP                                  AS maintenant_utc,
    CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Paris'      AS paris,
    CURRENT_TIMESTAMP AT TIME ZONE 'America/New_York'  AS new_york,
    CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo'        AS tokyo;

-- Définir le fuseau d'une session
SET timezone = 'Europe/Paris';
```

Règle d'or : Toujours stocker les dates en TIMESTAMPTZ (avec fuseau) et
convertir à l'affichage. Ne jamais stocker en heure locale dans la BDD.
(Voir partie 2, chapitre 8 pour la discussion complète sur les types de dates)


────────────────────────────────────────────────────────────────────────────────
39.9 EXERCICES FONCTIONS DE DATE
────────────────────────────────────────────────────────────────────────────────

EXERCICE 39-1 (Facile) — Age de chaque commande en jours
Afficher chaque commande avec son numéro, sa date, son statut et son
âge en jours (depuis la date de commande jusqu'à aujourd'hui).

SOLUTION 39-1 :
```sql
SELECT
    numero_commande,
    date_commande,
    statut,
    CURRENT_DATE - date_commande                   AS age_jours,
    TO_CHAR(date_commande, 'DD/MM/YYYY')           AS date_formatee
FROM commandes
ORDER BY age_jours DESC;
```


EXERCICE 39-2 (Facile) — Rapport mensuel 2024 avec format lisible
Afficher pour chaque mois de 2024 : période, nombre de commandes, CA.
Format période : "Janvier 2024", "Février 2024", etc.

SOLUTION 39-2 :
```sql
SELECT
    TO_CHAR(DATE_TRUNC('month', date_commande), 'TMMonth YYYY') AS periode,
    COUNT(*)                                                      AS nb_commandes,
    ROUND(SUM(montant_total), 2)                                  AS chiffre_affaires,
    ROUND(AVG(montant_total), 2)                                  AS panier_moyen
FROM commandes
WHERE date_commande >= '2024-01-01'
  AND date_commande < '2025-01-01'
  AND statut != 'annulé'
GROUP BY DATE_TRUNC('month', date_commande)
ORDER BY DATE_TRUNC('month', date_commande);
```

Note : TM (Translation Mode) dans TO_CHAR affiche les mois en français
si la locale du serveur est configurée en français.


EXERCICE 39-3 (Moyen) — Analyse par jour de la semaine
Identifier quel jour de la semaine génère le plus de commandes et de CA.
Afficher : nom du jour, nb commandes, CA total, pourcentage du total.

SOLUTION 39-3 :
```sql
WITH stats_jour AS (
    SELECT
        EXTRACT(DOW FROM date_commande)::INTEGER AS num_jour,
        TO_CHAR(date_commande, 'Day')            AS nom_jour,
        COUNT(*)                                  AS nb_commandes,
        SUM(montant_total)                        AS ca
    FROM commandes
    WHERE statut != 'annulé'
    GROUP BY num_jour, TO_CHAR(date_commande, 'Day')
),
total AS (
    SELECT SUM(nb_commandes) AS total_cmd, SUM(ca) AS total_ca FROM stats_jour
)
SELECT
    s.nom_jour,
    s.nb_commandes,
    ROUND(s.nb_commandes::DECIMAL / t.total_cmd * 100, 1) AS pct_commandes,
    ROUND(s.ca, 2)                                         AS ca_jour,
    ROUND(s.ca / t.total_ca * 100, 1)                     AS pct_ca
FROM stats_jour s, total t
ORDER BY s.num_jour;
```


EXERCICE 39-4 (Avancé) — Calendrier complet avec indicateurs
Générer un calendrier pour T1 2024 (janvier à mars).
Pour chaque jour : date, jour de la semaine, nb commandes ce jour,
CA, indicateur week-end, indicateur "Pic" si CA > moyenne quotidienne.

SOLUTION 39-4 :
```sql
WITH calendrier AS (
    SELECT
        generate_series(
            '2024-01-01'::DATE,
            '2024-03-31'::DATE,
            INTERVAL '1 day'
        )::DATE AS jour
),
ventes_jour AS (
    SELECT
        date_commande::DATE        AS jour,
        COUNT(*)                   AS nb_commandes,
        COALESCE(SUM(montant_total), 0) AS ca
    FROM commandes
    WHERE statut != 'annulé'
      AND date_commande >= '2024-01-01'
      AND date_commande < '2024-04-01'
    GROUP BY date_commande::DATE
),
moyenne AS (
    SELECT AVG(ca) AS ca_moyen FROM ventes_jour WHERE ca > 0
)
SELECT
    TO_CHAR(c.jour, 'DD/MM/YYYY')     AS date,
    TO_CHAR(c.jour, 'Day')            AS jour_semaine,
    COALESCE(v.nb_commandes, 0)        AS nb_commandes,
    COALESCE(ROUND(v.ca, 2), 0)       AS ca_jour,
    CASE WHEN EXTRACT(DOW FROM c.jour) IN (0, 6) THEN 'Weekend' ELSE '' END AS weekend,
    CASE WHEN COALESCE(v.ca, 0) > m.ca_moyen THEN '^ Pic' ELSE '' END AS indicateur
FROM calendrier c
LEFT JOIN ventes_jour v ON c.jour = v.jour
CROSS JOIN moyenne m
ORDER BY c.jour;
```


================================================================================
SYNTHÈSE DE LA PARTIE 9 — FONCTIONS SQL
================================================================================

TABLEAU RÉCAPITULATIF — FONCTIONS NUMÉRIQUES
──────────────────────────────────────────────
  Fonction          | Description                      | Exemple
  ──────────────────┼──────────────────────────────────┼─────────────────────
  ROUND(n, d)       | Arrondi à d décimales             | ROUND(3.567, 2) -> 3.57
  CEIL(n)           | Arrondi vers le haut              | CEIL(3.1) -> 4
  FLOOR(n)          | Arrondi vers le bas               | FLOOR(3.9) -> 3
  TRUNC(n, d)       | Tronque à d décimales             | TRUNC(3.999, 2) -> 3.99
  ABS(n)            | Valeur absolue                    | ABS(-5) -> 5
  MOD(a, b)         | Reste de division                 | MOD(10, 3) -> 1
  POWER(b, e)       | Puissance                         | POWER(2, 8) -> 256
  SQRT(n)           | Racine carrée                     | SQRT(16) -> 4

TABLEAU RÉCAPITULATIF — FONCTIONS TEXTE
─────────────────────────────────────────
  Fonction          | Description                      | Exemple
  ──────────────────┼──────────────────────────────────┼─────────────────────
  UPPER/LOWER       | Casse                            | UPPER('ab') -> 'AB'
  INITCAP           | Titre                            | INITCAP('ab cd') -> 'Ab Cd'
  LENGTH            | Longueur                         | LENGTH('abc') -> 3
  SUBSTR(t, d, l)   | Sous-chaîne                      | SUBSTR('Abc', 2, 2) -> 'bc'
  LEFT/RIGHT(t, n)  | N caractères gauche/droite       | LEFT('Abc', 2) -> 'Ab'
  TRIM              | Supprimer espaces                | TRIM(' ab ') -> 'ab'
  REPLACE(t, a, b)  | Remplacer                        | REPLACE('a-b', '-', '') -> 'ab'
  CONCAT_WS(s, ...) | Concaténer avec séparateur       | CONCAT_WS('-', 'a','b') -> 'a-b'
  LPAD/RPAD(t, n, c)| Compléter avec caractère         | LPAD('5', 3, '0') -> '005'

TABLEAU RÉCAPITULATIF — FONCTIONS DATE
────────────────────────────────────────
  Fonction            | Description                    | Exemple
  ────────────────────┼────────────────────────────────┼───────────────────────────
  CURRENT_DATE        | Date aujourd'hui               | 2024-03-15
  CURRENT_TIMESTAMP   | Date + heure actuelle          | 2024-03-15 14:30:00+01
  EXTRACT(p FROM d)   | Extraire une partie            | EXTRACT(YEAR FROM ...) -> 2024
  DATE_TRUNC(p, d)    | Tronquer à une période         | DATE_TRUNC('month',...) -> 2024-03-01
  TO_CHAR(d, fmt)     | Date vers texte formaté        | TO_CHAR(d, 'DD/MM/YYYY')
  TO_DATE(t, fmt)     | Texte vers date                | TO_DATE('15/03', 'DD/MM')
  AGE(d1, d2)         | Intervalle lisible             | AGE(...) -> 2 years 3 mons
  INTERVAL            | Durée à ajouter/soustraire     | + INTERVAL '1 month'
  generate_series     | Série de dates                 | Calendrier complet

================================================================================
FIN DE LA PARTIE 9 — FONCTIONS SQL
================================================================================
Chapitres couverts : 37 (numériques), 38 (texte), 39 (dates et heures)
Prochaine partie : PARTIE 10 — VUES ET INDEX
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 10
VUES ET INDEX
Chapitres 40 à 42
================================================================================

================================================================================
CHAPITRE 40 : LES VUES (VIEWS)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.1 INTRODUCTION AUX VUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une vue est une requête SQL sauvegardée sous forme d'objet de base de données,
que l'on peut interroger comme une table ordinaire. La vue ne stocke pas de
données : elle encapsule une requête SELECT et la ré-exécute à chaque appel.

Analogie : imaginez une vue comme une fenêtre sur vos données. La fenêtre ne
contient pas les données, elle vous montre simplement une perspective filtrée
et formatée de ce qui existe derrière elle.

Pourquoi les vues sont-elles indispensables ?
─────────────────────────────────────────────
1. SIMPLIFICATION : masquer la complexité des jointures et sous-requêtes
2. SÉCURITÉ      : exposer uniquement certaines colonnes/lignes à un utilisateur
3. RÉUTILISABILITÉ : éviter de réécrire des requêtes complexes à chaque fois
4. ABSTRACTION   : découpler l'interface de la structure physique des tables
5. MAINTENABILITÉ : modifier la logique en un seul endroit

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.2 SYNTAXE DE BASE — CREATE VIEW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Syntaxe complète :

    CREATE [OR REPLACE] VIEW nom_vue [(liste_colonnes)]
    AS
        requête_SELECT
    [WITH [CASCADED | LOCAL] CHECK OPTION];

Options importantes :
- OR REPLACE   : remplace la vue si elle existe déjà (sans DROP)
- liste_colonnes : renommer les colonnes de la vue
- CHECK OPTION : sur les vues modifiables, garantit la cohérence des INSERT/UPDATE

────────────────────────────────────────────
Exemple 1 : Vue simple sur les produits actifs
────────────────────────────────────────────

    CREATE VIEW v_produits_actifs AS
    SELECT
        p.produit_id,
        p.nom           AS produit,
        c.nom           AS categorie,
        p.prix_unitaire,
        p.stock_quantite
    FROM produits p
    JOIN categories c ON p.categorie_id = c.categorie_id
    WHERE p.actif = TRUE;

Utilisation :

    SELECT * FROM v_produits_actifs WHERE prix_unitaire < 100;
    -- Exactement comme une table ordinaire

Résultat (exemple) :
    produit_id | produit          | categorie    | prix_unitaire | stock_quantite
    -----------+------------------+--------------+---------------+---------------
             1 | Laptop Pro 15    | Informatique |       1299.99 |            45
             3 | Casque Bluetooth | Audio        |         89.99 |           120

────────────────────────────────────────────
Exemple 2 : Vue de récapitulatif des commandes
────────────────────────────────────────────

    CREATE VIEW v_commandes_resume AS
    SELECT
        co.commande_id,
        co.date_commande,
        cl.prenom || ' ' || cl.nom          AS client,
        cl.email,
        COUNT(lc.ligne_id)                  AS nb_articles,
        SUM(lc.quantite * lc.prix_unitaire) AS montant_total,
        co.statut,
        co.mode_livraison
    FROM commandes co
    JOIN clients cl ON co.client_id = cl.client_id
    JOIN lignes_commande lc ON co.commande_id = lc.commande_id
    GROUP BY
        co.commande_id, co.date_commande,
        cl.prenom, cl.nom, cl.email,
        co.statut, co.mode_livraison;

Utilisation :

    -- Les 5 plus grosses commandes du mois
    SELECT client, montant_total
    FROM v_commandes_resume
    WHERE date_commande >= DATE_TRUNC('month', CURRENT_DATE)
    ORDER BY montant_total DESC
    LIMIT 5;

    -- Commandes en attente
    SELECT commande_id, client, montant_total
    FROM v_commandes_resume
    WHERE statut = 'en_attente'
    ORDER BY date_commande;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.3 VUES COMPLEXES AVEC JOINTURES MULTIPLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les vues brillent particulièrement pour encapsuler des requêtes avec plusieurs
jointures que l'on utilise souvent.

────────────────────────────────────────────
Vue de dashboard produit complet
────────────────────────────────────────────

    CREATE VIEW v_dashboard_produit AS
    SELECT
        p.produit_id,
        p.nom                               AS produit,
        p.reference_sku,
        c.nom                               AS categorie,
        f.nom                               AS fournisseur,
        p.prix_achat,
        p.prix_unitaire,
        ROUND(
            (p.prix_unitaire - p.prix_achat) / p.prix_achat * 100,
        2)                                  AS marge_pct,
        p.stock_quantite,
        p.stock_minimum,
        CASE
            WHEN p.stock_quantite = 0          THEN 'rupture'
            WHEN p.stock_quantite <= p.stock_minimum THEN 'critique'
            WHEN p.stock_quantite <= p.stock_minimum * 2 THEN 'bas'
            ELSE 'normal'
        END                                 AS etat_stock,
        COALESCE(stats.nb_ventes, 0)        AS nb_ventes_total,
        COALESCE(stats.ca_total, 0)         AS chiffre_affaires,
        COALESCE(avis.note_moyenne, 0)      AS note_moyenne,
        COALESCE(avis.nb_avis, 0)           AS nb_avis,
        p.actif
    FROM produits p
    JOIN categories c  ON p.categorie_id  = c.categorie_id
    JOIN fournisseurs f ON p.fournisseur_id = f.fournisseur_id
    LEFT JOIN (
        SELECT
            produit_id,
            SUM(quantite)                       AS nb_ventes,
            SUM(quantite * prix_unitaire)       AS ca_total
        FROM lignes_commande
        GROUP BY produit_id
    ) stats ON p.produit_id = stats.produit_id
    LEFT JOIN (
        SELECT
            produit_id,
            ROUND(AVG(note), 2)                 AS note_moyenne,
            COUNT(*)                            AS nb_avis
        FROM avis
        GROUP BY produit_id
    ) avis ON p.produit_id = avis.produit_id;

Utilisation :

    -- Produits critiques nécessitant un réapprovisionnement
    SELECT produit, fournisseur, stock_quantite, stock_minimum
    FROM v_dashboard_produit
    WHERE etat_stock IN ('rupture', 'critique')
    AND actif = TRUE
    ORDER BY stock_quantite;

    -- Top produits par marge
    SELECT produit, categorie, marge_pct, chiffre_affaires
    FROM v_dashboard_produit
    WHERE actif = TRUE
    ORDER BY marge_pct DESC
    LIMIT 10;

    -- Produits mal notés mais populaires (à améliorer)
    SELECT produit, note_moyenne, nb_ventes_total, chiffre_affaires
    FROM v_dashboard_produit
    WHERE note_moyenne < 3.5
    AND nb_ventes_total > 10
    ORDER BY chiffre_affaires DESC;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.4 MODIFIER ET SUPPRIMER UNE VUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Modifier une vue avec CREATE OR REPLACE :
──────────────────────────────────────────

    -- Ajouter une colonne à une vue existante
    CREATE OR REPLACE VIEW v_produits_actifs AS
    SELECT
        p.produit_id,
        p.nom           AS produit,
        c.nom           AS categorie,
        p.prix_unitaire,
        p.stock_quantite,
        p.date_creation  -- nouvelle colonne ajoutée
    FROM produits p
    JOIN categories c ON p.categorie_id = c.categorie_id
    WHERE p.actif = TRUE;

ATTENTION : CREATE OR REPLACE ne peut pas :
- Supprimer des colonnes existantes
- Changer le type d'une colonne existante
- Réordonner les colonnes existantes

Pour ces opérations, il faut DROP + CREATE :

    DROP VIEW v_produits_actifs;
    CREATE VIEW v_produits_actifs AS
        ... -- nouvelle définition complète

Supprimer une vue :

    DROP VIEW v_produits_actifs;              -- erreur si inexistante
    DROP VIEW IF EXISTS v_produits_actifs;    -- sans erreur
    DROP VIEW v_produits_actifs CASCADE;      -- supprime aussi les vues dépendantes

Consulter la définition d'une vue :

    -- Dans PostgreSQL
    SELECT definition
    FROM pg_views
    WHERE viewname = 'v_produits_actifs';

    -- Ou avec \d+ dans psql
    \d+ v_produits_actifs

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.5 VUES ET SÉCURITÉ — CONTRÔLE D'ACCÈS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les vues sont un outil de sécurité puissant : on peut donner accès à une vue
sans donner accès aux tables sous-jacentes.

Scénario : un utilisateur "marketing" ne doit voir que le nom, email et segment
des clients, jamais le mot de passe ou les données bancaires.

    -- 1. Créer la vue sécurisée
    CREATE VIEW v_clients_marketing AS
    SELECT
        client_id,
        prenom,
        nom,
        email,
        ville,
        pays,
        segment,
        date_inscription
    FROM clients;
    -- Colonnes sensibles (telephone, adresse_complete...) exclues

    -- 2. Créer l'utilisateur
    CREATE USER marketing_user WITH PASSWORD 'motdepasse_securise';

    -- 3. Révoquer tout accès direct aux tables
    REVOKE ALL ON clients FROM marketing_user;

    -- 4. Donner accès uniquement à la vue
    GRANT SELECT ON v_clients_marketing TO marketing_user;

Maintenant marketing_user peut faire :
    SELECT * FROM v_clients_marketing;   -- OK
    SELECT * FROM clients;               -- ERREUR : permission refusée

Vue avec filtre de ligne (Row-Level Security simplifié) :

    -- Chaque commercial ne voit que ses propres clients
    CREATE VIEW v_mes_clients AS
    SELECT cl.*
    FROM clients cl
    JOIN commandes co ON cl.client_id = co.client_id
    JOIN employes e   ON co.employe_id = e.employe_id
    WHERE e.email = CURRENT_USER;  -- filtre automatique sur l'utilisateur connecté

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.6 VUES MODIFIABLES ET CHECK OPTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une vue est modifiable (updatable) si elle respecte ces conditions :
- Basée sur une seule table (sans JOIN)
- Pas de DISTINCT, GROUP BY, HAVING, UNION
- Pas de fonctions d'agrégation
- Pas de sous-requêtes dans SELECT

Vue modifiable — exemple :

    CREATE VIEW v_produits_high_end AS
    SELECT produit_id, nom, prix_unitaire, stock_quantite
    FROM produits
    WHERE prix_unitaire > 500;

    -- INSERT via la vue (insère dans la table produits)
    INSERT INTO v_produits_high_end (nom, prix_unitaire, stock_quantite)
    VALUES ('Ultra Monitor 4K', 1200.00, 10);

    -- UPDATE via la vue
    UPDATE v_produits_high_end
    SET prix_unitaire = 1250.00
    WHERE nom = 'Ultra Monitor 4K';

Problème sans CHECK OPTION : on peut insérer un produit à 50€ dans une vue
qui filtre prix > 500, et la ligne disparaît immédiatement de la vue.

WITH CHECK OPTION empêche cela :

    CREATE OR REPLACE VIEW v_produits_high_end AS
    SELECT produit_id, nom, prix_unitaire, stock_quantite
    FROM produits
    WHERE prix_unitaire > 500
    WITH CHECK OPTION;

    -- Maintenant, cette insertion échoue avec une erreur claire :
    INSERT INTO v_produits_high_end (nom, prix_unitaire, stock_quantite)
    VALUES ('Produit Bas de Gamme', 25.00, 100);
    -- ERROR: new row violates check option for view "v_produits_high_end"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.7 VUES MATÉRIALISÉES (MATERIALIZED VIEWS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Contrairement à une vue ordinaire (qui ré-exécute la requête à chaque appel),
une vue matérialisée STOCKE physiquement le résultat de la requête.

Quand utiliser une vue matérialisée ?
- Requête très lente (jointures massives, agrégations sur millions de lignes)
- Données qui n'ont pas besoin d'être en temps réel (rapport quotidien, etc.)
- Tableau de bord analytique où une légère latence est acceptable

Syntaxe :

    CREATE MATERIALIZED VIEW mv_stats_produits AS
    SELECT
        p.produit_id,
        p.nom                               AS produit,
        c.nom                               AS categorie,
        COUNT(DISTINCT lc.commande_id)      AS nb_commandes,
        SUM(lc.quantite)                    AS total_vendu,
        SUM(lc.quantite * lc.prix_unitaire) AS chiffre_affaires,
        ROUND(AVG(a.note), 2)               AS note_moyenne
    FROM produits p
    JOIN categories c ON p.categorie_id = c.categorie_id
    LEFT JOIN lignes_commande lc ON p.produit_id = lc.produit_id
    LEFT JOIN avis a ON p.produit_id = a.produit_id
    GROUP BY p.produit_id, p.nom, c.nom
    WITH DATA;  -- exécuter immédiatement et stocker

    -- Créer un index sur la vue matérialisée pour accélérer les requêtes
    CREATE INDEX ON mv_stats_produits (produit_id);
    CREATE INDEX ON mv_stats_produits (chiffre_affaires DESC);

Rafraîchir la vue matérialisée :

    -- Rafraîchissement bloquant (la vue est inaccessible pendant le refresh)
    REFRESH MATERIALIZED VIEW mv_stats_produits;

    -- Rafraîchissement non bloquant (disponible pendant le refresh)
    -- Requiert un index UNIQUE sur la vue
    CREATE UNIQUE INDEX ON mv_stats_produits (produit_id);
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_stats_produits;

Automatiser le rafraîchissement avec pg_cron (extension PostgreSQL) :

    -- Rafraîchir chaque nuit à 2h du matin
    SELECT cron.schedule(
        'refresh_stats_produits',
        '0 2 * * *',
        'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_stats_produits'
    );

Comparaison Vue ordinaire vs Vue matérialisée :

    ┌──────────────────────┬────────────────────────┬────────────────────────┐
    │ Critère              │ Vue ordinaire          │ Vue matérialisée       │
    ├──────────────────────┼────────────────────────┼────────────────────────┤
    │ Stockage données     │ Non (requête seule)    │ Oui (snapshot physique)│
    │ Fraîcheur données    │ Toujours à jour        │ Dépend du REFRESH      │
    │ Performances lecture │ Lente si req. complexe │ Très rapide            │
    │ Indexable            │ Non                    │ Oui                    │
    │ Espace disque        │ Minimal                │ Comme une vraie table  │
    │ Cas d'usage          │ Logique métier, sécu   │ Rapports, dashboards   │
    └──────────────────────┴────────────────────────┴────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.8 BONNES PRATIQUES SUR LES VUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. NOMMAGE : préfixer avec v_ (vue ordinaire) ou mv_ (vue matérialisée)
       v_commandes_resume, mv_stats_mensuelles

2. NE PAS IMBRIQUER trop profondément les vues (vue sur vue sur vue)
   — chaque couche ajoute de la complexité de débogage et peut masquer
     des problèmes de performance

3. DOCUMENTER la vue avec un commentaire :
       COMMENT ON VIEW v_commandes_resume IS
       'Résumé des commandes avec client, montant et statut. Actualisé en temps réel.';

4. ÉVITER SELECT * dans une vue
   — si la table sous-jacente change, la vue peut retourner des colonnes
     inattendues ou planter

5. VUE MATÉRIALISÉE : toujours créer un index UNIQUE si vous voulez
   REFRESH CONCURRENTLY

6. AUDIT : tenir un inventaire des vues (nombre, dépendances, fréquence d'usage)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.9 ERREURS FRÉQUENTES AVEC LES VUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Erreur 1 : Croire que la vue est toujours rapide
─────────────────────────────────────────────────
    -- Cette requête semble simple...
    SELECT * FROM v_dashboard_produit WHERE produit_id = 42;

    -- ...mais en interne, elle exécute des sous-requêtes complexes sur TOUTE la table.
    -- Solution : créer une vue matérialisée si les données peuvent être légèrement
    -- décalées, ou s'assurer que les index sur les tables sous-jacentes sont bons.

Erreur 2 : Modifier une table sous-jacente sans adapter la vue
───────────────────────────────────────────────────────────────
    -- Si vous renommez une colonne dans produits, les vues qui la référencent
    -- deviennent invalides et retournent une erreur à la prochaine utilisation.
    -- Solution : tester les vues après toute migration de schéma.

Erreur 3 : Vue sur vue avec ambiguïté de colonnes
──────────────────────────────────────────────────
    -- v_A et v_B ont toutes les deux une colonne "nom" sans alias différent
    -- Une vue v_C qui joint v_A et v_B crée une ambiguïté.
    -- Solution : toujours aliaser clairement dans chaque vue.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.10 EXERCICES PRATIQUES — VUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE FACILE 1
─────────────────
Créez une vue v_clients_vip qui affiche tous les clients dont le segment est
'VIP', avec leur nom complet (prénom + nom), email, ville et date d'inscription.

EXERCICE FACILE 2
─────────────────
Créez une vue v_stock_critique qui affiche les produits actifs dont le stock
est inférieur ou égal au stock minimum, avec le nom du produit, la catégorie,
le stock actuel et le stock minimum.

EXERCICE FACILE 3
─────────────────
Créez une vue v_paiements_recents qui affiche tous les paiements des 30
derniers jours avec : l'id de commande, la date, le mode de paiement,
le montant et le statut du paiement.

EXERCICE MOYEN 1
────────────────
Créez une vue v_performance_employes qui affiche pour chaque employé :
son nom complet, son poste, le nombre de commandes traitées, le montant
total des ventes gérées, et la valeur moyenne d'une commande.

EXERCICE MOYEN 2
────────────────
Créez une vue v_catalogue_complet qui affiche chaque produit actif avec :
son nom, sa référence SKU, sa catégorie, son fournisseur, son prix, sa marge
en pourcentage, son stock et sa note moyenne (arrondie à 1 décimale).

EXERCICE MOYEN 3
────────────────
Créez une vue v_commandes_livrees_ce_mois qui affiche les commandes avec
statut 'livree' du mois en cours, avec le nom du client, la date de commande,
le montant total et le mode de livraison.

EXERCICE AVANCÉ 1
─────────────────
Créez une vue matérialisée mv_rapport_mensuel qui calcule pour chaque mois
(sur les 12 derniers mois) : le nombre de commandes, le chiffre d'affaires
total, le panier moyen, le nombre de nouveaux clients (première commande dans
ce mois), et le nombre de produits différents vendus.

EXERCICE AVANCÉ 2
─────────────────
Créez une vue v_analyse_client qui affiche pour chaque client : son nom,
son segment, la date de sa première et dernière commande, le nombre total
de commandes, le montant total dépensé, la catégorie dans laquelle il
dépense le plus, et un label 'actif' (commande dans les 90 jours) ou 'inactif'.

EXERCICE AVANCÉ 3
─────────────────
Créez une vue v_suivi_livraison qui consolide pour chaque commande en cours
(statut != 'livree' et != 'annulee') : les informations du client (nom, email),
les détails de la commande (id, date, montant), le mode de livraison,
le nombre d'articles, et un label d'urgence : 'critique' si la commande
date de plus de 7 jours, 'attention' si entre 3 et 7 jours, 'normal' sinon.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.11 SOLUTIONS DÉTAILLÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SOLUTION FACILE 1
─────────────────
    CREATE VIEW v_clients_vip AS
    SELECT
        client_id,
        prenom || ' ' || nom    AS nom_complet,
        email,
        ville,
        date_inscription
    FROM clients
    WHERE segment = 'VIP';

SOLUTION FACILE 2
─────────────────
    CREATE VIEW v_stock_critique AS
    SELECT
        p.produit_id,
        p.nom               AS produit,
        c.nom               AS categorie,
        p.stock_quantite,
        p.stock_minimum
    FROM produits p
    JOIN categories c ON p.categorie_id = c.categorie_id
    WHERE p.actif = TRUE
    AND   p.stock_quantite <= p.stock_minimum
    ORDER BY p.stock_quantite ASC;

SOLUTION FACILE 3
─────────────────
    CREATE VIEW v_paiements_recents AS
    SELECT
        pa.paiement_id,
        pa.commande_id,
        pa.date_paiement,
        pa.mode_paiement,
        pa.montant,
        pa.statut_paiement
    FROM paiements pa
    WHERE pa.date_paiement >= CURRENT_DATE - INTERVAL '30 days'
    ORDER BY pa.date_paiement DESC;

SOLUTION MOYEN 1
────────────────
    CREATE VIEW v_performance_employes AS
    SELECT
        e.employe_id,
        e.prenom || ' ' || e.nom            AS nom_complet,
        e.poste,
        COUNT(co.commande_id)               AS nb_commandes,
        COALESCE(
            SUM(lc.quantite * lc.prix_unitaire), 0
        )                                   AS ventes_totales,
        COALESCE(
            ROUND(AVG(sous.montant_commande), 2), 0
        )                                   AS panier_moyen
    FROM employes e
    LEFT JOIN commandes co ON e.employe_id = co.employe_id
    LEFT JOIN lignes_commande lc ON co.commande_id = lc.commande_id
    LEFT JOIN (
        SELECT commande_id,
               SUM(quantite * prix_unitaire) AS montant_commande
        FROM lignes_commande
        GROUP BY commande_id
    ) sous ON co.commande_id = sous.commande_id
    GROUP BY e.employe_id, e.prenom, e.nom, e.poste;

SOLUTION MOYEN 2
────────────────
    CREATE VIEW v_catalogue_complet AS
    SELECT
        p.produit_id,
        p.nom                                   AS produit,
        p.reference_sku,
        c.nom                                   AS categorie,
        f.nom                                   AS fournisseur,
        p.prix_unitaire,
        ROUND(
            (p.prix_unitaire - p.prix_achat)
            / p.prix_achat * 100, 2
        )                                       AS marge_pct,
        p.stock_quantite,
        ROUND(COALESCE(AVG(a.note), 0), 1)      AS note_moyenne
    FROM produits p
    JOIN categories c   ON p.categorie_id   = c.categorie_id
    JOIN fournisseurs f  ON p.fournisseur_id  = f.fournisseur_id
    LEFT JOIN avis a     ON p.produit_id      = a.produit_id
    WHERE p.actif = TRUE
    GROUP BY
        p.produit_id, p.nom, p.reference_sku,
        c.nom, f.nom, p.prix_unitaire, p.prix_achat, p.stock_quantite;

SOLUTION MOYEN 3
────────────────
    CREATE VIEW v_commandes_livrees_ce_mois AS
    SELECT
        co.commande_id,
        cl.prenom || ' ' || cl.nom              AS client,
        co.date_commande,
        SUM(lc.quantite * lc.prix_unitaire)     AS montant_total,
        co.mode_livraison
    FROM commandes co
    JOIN clients cl         ON co.client_id   = cl.client_id
    JOIN lignes_commande lc ON co.commande_id  = lc.commande_id
    WHERE co.statut = 'livree'
    AND   co.date_commande >= DATE_TRUNC('month', CURRENT_DATE)
    AND   co.date_commande <  DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
    GROUP BY co.commande_id, cl.prenom, cl.nom, co.date_commande, co.mode_livraison
    ORDER BY co.date_commande DESC;

SOLUTION AVANCÉ 1
─────────────────
    CREATE MATERIALIZED VIEW mv_rapport_mensuel AS
    SELECT
        DATE_TRUNC('month', co.date_commande)       AS mois,
        COUNT(DISTINCT co.commande_id)              AS nb_commandes,
        SUM(lc.quantite * lc.prix_unitaire)         AS chiffre_affaires,
        ROUND(
            AVG(sous_commande.montant), 2
        )                                           AS panier_moyen,
        COUNT(DISTINCT nouveaux.client_id)          AS nouveaux_clients,
        COUNT(DISTINCT lc.produit_id)               AS produits_differents
    FROM commandes co
    JOIN lignes_commande lc ON co.commande_id = lc.commande_id
    -- Panier moyen par commande
    JOIN (
        SELECT commande_id, SUM(quantite * prix_unitaire) AS montant
        FROM lignes_commande
        GROUP BY commande_id
    ) sous_commande ON co.commande_id = sous_commande.commande_id
    -- Nouveaux clients : première commande dans ce mois
    LEFT JOIN (
        SELECT client_id, MIN(date_commande) AS premiere_commande
        FROM commandes
        GROUP BY client_id
    ) premiere ON co.client_id = premiere.client_id
                AND DATE_TRUNC('month', premiere.premiere_commande)
                  = DATE_TRUNC('month', co.date_commande)
    LEFT JOIN clients nouveaux
        ON premiere.client_id = nouveaux.client_id
    WHERE co.date_commande >= CURRENT_DATE - INTERVAL '12 months'
    GROUP BY DATE_TRUNC('month', co.date_commande)
    ORDER BY mois DESC
    WITH DATA;

    -- Index pour les requêtes fréquentes
    CREATE UNIQUE INDEX ON mv_rapport_mensuel (mois);

SOLUTION AVANCÉ 2
─────────────────
    CREATE VIEW v_analyse_client AS
    WITH stats_client AS (
        SELECT
            co.client_id,
            MIN(co.date_commande)                   AS premiere_commande,
            MAX(co.date_commande)                   AS derniere_commande,
            COUNT(DISTINCT co.commande_id)          AS nb_commandes,
            SUM(lc.quantite * lc.prix_unitaire)     AS total_depense
        FROM commandes co
        JOIN lignes_commande lc ON co.commande_id = lc.commande_id
        GROUP BY co.client_id
    ),
    categorie_preferee AS (
        SELECT DISTINCT ON (co.client_id)
            co.client_id,
            c.nom AS categorie_top
        FROM commandes co
        JOIN lignes_commande lc ON co.commande_id = lc.commande_id
        JOIN produits p         ON lc.produit_id   = p.produit_id
        JOIN categories c       ON p.categorie_id  = c.categorie_id
        GROUP BY co.client_id, c.nom
        ORDER BY co.client_id, SUM(lc.quantite * lc.prix_unitaire) DESC
    )
    SELECT
        cl.client_id,
        cl.prenom || ' ' || cl.nom      AS client,
        cl.segment,
        sc.premiere_commande,
        sc.derniere_commande,
        sc.nb_commandes,
        ROUND(sc.total_depense, 2)      AS total_depense,
        cp.categorie_top,
        CASE
            WHEN sc.derniere_commande >= CURRENT_DATE - INTERVAL '90 days'
                THEN 'actif'
            ELSE 'inactif'
        END                             AS statut_activite
    FROM clients cl
    LEFT JOIN stats_client sc       ON cl.client_id = sc.client_id
    LEFT JOIN categorie_preferee cp ON cl.client_id = cp.client_id;

SOLUTION AVANCÉ 3
─────────────────
    CREATE VIEW v_suivi_livraison AS
    SELECT
        co.commande_id,
        cl.prenom || ' ' || cl.nom                  AS client,
        cl.email,
        co.date_commande,
        SUM(lc.quantite * lc.prix_unitaire)         AS montant_total,
        COUNT(lc.ligne_id)                          AS nb_articles,
        co.mode_livraison,
        co.statut,
        CASE
            WHEN CURRENT_DATE - co.date_commande::date > 7  THEN 'critique'
            WHEN CURRENT_DATE - co.date_commande::date >= 3 THEN 'attention'
            ELSE 'normal'
        END                                         AS urgence
    FROM commandes co
    JOIN clients cl         ON co.client_id   = cl.client_id
    JOIN lignes_commande lc ON co.commande_id  = lc.commande_id
    WHERE co.statut NOT IN ('livree', 'annulee')
    GROUP BY
        co.commande_id, cl.prenom, cl.nom, cl.email,
        co.date_commande, co.mode_livraison, co.statut
    ORDER BY
        CASE
            WHEN CURRENT_DATE - co.date_commande::date > 7  THEN 1
            WHEN CURRENT_DATE - co.date_commande::date >= 3 THEN 2
            ELSE 3
        END,
        co.date_commande ASC;


================================================================================
CHAPITRE 41 : LES INDEX
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.1 QU'EST-CE QU'UN INDEX ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un index est une structure de données auxiliaire qui accélère la recherche
de lignes dans une table. Sans index, PostgreSQL doit lire toutes les lignes
d'une table (Sequential Scan) pour trouver celles qui correspondent à un critère.
Avec un index, il peut sauter directement aux lignes pertinentes (Index Scan).

Analogie : l'index d'un livre. Sans lui, pour trouver "jointure", vous lisez
page par page. Avec l'index, vous trouvez directement la page 147.

Impact sur les performances :
────────────────────────────
    Table avec 1 000 000 lignes, recherche par email :

    SANS index : Sequential Scan
        - Lit 1 000 000 lignes
        - Durée : ~300 ms

    AVEC index : Index Scan
        - Accède directement à ~1 ligne (via la structure B-tree)
        - Durée : ~0.1 ms
        - Accélération : ×3000

La contrepartie des index :
- Espace disque supplémentaire (souvent 10-30% de la table)
- Ralentissement des INSERT, UPDATE, DELETE (l'index doit être mis à jour)
- Maintenance nécessaire (VACUUM, REINDEX)

Règle d'or : indexez les colonnes que vous filtrez souvent (WHERE, JOIN ON,
ORDER BY), mais n'indexez pas aveuglément toutes les colonnes.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.2 INDEX B-TREE — LE TYPE PAR DÉFAUT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le B-tree (Balanced Tree) est le type d'index par défaut dans PostgreSQL.
Il est adapté pour la grande majorité des cas d'usage.

Comment fonctionne un B-tree ?
──────────────────────────────
Un B-tree organise les valeurs dans une structure arborescente équilibrée
(chaque feuille est à la même profondeur). Chaque nœud contient des valeurs
triées et des pointeurs vers les nœuds enfants.

Structure simplifiée pour un index sur prix_unitaire :

              [500]
            /       \
        [100|300]   [800|1200]
        /  |  \     /  |   \
    [89] [100][299] [750][1000][1299]
      ^v    ^v    ^v    ^v    ^v     ^v
    lignes dans la table (TID = Table ID)

Pour trouver prix = 89 :
    1. Racine : 89 < 500 -> aller à gauche
    2. Nœud   : 89 < 100 -> aller à gauche
    3. Feuille: trouvé 89 -> retourner le TID (pointeur vers la ligne)
    4. Total  : 3 lectures (log₂(N) en pratique)

Opérateurs supportés par B-tree : =, <, >, <=, >=, BETWEEN, IN, IS NULL

Syntaxe de base :

    -- Index simple (B-tree implicite)
    CREATE INDEX idx_clients_email
    ON clients (email);

    -- Index explicitement B-tree
    CREATE INDEX idx_produits_prix
    ON produits USING BTREE (prix_unitaire);

    -- Index sur plusieurs colonnes
    CREATE INDEX idx_commandes_client_date
    ON commandes (client_id, date_commande);

    -- Index descendant (pour ORDER BY ... DESC)
    CREATE INDEX idx_commandes_date_desc
    ON commandes (date_commande DESC);

Quand le B-tree est utilisé :

    -- Ces requêtes utilisent l'index idx_clients_email
    SELECT * FROM clients WHERE email = 'alice@email.com';   -- égalité
    SELECT * FROM clients WHERE email > 'a';                 -- comparaison
    SELECT * FROM clients ORDER BY email;                    -- tri

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.3 INDEX UNIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un index UNIQUE garantit que toutes les valeurs dans la colonne indexée sont
distinctes. C'est à la fois une contrainte d'intégrité ET un index.

    -- Index unique sur email
    CREATE UNIQUE INDEX idx_unique_clients_email
    ON clients (email);

    -- Équivalent via contrainte (crée l'index automatiquement)
    ALTER TABLE clients ADD CONSTRAINT uq_clients_email UNIQUE (email);

    -- Index unique composite (la combinaison des deux colonnes est unique)
    CREATE UNIQUE INDEX idx_unique_avis_client_produit
    ON avis (client_id, produit_id);
    -- Un client ne peut poster qu'un avis par produit

Différence PRIMARY KEY vs UNIQUE :
- PRIMARY KEY : UNIQUE + NOT NULL (une seule par table)
- UNIQUE : peut contenir des NULL (plusieurs NULL sont autorisés en SQL standard)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.4 INDEX PARTIELS (PARTIAL INDEXES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un index partiel n'indexe qu'un sous-ensemble de lignes, celles qui satisfont
une condition WHERE. Il est plus petit, plus rapide, et plus efficace quand on
filtre souvent sur ce sous-ensemble.

Cas d'usage classiques :
────────────────────────

1. Commandes non traitées (minorité active des données)

    -- Au lieu d'indexer TOUTES les commandes...
    CREATE INDEX idx_commandes_statut_pending
    ON commandes (date_commande)
    WHERE statut = 'en_attente';

    -- Utilisation : la requête doit correspondre exactement à la condition WHERE
    SELECT * FROM commandes
    WHERE statut = 'en_attente'
    AND   date_commande < NOW() - INTERVAL '24 hours';
    -- PostgreSQL utilisera l'index partiel (bien plus petit que l'index total)

2. Clients actifs seulement

    CREATE INDEX idx_clients_actifs_email
    ON clients (email)
    WHERE actif = TRUE;

3. Produits en stock

    CREATE INDEX idx_produits_en_stock
    ON produits (categorie_id, prix_unitaire)
    WHERE stock_quantite > 0 AND actif = TRUE;

    -- Avantage : si 80% des produits sont actifs et en stock, l'index
    -- couvre 80% des recherches habituelles tout en étant compact

Quand utiliser un index partiel ?
- Quand vous filtrez toujours sur la même valeur de statut
- Quand une valeur représente une minorité de lignes mais est souvent requêtée
- Soft delete : indexer uniquement WHERE deleted_at IS NULL

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.5 INDEX D'EXPRESSION (EXPRESSION INDEXES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un index d'expression indexe non pas une colonne brute, mais le résultat
d'une expression ou d'une fonction.

Problème sans index d'expression :

    -- Recherche insensible à la casse
    SELECT * FROM clients WHERE LOWER(email) = 'alice@email.com';
    -- PostgreSQL ne peut pas utiliser idx_clients_email ici !
    -- Car l'index est sur email, pas sur LOWER(email)

Solution :

    CREATE INDEX idx_clients_email_lower
    ON clients (LOWER(email));

    -- Maintenant cette requête utilise l'index :
    SELECT * FROM clients WHERE LOWER(email) = 'alice@email.com';

Autres exemples utiles :

    -- Index sur l'année d'une date (pour GROUP BY EXTRACT(YEAR ...))
    CREATE INDEX idx_commandes_annee
    ON commandes (EXTRACT(YEAR FROM date_commande));

    -- Index sur les 3 premiers caractères du code postal
    CREATE INDEX idx_clients_code_postal_prefixe
    ON clients (LEFT(code_postal, 3));

    -- Index sur un champ JSON (PostgreSQL)
    -- Si vous avez une colonne JSONB 'metadata'
    CREATE INDEX idx_produits_metadata_marque
    ON produits ((metadata->>'marque'));

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.6 INDEX COMPOSITES — ORDRE DES COLONNES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'ordre des colonnes dans un index composite est CRITIQUE.

Règle : mettez en premier la colonne avec la cardinalité la plus haute (la plus
discriminante), ou la colonne la plus souvent utilisée seule dans les requêtes.

    -- Index composite sur commandes
    CREATE INDEX idx_commandes_client_statut_date
    ON commandes (client_id, statut, date_commande);

Ce que cet index peut couvrir :
    [OK] WHERE client_id = 5                          -- utilise le préfixe
    [OK] WHERE client_id = 5 AND statut = 'livree'    -- utilise les 2 premiers
    [OK] WHERE client_id = 5 AND statut = 'livree' AND date_commande > '2024-01-01'
    [X] WHERE statut = 'livree'                      -- ne commence pas par client_id
    [X] WHERE date_commande > '2024-01-01'           -- ne commence pas par client_id

Règle du préfixe (Leading Column Rule) : un index composite (A, B, C) peut
être utilisé pour des requêtes sur A, sur A+B, ou sur A+B+C. Jamais pour B
seul ou C seul.

Index covering (couvrant) :

    -- Si votre requête sélectionne uniquement des colonnes dans l'index,
    -- PostgreSQL n'a même pas besoin de toucher la table (Index Only Scan)
    CREATE INDEX idx_produits_covering
    ON produits (categorie_id, actif, prix_unitaire, nom);

    -- Cette requête fait un Index Only Scan (très rapide)
    SELECT nom, prix_unitaire
    FROM produits
    WHERE categorie_id = 3 AND actif = TRUE
    ORDER BY prix_unitaire;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.7 AUTRES TYPES D'INDEX : HASH, GIN, GiST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HASH — égalité pure :
─────────────────────
    CREATE INDEX idx_clients_email_hash
    ON clients USING HASH (email);

    -- Avantage   : légèrement plus rapide que B-tree pour les recherches d'égalité exacte
    -- Inconvénient : ne supporte que =, pas <, >, BETWEEN, ORDER BY
    -- Usage       : colonnes avec beaucoup d'égalités exactes (codes, identifiants)

GIN (Generalized Inverted Index) — tableaux, JSONB, texte :
────────────────────────────────────────────────────────────
    -- Index GIN pour recherche full-text
    CREATE INDEX idx_produits_description_fts
    ON produits USING GIN (to_tsvector('french', description));

    -- Utilisation (recherche plein texte)
    SELECT nom FROM produits
    WHERE to_tsvector('french', description) @@ to_tsquery('french', 'bluetooth & audio');

    -- Index GIN pour colonnes JSONB
    CREATE INDEX idx_produits_metadata_gin
    ON produits USING GIN (metadata);

    -- Utilisation (requête sur JSONB)
    SELECT nom FROM produits WHERE metadata @> '{"marque": "Samsung"}';

GiST (Generalized Search Tree) — géographie, plages de valeurs :
─────────────────────────────────────────────────────────────────
    -- Pour les types géographiques (avec PostGIS) ou les ranges
    CREATE INDEX idx_livraisons_zone
    ON livraisons USING GIST (zone_geographique);

    -- Pour les plages de valeurs (daterange, numrange, etc.)
    CREATE INDEX idx_promotions_periode
    ON promotions USING GIST (periode_validite);

Résumé des types d'index :
    ┌───────────┬─────────────────────────────────────────┬──────────────┐
    │ Type      │ Cas d'usage                             │ Opérateurs   │
    ├───────────┼─────────────────────────────────────────┼──────────────┤
    │ B-tree    │ Cas général (90% des usages)            │ =,<,>,BETWEEN│
    │ Hash      │ Égalité pure sur grandes tables         │ = seulement  │
    │ GIN       │ Tableaux, JSONB, full-text search       │ @>, @@, &&   │
    │ GiST      │ Géographie, plages, données spéciales   │ &&, @>, <->  │
    │ BRIN      │ Très grandes tables avec données triées │ =, <, >      │
    └───────────┴─────────────────────────────────────────┴──────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.8 GÉRER LES INDEX : CRÉATION, SUPPRESSION, MAINTENANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Créer un index sans bloquer les écritures :

    -- Par défaut, CREATE INDEX pose un verrou exclusif (bloque les écritures)
    CREATE INDEX idx_commandes_statut ON commandes (statut);

    -- CONCURRENTLY : crée l'index sans bloquer (mais prend plus de temps)
    CREATE INDEX CONCURRENTLY idx_commandes_statut ON commandes (statut);

    -- Toujours utiliser CONCURRENTLY en production sur les tables actives

Supprimer un index :

    DROP INDEX idx_commandes_statut;
    DROP INDEX IF EXISTS idx_commandes_statut;
    DROP INDEX CONCURRENTLY idx_commandes_statut;  -- sans blocage

Renommer un index :

    ALTER INDEX idx_commandes_statut RENAME TO idx_commandes_statut_v2;

Voir tous les index d'une table :

    SELECT indexname, indexdef
    FROM pg_indexes
    WHERE tablename = 'commandes';

Voir la taille des index :

    SELECT
        indexname,
        pg_size_pretty(pg_relation_size(indexname::regclass)) AS taille
    FROM pg_indexes
    WHERE tablename = 'commandes'
    ORDER BY pg_relation_size(indexname::regclass) DESC;

Reconstruire un index (après beaucoup d'UPDATE/DELETE) :

    REINDEX INDEX idx_commandes_statut;    -- reconstructs l'index
    REINDEX TABLE commandes;               -- reconstruit tous les index de la table
    REINDEX DATABASE shopflow;             -- reconstruit tous les index de la base

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.9 BONNES PRATIQUES SUR LES INDEX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. INDEXEZ les colonnes dans WHERE, JOIN ON, ORDER BY fréquents
2. N'INDEXEZ PAS les colonnes avec peu de valeurs distinctes (ex: boolean actif)
   — sauf en index partiel
3. ÉVITEZ trop d'index sur les tables à forte écriture (INSERT/UPDATE/DELETE lents)
4. PRÉFÉREZ les index composites aux index multiples séparés quand
   plusieurs colonnes sont toujours filtrées ensemble
5. UTILISEZ CONCURRENTLY en production pour ne pas bloquer
6. ANALYSEZ l'utilisation avec pg_stat_user_indexes pour supprimer les index
   non utilisés :

    SELECT schemaname, tablename, indexname,
           idx_scan, idx_tup_read, idx_tup_fetch
    FROM pg_stat_user_indexes
    WHERE idx_scan = 0  -- index jamais utilisé !
    ORDER BY schemaname, tablename;

7. VACUUM régulièrement pour permettre à PostgreSQL de réutiliser les pages
   libérées et maintenir les index efficaces.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.10 EXERCICES PRATIQUES — INDEX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE FACILE 1
─────────────────
Créez un index B-tree sur la colonne nom de la table produits pour accélérer
les recherches par nom de produit.

EXERCICE FACILE 2
─────────────────
Créez un index unique sur la combinaison (client_id, produit_id) de la table
avis pour garantir qu'un client ne peut laisser qu'un avis par produit.

EXERCICE FACILE 3
─────────────────
Créez un index partiel sur la table commandes pour accélérer la recherche
des commandes dont le statut est 'en_attente' ou 'en_cours'.

EXERCICE MOYEN 1
────────────────
L'application fait souvent cette requête :
    SELECT * FROM produits
    WHERE categorie_id = ? AND actif = TRUE AND prix_unitaire BETWEEN ? AND ?
    ORDER BY prix_unitaire;
Quel index composite créer pour optimiser cette requête ? Justifiez l'ordre
des colonnes.

EXERCICE MOYEN 2
────────────────
L'équipe marketing fait souvent des recherches insensibles à la casse sur
l'email des clients :
    SELECT * FROM clients WHERE LOWER(email) LIKE 'alice%';
Créez l'index approprié pour cette requête.

EXERCICE MOYEN 3
────────────────
Écrivez une requête SQL pour afficher le nom, la taille et le nombre d'accès
(idx_scan) de tous les index de la table commandes, triés par taille
décroissante.

EXERCICE AVANCÉ 1
─────────────────
Vous avez une table avis avec 5 millions de lignes. La requête suivante
est lente :
    SELECT produit_id, ROUND(AVG(note), 2), COUNT(*)
    FROM avis
    WHERE statut_validation = 'approuve'
    GROUP BY produit_id;
Proposez la stratégie d'indexation optimale (type d'index, colonnes, partiel
ou non) et expliquez pourquoi.

EXERCICE AVANCÉ 2
─────────────────
Expliquez pourquoi l'index suivant n'est pas utilisé pour la requête ci-dessous,
et proposez la correction :
    CREATE INDEX idx_clients_nom ON clients (nom);
    SELECT * FROM clients WHERE UPPER(nom) = 'DUPONT';

EXERCICE AVANCÉ 3
─────────────────
Écrivez une requête pour identifier les index "inutiles" sur la base ShopFlow :
ceux qui n'ont jamais été utilisés (idx_scan = 0) et dont la table contient
plus de 1000 lignes. Affichez le nom de la table, le nom de l'index et la
taille de l'index.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.11 SOLUTIONS DÉTAILLÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SOLUTION FACILE 1
─────────────────
    CREATE INDEX idx_produits_nom ON produits (nom);

SOLUTION FACILE 2
─────────────────
    CREATE UNIQUE INDEX idx_uq_avis_client_produit
    ON avis (client_id, produit_id);

SOLUTION FACILE 3
─────────────────
    CREATE INDEX idx_commandes_statut_actif
    ON commandes (date_commande, commande_id)
    WHERE statut IN ('en_attente', 'en_cours');

    -- Note : on ajoute date_commande et commande_id dans l'index car les
    -- requêtes sur les commandes actives trient souvent par date.

SOLUTION MOYEN 1
────────────────
    CREATE INDEX idx_produits_categorie_actif_prix
    ON produits (categorie_id, prix_unitaire)
    WHERE actif = TRUE;

    -- Justification de l'ordre :
    -- 1. categorie_id en premier car c'est une égalité (filtre le plus restrictif)
    -- 2. prix_unitaire en second car c'est un BETWEEN + ORDER BY
    -- 3. actif = TRUE dans le WHERE partiel car toujours constant dans la requête
    --
    -- Note : actif n'est PAS mis dans les colonnes de l'index car il est
    -- dans la condition partielle WHERE — cela rend l'index plus compact.

SOLUTION MOYEN 2
────────────────
    CREATE INDEX idx_clients_email_lower
    ON clients (LOWER(email));

    -- Attention : la requête doit utiliser exactement LOWER(email), pas email.
    -- Pour LIKE avec préfixe, l'index est utilisé si la recherche commence
    -- par des caractères non-wildcard (ex: LOWER(email) LIKE 'alice%' [OK])
    -- mais pas si (LOWER(email) LIKE '%alice%' [X] -> le wildcard est au début)

SOLUTION MOYEN 3
────────────────
    SELECT
        i.indexname                                             AS index_nom,
        pg_size_pretty(pg_relation_size(i.indexname::regclass)) AS taille,
        s.idx_scan                                              AS nb_acces
    FROM pg_indexes i
    JOIN pg_stat_user_indexes s
        ON i.indexname = s.indexname
        AND i.tablename = s.relname
    WHERE i.tablename = 'commandes'
    ORDER BY pg_relation_size(i.indexname::regclass) DESC;

SOLUTION AVANCÉ 1
─────────────────
    -- Stratégie optimale : index partiel composite couvrant
    CREATE INDEX idx_avis_approuves_produit
    ON avis (produit_id, note)
    WHERE statut_validation = 'approuve';

    -- Explications :
    -- 1. INDEX PARTIEL sur statut_validation = 'approuve' :
    --    Si 70% des avis sont approuvés, l'index est 30% plus petit.
    --    Si seulement 10% sont approuvés, l'index est 10× plus petit !
    --
    -- 2. produit_id en premier : correspond au GROUP BY (regroupement)
    --
    -- 3. note en second : l'index "couvre" la colonne note, donc
    --    PostgreSQL peut faire un Index Only Scan sans toucher la table
    --    (lit directement note depuis l'index pour calculer AVG)
    --
    -- Résultat attendu : passage de Sequential Scan sur 5M lignes à
    -- Index Only Scan sur l'index partiel (beaucoup plus petit).

SOLUTION AVANCÉ 2
─────────────────
    -- Problème : L'index est sur (nom), mais la requête filtre sur UPPER(nom).
    -- UPPER(nom) est une expression différente de nom -> l'index est inutilisé.

    -- Correction : créer un index d'expression sur UPPER(nom)
    CREATE INDEX idx_clients_nom_upper ON clients (UPPER(nom));

    -- Vérification : la requête suivante utilisera maintenant l'index
    SELECT * FROM clients WHERE UPPER(nom) = 'DUPONT';

    -- Bonne pratique : normaliser les données à l'insertion
    -- (stocker nom en majuscules dans la table) pour éviter le besoin
    -- de UPPER() dans les requêtes.

SOLUTION AVANCÉ 3
─────────────────
    SELECT
        s.relname                                               AS table_nom,
        s.indexrelname                                          AS index_nom,
        pg_size_pretty(pg_relation_size(s.indexrelid))         AS taille_index,
        s.idx_scan                                              AS nb_utilisations,
        pg_relation_size(s.relid)                               AS taille_table_bytes
    FROM pg_stat_user_indexes s
    JOIN pg_class t ON s.relid = t.oid
    WHERE s.idx_scan = 0
    AND   t.reltuples > 1000
    AND   s.indexrelname NOT LIKE 'pg_%'     -- exclure les index systèmes
    ORDER BY pg_relation_size(s.indexrelid) DESC;

    -- En production, avant de DROP ces index, vérifiez :
    -- 1. pg_stat_reset() peut avoir été appelé récemment (stats remises à zéro)
    -- 2. Certains index sont utilisés uniquement lors de pics saisonniers
    -- 3. Certains index sont des contraintes UNIQUE nécessaires à l'intégrité


================================================================================
CHAPITRE 42 : ANALYSE DE PERFORMANCE AVEC EXPLAIN
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.1 INTRODUCTION À EXPLAIN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXPLAIN est la commande qui révèle le plan d'exécution d'une requête SQL.
Elle montre comment PostgreSQL compte accéder aux données : quel(s) index
il utilise, dans quel ordre il traite les tables, quelle méthode de jointure
il choisit, et combien de lignes et de temps il estime.

Sans EXPLAIN, optimiser une requête revient à réparer une voiture à l'aveugle.
Avec EXPLAIN, vous voyez exactement ce qui se passe.

Syntaxes disponibles :

    EXPLAIN requête;                -- plan estimé seulement
    EXPLAIN ANALYZE requête;        -- exécute + affiche plan réel vs estimé
    EXPLAIN (ANALYZE, BUFFERS) req; -- aussi : accès mémoire/disque
    EXPLAIN (FORMAT JSON) requête;  -- sortie JSON (pour outils d'analyse)
    EXPLAIN (FORMAT TEXT) requête;  -- sortie texte (défaut)

ATTENTION : EXPLAIN ANALYZE EXÉCUTE RÉELLEMENT LA REQUÊTE.
Pour un DELETE ou UPDATE, envelopper dans une transaction annulée :

    BEGIN;
    EXPLAIN ANALYZE DELETE FROM commandes WHERE statut = 'test';
    ROLLBACK;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.2 LIRE UN PLAN D'EXÉCUTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Exemple concret :

    EXPLAIN ANALYZE
    SELECT cl.nom, COUNT(co.commande_id) AS nb_commandes
    FROM clients cl
    JOIN commandes co ON cl.client_id = co.client_id
    WHERE co.date_commande >= '2024-01-01'
    GROUP BY cl.nom
    ORDER BY nb_commandes DESC;

Sortie typique :

    Sort  (cost=85.42..85.67 rows=100 width=40)
                (actual time=1.234..1.245 rows=8 loops=1)
      Sort Key: (count(co.commande_id)) DESC
      ->  HashAggregate  (cost=80.00..81.00 rows=100 width=40)
                         (actual time=1.189..1.201 rows=8 loops=1)
            Group Key: cl.nom
            ->  Hash Join  (cost=30.00..75.00 rows=200 width=32)
                           (actual time=0.523..1.130 rows=8 loops=1)
                  Hash Cond: (co.client_id = cl.client_id)
                  ->  Index Scan using idx_commandes_date on commandes co
                        (cost=0.28..40.00 rows=200 width=8)
                        (actual time=0.045..0.890 rows=8 loops=1)
                        Index Cond: (date_commande >= '2024-01-01')
                  ->  Hash  (cost=20.00..20.00 rows=1000 width=24)
                             (actual time=0.432..0.432 rows=10 loops=1)
                        ->  Seq Scan on clients cl
                              (cost=0.00..20.00 rows=1000 width=24)
                              (actual time=0.018..0.423 rows=10 loops=1)
    Planning Time: 0.532 ms
    Execution Time: 1.523 ms

Décoder chaque élément :

    cost=85.42..85.67
    ├── 85.42 : coût de démarrage (avant de retourner la première ligne)
    └── 85.67 : coût total estimé (unités arbitraires PostgreSQL)

    rows=100       -> nombre de lignes estimé par PostgreSQL
    width=40       -> taille moyenne estimée d'une ligne (en octets)

    actual time=1.234..1.245
    ├── 1.234 : temps réel pour la première ligne (ms)
    └── 1.245 : temps réel total (ms)

    rows=8 (actual) -> nombre de lignes réellement retournées
    loops=1         -> combien de fois ce nœud a été exécuté

Lire le plan de bas en haut (feuilles -> racine) :
1. Seq Scan on clients : lit toute la table clients (10 lignes)
2. Index Scan on commandes : utilise l'index de date pour filtrer
3. Hash Join : joint les deux en construisant une table de hachage
4. HashAggregate : fait le GROUP BY
5. Sort : fait le ORDER BY

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.3 TYPES DE SCANS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sequential Scan (Seq Scan) :
─────────────────────────────
    Seq Scan on produits  (cost=0.00..1.15 rows=15 width=200)

- Lit toutes les lignes de la table, séquentiellement
- NORMAL et OPTIMAL pour les petites tables (< 1000 lignes)
- PROBLÉMATIQUE pour les grandes tables si un index existe mais n'est pas utilisé
- Peut être choisi même avec un index si PostgreSQL estime que >10-20% des lignes
  seront retournées (plus rapide de tout lire que de sauter partout)

Index Scan :
─────────────
    Index Scan using idx_commandes_date on commandes
      Index Cond: (date_commande >= '2024-01-01')

- Utilise l'index pour trouver les TID (pointeurs de ligne)
- Puis accède à la table pour chaque TID trouvé
- Optimal quand peu de lignes sont retournées (< 10-20% de la table)

Index Only Scan :
──────────────────
    Index Only Scan using idx_produits_covering on produits
      Index Cond: (categorie_id = 3)
      Heap Fetches: 0  <- aucun accès à la table !

- Toutes les colonnes nécessaires sont dans l'index
- Aucun accès à la table : le plus rapide possible
- Heap Fetches = 0 est le signe que c'est un vrai Index Only Scan

Bitmap Index Scan + Bitmap Heap Scan :
────────────────────────────────────────
    Bitmap Heap Scan on commandes
      ->  Bitmap Index Scan on idx_commandes_statut
            Index Cond: (statut = 'en_attente')

- D'abord Bitmap Index Scan : parcourt l'index et construit un bitmap
  (une carte des pages à lire)
- Ensuite Bitmap Heap Scan : lit les pages de la table dans l'ordre
  (plus efficace que Index Scan pour de nombreuses lignes)
- Utilisé quand le résultat est trop grand pour un Index Scan classique
  mais pas assez pour justifier un Seq Scan

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.4 TYPES DE JOINTURES DANS LE PLAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Nested Loop Join :
───────────────────
    Nested Loop  (cost=0.28..55.00 rows=10 width=32)
      ->  Seq Scan on clients         (outer)
      ->  Index Scan on commandes     (inner, pour chaque ligne de clients)

- Pour chaque ligne de la table externe, parcourt la table interne
- Optimal quand la table externe est petite ET l'interne a un bon index
- Complexité : O(n × m) dans le pire cas, O(n × log(m)) avec index

Hash Join :
────────────
    Hash Join  (cost=30.00..75.00 rows=200 width=32)
      Hash Cond: (co.client_id = cl.client_id)
      ->  Seq Scan on commandes     (probe side)
      ->  Hash                      (build side)
            ->  Seq Scan on clients

- Construit une table de hachage en mémoire à partir de la petite table
- Puis parcourt la grande table et sonde la table de hachage
- Optimal quand les deux tables sont grandes et sans index de jointure
- Nécessite de la mémoire (work_mem)

Merge Join :
─────────────
    Merge Join  (cost=200.00..250.00 rows=1000 width=32)
      Merge Cond: (co.client_id = cl.client_id)
      ->  Sort on commandes  (ou Index Scan trié)
      ->  Sort on clients    (ou Index Scan trié)

- Parcourt les deux tables en parallèle, en ordre trié
- Optimal quand les deux tables sont déjà triées (via un index)
- Très efficace pour les grandes tables avec index sur la clé de jointure

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.5 IDENTIFIER ET RÉSOUDRE LES PROBLÈMES DE PERFORMANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Signal 1 : Rows estimé ≠ Rows actual (mauvaises statistiques)
─────────────────────────────────────────────────────────────
    -- Plan dit rows=100, mais actual rows=50000
    -- PostgreSQL a fait un mauvais choix de plan car ses statistiques sont périmées

    -- Solution : mettre à jour les statistiques
    ANALYZE commandes;    -- met à jour les stats de cette table
    ANALYZE;              -- met à jour toutes les tables

    -- PostgreSQL fait ANALYZE automatiquement (autovacuum), mais parfois
    -- après un gros chargement de données, il faut le forcer.

Signal 2 : Seq Scan sur grande table (index manquant)
──────────────────────────────────────────────────────
    -- Plan : Seq Scan on commandes  rows=500000
    -- La requête filtre sur date_commande mais pas d'index

    EXPLAIN ANALYZE
    SELECT * FROM commandes WHERE date_commande >= '2024-06-01';
    -- -> Seq Scan : très lent (lit 500 000 lignes)

    -- Solution : créer l'index manquant
    CREATE INDEX CONCURRENTLY idx_commandes_date ON commandes (date_commande);

    -- Après l'index :
    -- -> Index Scan : lit seulement les lignes pertinentes

Signal 3 : Index présent mais non utilisé
──────────────────────────────────────────
Raisons possibles :
    a) La requête applique une fonction sur la colonne indexée
       WHERE UPPER(nom) = 'DUPONT'  -- index sur nom, pas sur UPPER(nom)
       Solution : index d'expression sur UPPER(nom)

    b) La sélectivité est trop faible (beaucoup de lignes retournées)
       WHERE actif = TRUE  -- si 95% des lignes sont actives -> Seq Scan préféré
       Solution : index partiel ou repenser la requête

    c) Le type de données ne correspond pas
       WHERE client_id = '42'  -- client_id est INTEGER, '42' est TEXT
       PostgreSQL doit caster -> invalidation de l'index
       Solution : WHERE client_id = 42  (pas de quotes)

    d) L'index est fragmenté (après beaucoup d'UPDATE/DELETE)
       Solution : REINDEX INDEX nom_index;

Signal 4 : Sort lent sur grande table
──────────────────────────────────────
    -- Sort  (cost=1500.00..1550.00 rows=50000)
    -- Sort Method: external merge  <- trie sur disque ! très lent

    -- Solution 1 : augmenter work_mem pour ce tri en mémoire
    SET work_mem = '256MB';
    -- puis ré-exécuter la requête

    -- Solution 2 : créer un index sur la colonne de tri
    CREATE INDEX idx_commandes_date_desc ON commandes (date_commande DESC);

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.6 WORKFLOW COMPLET D'OPTIMISATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici la méthode étape par étape pour optimiser une requête lente :

    ┌──────────────────────────────────────────────────────────────┐
    │ ÉTAPE 1 : Identifier la requête lente                        │
    │   -> pg_stat_statements, logs PostgreSQL, monitoring          │
    └──────────────────┬───────────────────────────────────────────┘
                       v
    ┌──────────────────────────────────────────────────────────────┐
    │ ÉTAPE 2 : EXPLAIN ANALYZE la requête                         │
    │   -> Lire le plan, identifier le nœud le plus coûteux        │
    └──────────────────┬───────────────────────────────────────────┘
                       v
    ┌──────────────────────────────────────────────────────────────┐
    │ ÉTAPE 3 : Diagnostiquer                                      │
    │   -> Seq Scan sur grande table ? -> index manquant             │
    │   -> Rows estimé ≠ actual ?      -> ANALYZE                   │
    │   -> Sort on disk ?              -> work_mem ou index          │
    │   -> Hash Join avec grosse table ? -> index de jointure        │
    └──────────────────┬───────────────────────────────────────────┘
                       v
    ┌──────────────────────────────────────────────────────────────┐
    │ ÉTAPE 4 : Appliquer la correction                            │
    │   -> Créer/modifier l'index                                   │
    │   -> Réécrire la requête (sous-requête -> JOIN, CTE, etc.)     │
    │   -> ANALYZE la table                                         │
    └──────────────────┬───────────────────────────────────────────┘
                       v
    ┌──────────────────────────────────────────────────────────────┐
    │ ÉTAPE 5 : Vérifier avec EXPLAIN ANALYZE                      │
    │   -> Comparer les temps avant/après                           │
    │   -> S'assurer que le bon index est utilisé                   │
    └──────────────────────────────────────────────────────────────┘

Exemple complet de cycle d'optimisation :

    -- Requête signalée comme lente (2.3 secondes)
    SELECT p.nom, SUM(lc.quantite) AS total_vendu
    FROM produits p
    JOIN lignes_commande lc ON p.produit_id = lc.produit_id
    JOIN commandes co ON lc.commande_id = co.commande_id
    WHERE co.date_commande BETWEEN '2024-01-01' AND '2024-12-31'
    AND p.categorie_id = 1
    GROUP BY p.nom
    ORDER BY total_vendu DESC;

    -- Étape 1 : EXPLAIN ANALYZE
    -- -> Seq Scan on commandes (rows=50000) : pas d'index sur date_commande
    -- -> Seq Scan on lignes_commande (rows=200000) : pas d'index sur commande_id
    -- -> Rows estimés pour commandes : 10000, actual : 8750 (correct)

    -- Étape 2 : créer les index manquants
    CREATE INDEX CONCURRENTLY idx_co_date
        ON commandes (date_commande)
        WHERE date_commande IS NOT NULL;

    -- idx_lignes_commande_commande existe déjà dans ShopFlow [OK]
    -- idx_produits_categorie existe déjà [OK]

    -- Étape 3 : EXPLAIN ANALYZE après
    -- -> Index Scan on commandes via idx_co_date : 0.8 ms (vs 450 ms)
    -- -> Nested Loop avec Index Scan on lignes_commande : 12 ms (vs 1800 ms)
    -- -> Execution Time: 0.045 s (vs 2.3 s) : ×50 plus rapide !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.7 EXERCICES PRATIQUES — EXPLAIN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE FACILE 1
─────────────────
Exécutez EXPLAIN (sans ANALYZE) sur cette requête et identifiez le type
de scan utilisé :
    SELECT * FROM produits WHERE prix_unitaire > 500;

EXERCICE FACILE 2
─────────────────
Quelle est la différence entre EXPLAIN et EXPLAIN ANALYZE ? Quand utiliser
l'un plutôt que l'autre ? Dans quel cas faut-il absolument envelopper
EXPLAIN ANALYZE dans une transaction ?

EXERCICE FACILE 3
─────────────────
Dans un plan EXPLAIN, que signifient les deux valeurs de cost=X..Y ?
Et les deux valeurs de actual time=A..B ?

EXERCICE MOYEN 1
────────────────
Exécutez EXPLAIN ANALYZE sur la requête suivante et identifiez si des
index sont utilisés ou non. Si un Seq Scan est observé sur une grande
table, proposez l'index approprié :
    SELECT co.commande_id, cl.email, co.statut
    FROM commandes co
    JOIN clients cl ON co.client_id = cl.client_id
    WHERE co.statut = 'livree'
    AND cl.segment = 'VIP';

EXERCICE MOYEN 2
────────────────
La requête suivante est lente. Utilisez EXPLAIN ANALYZE pour diagnostiquer
le problème, puis proposez une solution :
    SELECT *
    FROM commandes
    WHERE EXTRACT(YEAR FROM date_commande) = 2024;

EXERCICE MOYEN 3
────────────────
Comment forcer PostgreSQL à utiliser un Seq Scan même si un index existe
(utile pour tester) ? Comment forcer l'utilisation d'un index spécifique ?

EXERCICE AVANCÉ 1
─────────────────
Écrivez une requête utilisant pg_stat_statements (si disponible) pour
identifier les 5 requêtes les plus consommatrices de temps total sur
la base de données. Quelles colonnes sont les plus utiles pour le diagnostic ?

EXERCICE AVANCÉ 2
─────────────────
Analysez ce plan et identifiez les deux problèmes principaux :

    Hash Join  (cost=500..8000 rows=50000)
               (actual time=12..9500 rows=47000 loops=1)
      Hash Cond: (lc.produit_id = p.produit_id)
      ->  Seq Scan on lignes_commande lc
            (cost=0..4000 rows=200000)(actual time=0.1..1200 rows=200000 loops=1)
      ->  Hash
            ->  Seq Scan on produits p
                  (cost=0..1.15 rows=15)(actual time=0.02..0.08 rows=15 loops=1)
                  Filter: (actif = TRUE)
                  Rows Removed by Filter: 2

EXERCICE AVANCÉ 3
─────────────────
Expliquez la différence entre un Index Scan et un Index Only Scan.
Comment créer un index "couvrant" pour que la requête suivante utilise
un Index Only Scan ?
    SELECT nom, prix_unitaire, stock_quantite
    FROM produits
    WHERE categorie_id = 2 AND actif = TRUE
    ORDER BY prix_unitaire;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.8 SOLUTIONS DÉTAILLÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SOLUTION FACILE 1
─────────────────
    EXPLAIN SELECT * FROM produits WHERE prix_unitaire > 500;

    -- Résultat probable (petite table) :
    -- Seq Scan on produits  (cost=0.00..1.19 rows=5 width=200)
    --   Filter: (prix_unitaire > '500'::numeric)
    --
    -- Explication : la table produits est petite (15 lignes dans ShopFlow).
    -- PostgreSQL préfère un Seq Scan car c'est plus rapide que de traverser
    -- un index pour 15 lignes. Ce comportement est NORMAL et CORRECT.

SOLUTION FACILE 2
─────────────────
    EXPLAIN         : montre seulement le PLAN ESTIMÉ (coûts, lignes prévus)
                      N'exécute pas réellement la requête.
                      Usage : comprendre rapidement le plan sans risque.

    EXPLAIN ANALYZE : EXÉCUTE la requête + affiche plan réel (temps et lignes réels)
                      Permet de comparer estimations vs réalité.
                      Usage : diagnostiquer précisément un problème de performance.

    Quand envelopper dans une transaction :
    - Pour toute requête modifiant des données (INSERT, UPDATE, DELETE, TRUNCATE)
    - Pour ne pas appliquer les changements réels pendant le diagnostic

    Exemple :
        BEGIN;
        EXPLAIN ANALYZE UPDATE clients SET segment = 'VIP' WHERE total_depense > 5000;
        ROLLBACK;  -- annule les changements, garde uniquement le plan

SOLUTION FACILE 3
─────────────────
    cost=X..Y :
    X = coût de démarrage : coût avant de retourner la première ligne
        (ex: pour un tri, X est élevé car il faut tout trier avant la première sortie)
    Y = coût total estimé : coût pour retourner toutes les lignes
        (unités arbitraires : 1 unité ≈ lecture d'une page disque)

    actual time=A..B :
    A = temps réel en millisecondes pour retourner la première ligne
    B = temps réel en millisecondes pour retourner toutes les lignes

    Un grand écart entre cost et actual time indique des statistiques inexactes
    -> solution : ANALYZE la table concernée

SOLUTION MOYEN 1
────────────────
    EXPLAIN ANALYZE
    SELECT co.commande_id, cl.email, co.statut
    FROM commandes co
    JOIN clients cl ON co.client_id = cl.client_id
    WHERE co.statut = 'livree'
    AND cl.segment = 'VIP';

    -- Analyse du plan probable :
    -- • Seq Scan on commandes avec Filter(statut = 'livree')
    --   -> index idx_commandes_statut absent dans le schéma de base ShopFlow
    --   -> créer : CREATE INDEX idx_commandes_statut ON commandes (statut);
    --
    -- • Seq Scan on clients avec Filter(segment = 'VIP')
    --   -> idx_clients_segment existe dans ShopFlow (créé en Part 1) [OK]
    --   -> si absent : CREATE INDEX idx_clients_segment ON clients (segment);
    --
    -- • La jointure sur client_id utilise idx_commandes_client [OK]

SOLUTION MOYEN 2
────────────────
    EXPLAIN ANALYZE
    SELECT * FROM commandes WHERE EXTRACT(YEAR FROM date_commande) = 2024;

    -- Problème : EXTRACT(YEAR FROM date_commande) est une EXPRESSION
    -- L'index sur date_commande ne peut pas être utilisé directement
    -- -> Seq Scan obligatoire même si idx_commandes_date existe

    -- Solutions (par ordre de préférence) :

    -- Option 1 : réécrire la requête avec une plage de dates (RECOMMANDÉ)
    SELECT * FROM commandes
    WHERE date_commande >= '2024-01-01'
    AND   date_commande <  '2025-01-01';
    -- -> utilise idx_commandes_date : Index Scan !

    -- Option 2 : créer un index d'expression (si la requête ne peut pas changer)
    CREATE INDEX idx_commandes_annee
    ON commandes (EXTRACT(YEAR FROM date_commande));
    -- Alors EXTRACT(...) = 2024 utilise cet index

SOLUTION MOYEN 3
────────────────
    -- Forcer un Seq Scan (désactiver les index scans pour la session) :
    SET enable_indexscan = OFF;
    SET enable_bitmapscan = OFF;
    -- Exécuter la requête -> Seq Scan forcé
    -- Remettre les paramètres par défaut :
    SET enable_indexscan = ON;
    SET enable_bitmapscan = ON;

    -- Forcer un index spécifique : PostgreSQL n'a pas de hint direct
    -- (contrairement à Oracle ou MySQL).
    -- Astuce : désactiver les autres scans pour forcer l'index voulu.
    -- En pratique : si l'optimiseur ne choisit pas votre index, c'est souvent
    -- parce que les statistiques sont mauvaises -> ANALYZE d'abord.

SOLUTION AVANCÉ 1
─────────────────
    -- Activer pg_stat_statements (si pas déjà fait)
    -- Dans postgresql.conf : shared_preload_libraries = 'pg_stat_statements'
    -- Puis :
    CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

    -- Top 5 requêtes par temps total
    SELECT
        LEFT(query, 100)                    AS requete,
        calls                               AS nb_executions,
        ROUND(total_exec_time::numeric, 2)  AS temps_total_ms,
        ROUND(mean_exec_time::numeric, 2)   AS temps_moyen_ms,
        ROUND(stddev_exec_time::numeric, 2) AS ecart_type_ms,
        rows                                AS total_lignes
    FROM pg_stat_statements
    ORDER BY total_exec_time DESC
    LIMIT 5;

    -- Colonnes les plus utiles :
    -- total_exec_time : temps total cumulé -> identifie les requêtes qui coûtent
    --                   le plus globalement (même si rapides mais appelées souvent)
    -- mean_exec_time  : temps moyen par exécution -> identifie les requêtes
    --                   lentes individuellement
    -- calls           : nombre d'appels -> requêtes fréquentes à optimiser en priorité
    -- rows/calls      : lignes retournées en moyenne -> détecte les SELECT * abusifs

SOLUTION AVANCÉ 2
─────────────────
    -- PROBLÈME 1 : Hash Join inefficace sur lignes_commande
    --
    -- actual rows=200000 loops=1 sur le Seq Scan de lignes_commande
    -- -> toute la table est lue sans aucun filtre !
    -- La jointure se fait sur produit_id, mais il n'y a pas de filtre
    -- sur lignes_commande elle-même.
    -- Solution : s'assurer que idx_lignes_commande_produit existe,
    -- ou repenser la requête pour filtrer plus tôt.

    -- PROBLÈME 2 : Seq Scan sur produits avec filter = actif=TRUE
    -- Rows Removed by Filter: 2 -> seulement 2 lignes filtrées sur 17
    -- La table est petite -> Seq Scan est NORMAL et CORRECT ici.
    -- Pas de problème réel sur cette branche.

    -- VRAI PROBLÈME : Hash Join construit une table de hachage sur
    -- 200 000 lignes de lignes_commande pour joindre avec 15 produits.
    -- C'est à l'envers ! PostgreSQL devrait utiliser produits comme
    -- "build" (table de hachage) et lignes_commande comme "probe".
    -- Si les statistiques étaient correctes, PostgreSQL inverserait
    -- automatiquement. -> Solution : ANALYZE lignes_commande; ANALYZE produits;

SOLUTION AVANCÉ 3
─────────────────
    -- Index Scan vs Index Only Scan :
    --
    -- Index Scan :
    --   1. Parcourt l'index pour trouver les TID (pointeurs de lignes)
    --   2. Pour chaque TID, accède à la table (Heap) pour lire la ligne complète
    --   Nécessite des accès table -> plus lent
    --
    -- Index Only Scan :
    --   1. Parcourt l'index pour trouver les TID
    --   2. Toutes les colonnes SELECT sont DANS l'index -> pas d'accès table !
    --   Heap Fetches = 0 -> le plus rapide possible
    --
    -- Pour que SELECT nom, prix_unitaire, stock_quantite utilise Index Only Scan
    -- avec WHERE categorie_id = 2 AND actif = TRUE ORDER BY prix_unitaire :
    -- L'index doit contenir TOUTES les colonnes : categorie_id, actif, prix_unitaire,
    -- nom, stock_quantite.

    -- Index couvrant :
    CREATE INDEX idx_produits_categorie_covering
    ON produits (categorie_id, prix_unitaire)
    INCLUDE (nom, stock_quantite)    -- INCLUDE = colonnes stockées dans l'index
                                     -- mais non triées (disponibles pour l'Index Only)
    WHERE actif = TRUE;              -- index partiel pour les produits actifs

    -- INCLUDE (PostgreSQL 11+) : ajoute des colonnes "extra" dans l'index
    -- sans les intégrer dans la structure de tri.
    -- Résultat : Index Only Scan possible pour la requête !
    -- Vérification :
    EXPLAIN ANALYZE
    SELECT nom, prix_unitaire, stock_quantite
    FROM produits
    WHERE categorie_id = 2 AND actif = TRUE
    ORDER BY prix_unitaire;
    -- -> Index Only Scan using idx_produits_categorie_covering
    --   Heap Fetches: 0  [OK]


================================================================================
FIN DE LA PARTIE 10
Vues (CREATE VIEW, vues matérialisées, sécurité, CHECK OPTION)
Index (B-tree, UNIQUE, partiels, expression, composites, GIN/GiST)
Performance avec EXPLAIN ANALYZE (scans, jointures, optimisation)
================================================================================


================================================================================
GUIDE SQL COMPLET — PARTIE 11
TRANSACTIONS : BEGIN, COMMIT, ROLLBACK, SAVEPOINT
Chapitres 43 à 45
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Intermédiaire -> Avancé
================================================================================

TABLE DES MATIÈRES — PARTIE 11
════════════════════════════════
  Chapitre 43 : BEGIN — Démarrer une transaction
  Chapitre 44 : COMMIT — Valider une transaction
  Chapitre 45 : ROLLBACK et SAVEPOINT — Annuler et points de sauvegarde


================================================================================
CHAPITRE 43 : BEGIN — DÉMARRER UNE TRANSACTION
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.1 INTRODUCTION — QU'EST-CE QU'UNE TRANSACTION ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une transaction est un groupe d'opérations SQL qui s'exécutent comme une
seule unité atomique : soit TOUT réussit, soit RIEN n'est appliqué.

L'exemple classique — le virement bancaire :
────────────────────────────────────────────
    -- Virer 100€ du compte A vers le compte B
    UPDATE comptes SET solde = solde - 100 WHERE id = 'A';  -- Étape 1
    UPDATE comptes SET solde = solde + 100 WHERE id = 'B';  -- Étape 2

Que se passe-t-il si le serveur plante entre les deux UPDATE ?
  - Sans transaction : le compte A est débité, B n'est pas crédité -> 100€ perdus !
  - Avec transaction : les deux opérations sont liées -> si l'une échoue,
    l'autre est automatiquement annulée.

Dans ShopFlow, une commande implique :
  1. Créer la commande (INSERT dans commandes)
  2. Ajouter les lignes (INSERT dans lignes_commande)
  3. Décrémenter le stock (UPDATE produits)
  4. Créer le paiement (INSERT dans paiements)

Si l'une de ces étapes échoue, toutes doivent être annulées. Les transactions
garantissent cette cohérence.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.2 LES PROPRIÉTÉS ACID — LE FONDEMENT DES TRANSACTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les transactions garantissent 4 propriétés fondamentales (revues en profondeur).

A — ATOMICITÉ (Atomicity)
─────────────────────────
Toutes les opérations de la transaction réussissent, ou aucune n'est appliquée.
La transaction est indivisible (atomos = insécable en grec).

    BEGIN;
    UPDATE produits SET stock = stock - 1 WHERE id_produit = 1;
    INSERT INTO lignes_commande (...) VALUES (...);
    -- Si l'INSERT échoue -> l'UPDATE est automatiquement annulé
    COMMIT;

C — COHÉRENCE (Consistency)
────────────────────────────
La base passe d'un état valide à un autre état valide.
Les contraintes (FK, CHECK, UNIQUE, NOT NULL) sont vérifiées.
Si une contrainte est violée -> la transaction est refusée.

    BEGIN;
    INSERT INTO commandes (id_client) VALUES (99999);  -- Client inexistant !
    -- PostgreSQL vérifie la FK -> ERREUR immédiate
    -- La transaction entière est annulée
    ROLLBACK;

I — ISOLATION
─────────────
Les transactions concurrentes ne se voient pas mutuellement jusqu'à leur COMMIT.
Les lectures intermédiaires d'une transaction non validée sont invisibles aux autres.

Exemple : deux caissiers traitent des commandes simultanément sans se gêner.

D — DURABILITÉ (Durability)
────────────────────────────
Une transaction validée (COMMIT) est permanente, même en cas de panne.
PostgreSQL utilise un Write-Ahead Log (WAL) : toutes les modifications sont
d'abord écrites dans le journal avant d'être appliquées aux données.

    BEGIN;
    INSERT INTO clients (...) VALUES (...);
    COMMIT;  -- À partir de là, même si le serveur plante 1ms après, la ligne est sauvée

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.3 SYNTAXE DE BEGIN — DÉMARRER UNE TRANSACTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Syntaxes équivalentes (toutes identiques dans PostgreSQL) :

    BEGIN;                          -- Le plus courant
    BEGIN WORK;                     -- Alias de BEGIN
    START TRANSACTION;              -- Norme SQL standard
    BEGIN TRANSACTION;              -- Variante longue

Options de BEGIN (niveau d'isolation) :

    BEGIN ISOLATION LEVEL READ COMMITTED;   -- défaut dans PostgreSQL
    BEGIN ISOLATION LEVEL REPEATABLE READ;
    BEGIN ISOLATION LEVEL SERIALIZABLE;
    BEGIN READ ONLY;                        -- transaction de lecture seule

Autocommit (comportement par défaut sans BEGIN) :
─────────────────────────────────────────────────
PostgreSQL, comme la plupart des SGBD, fonctionne en mode AUTOCOMMIT.
Chaque instruction SQL est automatiquement enveloppée dans sa propre
transaction et validée immédiatement.

    -- Sans BEGIN, chaque instruction est une transaction implicite :
    INSERT INTO clients (...) VALUES (...);   -- transaction implicite -> COMMIT auto
    UPDATE produits SET stock = 5 WHERE ...;  -- transaction implicite -> COMMIT auto

    -- Avec BEGIN, vous groupez plusieurs instructions :
    BEGIN;
    INSERT INTO clients (...) VALUES (...);
    UPDATE produits SET stock = 5 WHERE ...;
    COMMIT;   -- les deux sont validées ensemble

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.4 TRANSACTION COMPLÈTE — CRÉATION D'UNE COMMANDE SHOPFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici une transaction réaliste pour créer une commande complète dans ShopFlow.

    BEGIN;

    -- 1. Créer la commande principale
    INSERT INTO commandes (
        numero_commande, id_client, id_employe,
        date_commande, statut, montant_ht, montant_tva, montant_ttc
    )
    VALUES (
        'CMD-2024-011', 1, 2,
        NOW(), 'CONFIRMEE',
        1999.17, 399.83, 2399.00
    );

    -- 2. Récupérer l'ID généré (PostgreSQL)
    -- Dans une application, on utilise RETURNING :
    -- INSERT INTO commandes (...) VALUES (...) RETURNING id_commande;

    -- 3. Ajouter les lignes de commande
    INSERT INTO lignes_commande (
        id_commande, id_produit, quantite,
        prix_unitaire_ht, taux_tva, remise_pct
    )
    VALUES
        (currval('commandes_id_commande_seq'), 1, 1, 1999.17, 20.00, 0),
        (currval('commandes_id_commande_seq'), 8, 2, 207.50,  20.00, 5);

    -- 4. Décrémenter le stock pour chaque produit commandé
    UPDATE produits
    SET stock = stock - 1,
        date_maj = NOW()
    WHERE id_produit = 1;

    UPDATE produits
    SET stock = stock - 2,
        date_maj = NOW()
    WHERE id_produit = 8;

    -- 5. Vérification : s'assurer qu'on n'est pas en stock négatif
    -- (Dans une application, on vérifierait avant et ferait un ROLLBACK si nécessaire)

    -- 6. Enregistrer le paiement
    INSERT INTO paiements (
        id_commande, montant, mode_paiement, statut
    )
    VALUES (
        currval('commandes_id_commande_seq'),
        2399.00, 'CARTE_CREDIT', 'VALIDE'
    );

    COMMIT;
    -- Si tout s'est bien passé, les 4 opérations sont validées ensemble.
    -- Si une erreur survient n'importe où -> ROLLBACK automatique.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.5 LES NIVEAUX D'ISOLATION EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les niveaux d'isolation définissent comment les transactions concurrentes
se "voient" mutuellement. Plus le niveau est élevé, plus la cohérence est
garantie, mais plus le risque de contention (verrous) est élevé.

Problèmes que les niveaux d'isolation résolvent :

  Dirty Read    : lire des données d'une transaction non encore validée
  Non-Repeatable Read : lire deux fois la même ligne et obtenir des valeurs différentes
  Phantom Read  : une requête retourne des lignes différentes lors de deux exécutions

Tableau des niveaux d'isolation SQL standard :

  ┌─────────────────────┬────────────┬──────────────────────┬──────────────┐
  │ Niveau              │ Dirty Read │ Non-Repeatable Read  │ Phantom Read │
  ├─────────────────────┼────────────┼──────────────────────┼──────────────┤
  │ READ UNCOMMITTED    │ Possible   │ Possible             │ Possible     │
  │ READ COMMITTED (*)  │ Impossible │ Possible             │ Possible     │
  │ REPEATABLE READ     │ Impossible │ Impossible           │ Possible (*) │
  │ SERIALIZABLE        │ Impossible │ Impossible           │ Impossible   │
  └─────────────────────┴────────────┴──────────────────────┴──────────────┘
  (*) = défaut PostgreSQL / (*) = PostgreSQL empêche aussi les Phantoms en REPEATABLE READ

READ COMMITTED (défaut PostgreSQL) :
────────────────────────────────────
    -- Transaction A                    -- Transaction B (simultanée)
    BEGIN;                              BEGIN;
    SELECT stock FROM produits          UPDATE produits
    WHERE id_produit = 1;               SET stock = 50
    -- Voit : stock = 45                WHERE id_produit = 1;
                                        COMMIT;
    SELECT stock FROM produits
    WHERE id_produit = 1;
    -- Voit maintenant : stock = 50  <- Non-Repeatable Read !
    COMMIT;

REPEATABLE READ :
─────────────────
    BEGIN ISOLATION LEVEL REPEATABLE READ;
    SELECT stock FROM produits WHERE id_produit = 1;  -- Voit 45
    -- Même si B fait COMMIT avec stock=50 entre les deux...
    SELECT stock FROM produits WHERE id_produit = 1;  -- Voit ENCORE 45
    -- Snapshot stable pour toute la transaction
    COMMIT;

SERIALIZABLE :
───────────────
Le plus strict. Les transactions s'exécutent comme si elles étaient
séquentielles (l'une après l'autre), même si elles sont concurrentes.
PostgreSQL utilise SSI (Serializable Snapshot Isolation) pour cela.

    BEGIN ISOLATION LEVEL SERIALIZABLE;
    -- Si une sérialisation est impossible (conflit détecté),
    -- la transaction est annulée avec ERROR: could not serialize access
    -- L'application doit alors réessayer.
    COMMIT;

Quand utiliser chaque niveau dans ShopFlow ?
  READ COMMITTED  : lecture de rapports, consultation de catalogue (défaut)
  REPEATABLE READ : calculs complexes nécessitant une vue cohérente des données
  SERIALIZABLE    : opérations financières critiques (décrémentation de stock,
                    transferts, allocations de ressources uniques)


================================================================================
CHAPITRE 44 : COMMIT — VALIDER UNE TRANSACTION
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44.1 CE QUE FAIT COMMIT INTERNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand vous exécutez COMMIT, PostgreSQL :

  1. Écrit un enregistrement de validation dans le WAL (Write-Ahead Log)
  2. Rend les modifications visibles pour toutes les autres sessions
  3. Libère tous les verrous acquis pendant la transaction
  4. Détruit le snapshot de lecture (pour les niveaux REPEATABLE READ+)

Flow simplifié du COMMIT :

    BEGIN
      v
    Modifications en mémoire (buffer pool)
    Verrous acquis sur les lignes/tables
      v
    COMMIT
      v
    WAL flush (garantie de durabilité)
      v
    Modifications visibles pour les autres
      v
    Libération des verrous

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44.2 COMMIT ET GESTION DES ERREURS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Si une erreur survient pendant une transaction, PostgreSQL met la transaction
dans un état "aborted". Dans cet état, TOUTE instruction suivante échoue
avec l'erreur "ERROR: current transaction is aborted, commands ignored
until end of transaction block".

    BEGIN;
    INSERT INTO produits (nom, prix_ht) VALUES ('Test', 10.00);  -- OK
    INSERT INTO produits (nom, prix_ht) VALUES (NULL, 10.00);    -- ERREUR (NOT NULL)
    -- Transaction maintenant en état "aborted"
    INSERT INTO clients (...);  -- ERREUR : "transaction is aborted"
    COMMIT;  -- Ne fait rien ! La transaction est en état aborted
    -- Il faut faire ROLLBACK pour nettoyer l'état

Gestion correcte des erreurs :

    BEGIN;
    INSERT INTO produits (nom, prix_ht) VALUES ('Test', 10.00);

    -- Si on sait qu'une erreur peut survenir, on utilise un SAVEPOINT
    SAVEPOINT avant_insertion_risquee;
    INSERT INTO produits (nom) VALUES (NULL);  -- ERREUR possible
    -- Si erreur :
    ROLLBACK TO SAVEPOINT avant_insertion_risquee;
    -- Continuer normalement...

    COMMIT;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44.3 COMMIT ET RETURNING — RÉCUPÉRER LES VALEURS GÉNÉRÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans une application, on utilise souvent RETURNING pour récupérer les IDs
générés au sein d'une transaction.

    BEGIN;

    -- Créer un client et récupérer son ID
    INSERT INTO clients (prenom, nom, email, code_client)
    VALUES ('Test', 'Dupont', 'test@email.com', 'CLI-0011')
    RETURNING id_client;
    -- Résultat : id_client = 11

    -- Utiliser l'ID pour créer une commande
    INSERT INTO commandes (id_client, numero_commande, statut)
    VALUES (11, 'CMD-2024-012', 'CONFIRMEE')
    RETURNING id_commande;
    -- Résultat : id_commande = 11

    COMMIT;

En Python (psycopg2), on récupère ces valeurs ainsi :

    with conn.cursor() as cur:
        cur.execute("""
            INSERT INTO clients (prenom, nom, email, code_client)
            VALUES (%s, %s, %s, %s)
            RETURNING id_client
        """, ('Test', 'Dupont', 'test@email.com', 'CLI-0011'))
        client_id = cur.fetchone()[0]

        cur.execute("""
            INSERT INTO commandes (id_client, numero_commande, statut)
            VALUES (%s, %s, %s)
            RETURNING id_commande
        """, (client_id, 'CMD-2024-012', 'CONFIRMEE'))
        commande_id = cur.fetchone()[0]

    conn.commit()  # Valide toute la transaction

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44.4 TRANSACTIONS DDL — UNE FORCE DE POSTGRESQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PostgreSQL supporte les transactions DDL — vous pouvez envelopper
CREATE TABLE, ALTER TABLE, DROP TABLE dans une transaction.
MySQL et Oracle ne le font PAS.

    BEGIN;

    -- Migration en toute sécurité
    ALTER TABLE clients ADD COLUMN score_fidélite INTEGER DEFAULT 0;
    UPDATE clients SET score_fidélite = 100 WHERE segment = 'VIP';
    UPDATE clients SET score_fidélite = 50  WHERE segment = 'PREMIUM';

    -- Vérifier le résultat avant de valider
    SELECT segment, AVG(score_fidélite) FROM clients GROUP BY segment;

    -- Si tout est correct :
    COMMIT;
    -- Sinon :
    -- ROLLBACK;  -- La colonne score_fidélite n'existera jamais

Avantage immense pour les migrations de schéma : si une migration en 10 étapes
échoue à l'étape 7, ROLLBACK remet la base dans l'état d'avant la migration.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44.5 EXERCICES COMMIT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 44-1 (Facile)
Écrivez une transaction qui ajoute un nouveau client et immédiatement
sa première commande. Utilisez RETURNING pour récupérer les IDs.

SOLUTION :
    BEGIN;

    WITH nouveau_client AS (
        INSERT INTO clients (
            code_client, prenom, nom, email,
            adresse_ville, adresse_cp, segment
        )
        VALUES (
            'CLI-0011', 'Sophie', 'Durand', 'sophie.durand@email.fr',
            'Bordeaux', '33000', 'NOUVEAU'
        )
        RETURNING id_client
    )
    INSERT INTO commandes (
        numero_commande, id_client, statut, montant_ht, montant_tva, montant_ttc
    )
    SELECT
        'CMD-2024-011', id_client, 'CONFIRMEE', 45.83, 9.17, 55.00
    FROM nouveau_client
    RETURNING id_commande, numero_commande;

    COMMIT;

EXERCICE 44-2 (Moyen)
Écrivez une migration sécurisée dans une transaction DDL :
1. Ajouter une colonne "date_derniere_commande" à la table clients
2. La renseigner avec la vraie valeur pour chaque client
3. Vérifier que les données sont cohérentes avant de valider

SOLUTION :
    BEGIN;

    -- Étape 1 : Ajouter la colonne
    ALTER TABLE clients
    ADD COLUMN date_derniere_commande TIMESTAMP;

    -- Étape 2 : Renseigner avec la dernière commande de chaque client
    UPDATE clients c
    SET date_derniere_commande = (
        SELECT MAX(date_commande)
        FROM commandes co
        WHERE co.id_client = c.id_client
    );

    -- Étape 3 : Vérification avant commit
    SELECT
        segment,
        COUNT(*) AS nb_clients,
        COUNT(date_derniere_commande) AS avec_commande,
        MAX(date_derniere_commande) AS plus_recente
    FROM clients
    GROUP BY segment;

    -- Si cohérent :
    COMMIT;

EXERCICE 44-3 (Avancé)
Implémentez une transaction "passer commande" complète avec vérification
du stock disponible avant décrément. Si le stock est insuffisant pour
n'importe quel produit, annuler toute la transaction.

SOLUTION :
    BEGIN;

    -- Vérification du stock pour tous les produits du panier
    -- (On lève une erreur explicite si stock insuffisant)
    DO $$
    DECLARE
        v_stock_actuel INTEGER;
        v_stock_requis INTEGER := 3;  -- Quantité souhaitée
        v_produit_id   INTEGER := 4;  -- iPhone 15 Pro
    BEGIN
        SELECT stock INTO v_stock_actuel
        FROM produits
        WHERE id_produit = v_produit_id
        FOR UPDATE;  -- Verrou pour éviter les race conditions

        IF v_stock_actuel < v_stock_requis THEN
            RAISE EXCEPTION 'Stock insuffisant : % disponible(s), % requis',
                v_stock_actuel, v_stock_requis;
        END IF;
    END $$;

    -- Si on arrive ici, le stock est suffisant
    INSERT INTO commandes (numero_commande, id_client, statut,
                           montant_ht, montant_tva, montant_ttc)
    VALUES ('CMD-2024-013', 3, 'CONFIRMEE', 2497.08, 499.42, 2996.50);

    INSERT INTO lignes_commande (id_commande, id_produit, quantite,
                                  prix_unitaire_ht, taux_tva)
    VALUES (currval('commandes_id_commande_seq'), 4, 3, 999.17, 20.00);

    UPDATE produits SET stock = stock - 3 WHERE id_produit = 4;

    COMMIT;


================================================================================
CHAPITRE 45 : ROLLBACK ET SAVEPOINT — ANNULER ET POINTS DE SAUVEGARDE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.1 ROLLBACK — ANNULER TOUTE LA TRANSACTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ROLLBACK annule toutes les modifications faites depuis BEGIN.
Après un ROLLBACK, la base est dans l'état exact où elle était avant BEGIN.

Syntaxes équivalentes :

    ROLLBACK;
    ROLLBACK WORK;
    ABORT;         -- Alias PostgreSQL (moins courant)

Scénario typique — Import de données avec vérification :

    BEGIN;

    -- Importer les nouvelles données
    INSERT INTO produits (reference, nom, prix_ht, stock, id_categorie, id_fournisseur)
    VALUES
        ('NEW-001', 'Nouveau Produit A', 49.99, 100, 2, 1),
        ('NEW-002', 'Nouveau Produit B', 89.99, 50,  3, 2),
        ('NEW-003', 'Nouveau Produit C', 149.99, 75, 4, 1);

    -- Vérification avant de valider
    SELECT COUNT(*), AVG(prix_ht) FROM produits WHERE reference LIKE 'NEW-%';

    -- Si les données semblent correctes
    -- COMMIT;

    -- Si quelque chose semble bizarre
    ROLLBACK;  -- Remet la base dans l'état d'avant
    -- Les 3 INSERT sont comme s'ils n'avaient jamais eu lieu

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.2 SAVEPOINT — POINTS DE SAUVEGARDE INTERMÉDIAIRES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les SAVEPOINTs permettent d'annuler une partie d'une transaction sans annuler
la totalité. Imaginez des "checkpoints" dans un jeu vidéo.

Syntaxe :

    SAVEPOINT nom_point;                    -- Créer un point de sauvegarde
    ROLLBACK TO SAVEPOINT nom_point;        -- Revenir à ce point (les opérations après sont annulées)
    ROLLBACK TO nom_point;                  -- Raccourci
    RELEASE SAVEPOINT nom_point;            -- Supprimer le savepoint (libère de la mémoire)

Exemple — Import d'un fichier CSV avec erreurs partielles :

    BEGIN;

    -- Import ligne par ligne avec gestion des erreurs
    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('CLI-0011', 'Alice', 'Blanc', 'alice.blanc@email.fr', 'STANDARD');
    -- OK -> pas besoin de savepoint encore

    SAVEPOINT apres_client_1;

    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('CLI-0012', 'Bob', NULL, 'bob@email.fr', 'STANDARD');  -- NOM NULL !
    -- ERREUR : violation NOT NULL

    -- Revenir au savepoint (annule uniquement la tentative d'insert de Bob)
    ROLLBACK TO SAVEPOINT apres_client_1;

    SAVEPOINT apres_client_2;

    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('CLI-0013', 'Claire', 'Noir', 'claire.noir@email.fr', 'VIP');
    -- OK

    COMMIT;
    -- Résultat : Alice (CLI-0011) et Claire (CLI-0013) insérées.
    --            Bob (CLI-0012) ignoré (erreur gérée par SAVEPOINT).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.3 SAVEPOINTS IMBRIQUÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

On peut créer plusieurs SAVEPOINTs imbriqués :

    BEGIN;

    INSERT INTO commandes (...) VALUES (...);  -- Commande parente
    SAVEPOINT commande_creee;

        INSERT INTO lignes_commande (...) VALUES (...);  -- Ligne 1
        SAVEPOINT ligne_1_ajoutee;

            UPDATE produits SET stock = stock - 1 WHERE id_produit = 1;
            -- Si le stock devient négatif :
            -- ROLLBACK TO ligne_1_ajoutee;
            -- La ligne 1 est annulée, la commande parente reste

        RELEASE SAVEPOINT ligne_1_ajoutee;  -- Si tout est OK

        INSERT INTO lignes_commande (...) VALUES (...);  -- Ligne 2
        -- ...

    COMMIT;

Règle importante : ROLLBACK TO SAVEPOINT ne supprime pas le savepoint.
On peut revenir plusieurs fois au même savepoint.
RELEASE SAVEPOINT supprime le savepoint (mais conserve les modifications).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.4 TRANSACTIONS ET VERROUS (LOCKING)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les transactions acquièrent des verrous pour garantir l'isolation. Deux types
principaux :

VERROUS PARTAGÉS (Shared Locks - S) :
  Acquis par les SELECT (dans certains niveaux d'isolation).
  Plusieurs transactions peuvent tenir un verrou partagé simultanément.
  Incompatible avec les verrous exclusifs.

VERROUS EXCLUSIFS (Exclusive Locks - X) :
  Acquis par INSERT, UPDATE, DELETE.
  Une seule transaction peut tenir un verrou exclusif.
  Incompatible avec tous les autres verrous.

SELECT FOR UPDATE — Verrouiller des lignes pour modification :

    BEGIN;

    -- Verrouiller les lignes sélectionnées pour empêcher les modifications concurrent
    SELECT id_produit, stock
    FROM produits
    WHERE id_produit IN (1, 4, 7)
    FOR UPDATE;  -- Les autres transactions ne peuvent pas modifier ces lignes

    -- Effectuer les mises à jour en sachant que le stock ne changera pas
    UPDATE produits SET stock = stock - 1 WHERE id_produit = 1;
    UPDATE produits SET stock = stock - 2 WHERE id_produit = 4;

    COMMIT;
    -- Les verrous sont libérés

Variantes de FOR UPDATE :
  FOR UPDATE            : verrou exclusif, bloque les autres SELECT FOR UPDATE
  FOR SHARE             : verrou partagé, bloque les UPDATE/DELETE
  FOR NO KEY UPDATE     : comme FOR UPDATE mais permet les FK checks
  FOR UPDATE NOWAIT     : échoue immédiatement si le verrou est pris
  FOR UPDATE SKIP LOCKED : saute les lignes verrouillées (utile pour les queues)

Exemple de file de travail avec SKIP LOCKED :

    -- Worker 1 prend les commandes en attente sans bloquer Worker 2
    BEGIN;
    SELECT id_commande, id_client, montant_ttc
    FROM commandes
    WHERE statut = 'EN_ATTENTE'
    ORDER BY date_commande
    LIMIT 10
    FOR UPDATE SKIP LOCKED;  -- Prend les 10 premières non verrouillées
    -- Worker 2 peut simultanément prendre les 10 suivantes
    COMMIT;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.5 DEADLOCKS — QUAND LES TRANSACTIONS SE BLOQUENT MUTUELLEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un deadlock (interblocage) se produit quand deux transactions s'attendent
mutuellement, chacune tenant un verrou que l'autre veut acquérir.

    -- Transaction A                -- Transaction B (simultanée)
    BEGIN;                          BEGIN;
    UPDATE produits                 UPDATE clients
    SET stock = stock - 1           SET segment = 'VIP'
    WHERE id_produit = 1;           WHERE id_client = 1;
    -- A tient verrou sur produit 1    -- B tient verrou sur client 1

    UPDATE clients                  UPDATE produits
    SET segment = 'VIP'             SET stock = stock - 1
    WHERE id_client = 1;            WHERE id_produit = 1;
    -- A attend B (client 1)           -- B attend A (produit 1)
    -- DEADLOCK !

PostgreSQL détecte automatiquement les deadlocks et annule l'une des
transactions avec : ERROR: deadlock detected.

Comment éviter les deadlocks :
  1. Toujours accéder aux ressources dans le même ordre :
     -> Toujours verrouiller produits AVANT clients (jamais l'inverse)

  2. Garder les transactions courtes (moins de temps = moins de risque)

  3. Utiliser des index pour que les verrous soient sur des lignes précises
     (pas sur des plages)

  4. Augmenter le niveau d'isolation (SERIALIZABLE détecte les conflits
     plus tôt et évite certains deadlocks)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.6 BONNES PRATIQUES TRANSACTIONNELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. DURÉE MINIMALE : garder les transactions aussi courtes que possible.
   Ne pas faire d'appels API, d'IO disque, d'opérations lentes DANS une transaction.

2. VÉRIFIER AVANT D'AGIR : ne pas ouvrir une transaction pour "voir ce qui se passe".
   Préparer les données en dehors, puis ouvrir la transaction et agir vite.

3. TOUJOURS GÉRER LES ERREURS : dans les applications, envelopper COMMIT
   dans un try/except et faire ROLLBACK en cas d'erreur.

    -- Python (psycopg2)
    try:
        with conn:  # conn gère automatiquement BEGIN/COMMIT/ROLLBACK
            with conn.cursor() as cur:
                cur.execute("INSERT INTO clients ...")
                cur.execute("INSERT INTO commandes ...")
    except Exception as e:
        conn.rollback()
        raise

4. TRANSACTIONS LONGUES : surveiller pg_stat_activity pour détecter
   les transactions ouvertes depuis trop longtemps (idle in transaction).

    SELECT pid, state, query_start,
           NOW() - query_start AS duree,
           query
    FROM pg_stat_activity
    WHERE state = 'idle in transaction'
      AND NOW() - query_start > INTERVAL '5 minutes';

5. LOCK TIMEOUT : configurer un timeout pour éviter d'attendre indéfiniment
   un verrou.

    SET lock_timeout = '5s';  -- Échoue après 5 secondes si verrou non obtenu
    BEGIN;
    SELECT * FROM produits WHERE id_produit = 1 FOR UPDATE;
    -- Si le verrou n'est pas obtenu en 5s -> ERROR

6. IDLE IN TRANSACTION TIMEOUT : terminer automatiquement les transactions
   ouvertes trop longtemps sans activité.

    SET idle_in_transaction_session_timeout = '60s';  -- dans postgresql.conf

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45.7 EXERCICES ROLLBACK ET SAVEPOINT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 45-1 (Facile)
Démontrez le comportement ROLLBACK : insérez 3 clients dans une transaction,
puis annulez. Vérifiez que les clients n'existent plus.

SOLUTION :
    -- Compter avant
    SELECT COUNT(*) FROM clients;  -- Ex: 10

    BEGIN;
    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('TEST-001', 'Test1', 'Test1', 'test1@test.fr', 'STANDARD');
    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('TEST-002', 'Test2', 'Test2', 'test2@test.fr', 'STANDARD');
    INSERT INTO clients (code_client, prenom, nom, email, segment)
    VALUES ('TEST-003', 'Test3', 'Test3', 'test3@test.fr', 'STANDARD');

    -- À l'intérieur de la transaction, on voit 13 clients
    SELECT COUNT(*) FROM clients;  -- 13

    ROLLBACK;

    -- Après ROLLBACK, on revient à 10 clients
    SELECT COUNT(*) FROM clients;  -- 10 (comme avant)

EXERCICE 45-2 (Moyen)
Utilisez les SAVEPOINTs pour importer une liste de produits où certains peuvent
avoir des erreurs de données (prix négatif, stock NULL). Les produits valides
doivent être insérés, les invalides ignorés.

SOLUTION :
    BEGIN;

    -- Produit 1 : valide
    SAVEPOINT avant_produit_1;
    INSERT INTO produits (reference, nom, prix_ht, stock, id_categorie, id_fournisseur)
    VALUES ('IMPORT-001', 'Produit Import A', 29.99, 100, 10, 6);
    -- Supposons qu'il passe -> OK
    SAVEPOINT apres_produit_1;

    -- Produit 2 : prix invalide (CHECK prix_ht >= 0)
    SAVEPOINT avant_produit_2;
    -- Cette insertion va échouer :
    -- INSERT INTO produits (reference, nom, prix_ht, stock, id_categorie, id_fournisseur)
    -- VALUES ('IMPORT-002', 'Produit Invalide', -5.00, 50, 10, 6);
    -- Après l'échec, on revient au savepoint :
    ROLLBACK TO SAVEPOINT avant_produit_2;

    -- Produit 3 : valide
    SAVEPOINT avant_produit_3;
    INSERT INTO produits (reference, nom, prix_ht, stock, id_categorie, id_fournisseur)
    VALUES ('IMPORT-003', 'Produit Import C', 49.99, 200, 10, 6);
    SAVEPOINT apres_produit_3;

    COMMIT;
    -- Résultat : IMPORT-001 et IMPORT-003 insérés, IMPORT-002 ignoré

EXERCICE 45-3 (Avancé)
Implémentez une opération "transfert de stock" entre deux entrepôts :
décrémenter le stock dans l'entrepôt source, incrémenter dans l'entrepôt
destination. Utiliser FOR UPDATE pour éviter les race conditions.
Gérer le cas où l'entrepôt source n'a pas assez de stock.

SOLUTION :
    DO $$
    DECLARE
        v_id_produit    INTEGER := 1;        -- MacBook Pro
        v_id_entrepot_src INTEGER := 1;      -- Entrepôt source
        v_id_entrepot_dst INTEGER := 2;      -- Entrepôt destination
        v_quantite      INTEGER := 5;        -- Quantité à transférer
        v_stock_src     INTEGER;
    BEGIN
        -- Démarrer la transaction
        -- (On est dans un bloc PL/pgSQL, la transaction est gérée implicitement)

        -- Verrouiller les deux stocks dans l'ordre d'ID pour éviter les deadlocks
        SELECT quantite INTO v_stock_src
        FROM stocks_entrepot
        WHERE id_entrepot = LEAST(v_id_entrepot_src, v_id_entrepot_dst)
          AND id_produit = v_id_produit
        FOR UPDATE;

        SELECT quantite INTO v_stock_src
        FROM stocks_entrepot
        WHERE id_entrepot = GREATEST(v_id_entrepot_src, v_id_entrepot_dst)
          AND id_produit = v_id_produit
        FOR UPDATE;

        -- Récupérer le stock source
        SELECT quantite INTO v_stock_src
        FROM stocks_entrepot
        WHERE id_entrepot = v_id_entrepot_src
          AND id_produit = v_id_produit;

        -- Vérifier le stock
        IF v_stock_src IS NULL THEN
            RAISE EXCEPTION 'Produit absent de l''entrepôt source';
        END IF;

        IF v_stock_src < v_quantite THEN
            RAISE EXCEPTION 'Stock insuffisant : % disponible(s), % demandés',
                v_stock_src, v_quantite;
        END IF;

        -- Décrémenter source
        UPDATE stocks_entrepot
        SET quantite = quantite - v_quantite
        WHERE id_entrepot = v_id_entrepot_src AND id_produit = v_id_produit;

        -- Incrémenter destination (créer si n'existe pas)
        INSERT INTO stocks_entrepot (id_entrepot, id_produit, quantite)
        VALUES (v_id_entrepot_dst, v_id_produit, v_quantite)
        ON CONFLICT (id_entrepot, id_produit)
        DO UPDATE SET quantite = stocks_entrepot.quantite + EXCLUDED.quantite;

        RAISE NOTICE 'Transfert réussi : % unités du produit % de l''entrepôt % vers %',
            v_quantite, v_id_produit, v_id_entrepot_src, v_id_entrepot_dst;
    END $$;


================================================================================
SYNTHÈSE DE LA PARTIE 11 — TRANSACTIONS
================================================================================

TABLEAU RÉCAPITULATIF DES COMMANDES
──────────────────────────────────────
  Commande                         | Description
  ─────────────────────────────────┼──────────────────────────────────────────
  BEGIN / START TRANSACTION        | Démarrer une transaction
  COMMIT / COMMIT WORK             | Valider toutes les modifications
  ROLLBACK / ABORT                 | Annuler toutes les modifications
  SAVEPOINT nom                    | Créer un point de sauvegarde
  ROLLBACK TO SAVEPOINT nom        | Revenir à un savepoint
  RELEASE SAVEPOINT nom            | Supprimer un savepoint
  SELECT ... FOR UPDATE            | Verrouiller des lignes pour modification
  SELECT ... FOR UPDATE NOWAIT     | Verrouiller ou échouer immédiatement
  SELECT ... FOR UPDATE SKIP LOCKED| Verrouiller en ignorant les lignes prises

NIVEAUX D'ISOLATION
────────────────────
  READ COMMITTED  -> défaut PostgreSQL, protège contre Dirty Reads
  REPEATABLE READ -> vue stable, protège aussi contre Non-Repeatable Reads
  SERIALIZABLE    -> garantie maximale, comme une exécution séquentielle

RÈGLES D'OR
────────────
  1. Toujours envelopper les opérations multi-tables dans une transaction
  2. Garder les transactions aussi courtes que possible
  3. Toujours gérer les erreurs et faire ROLLBACK en cas d'exception
  4. Utiliser FOR UPDATE pour éviter les race conditions sur le stock
  5. Accéder aux ressources toujours dans le même ordre pour éviter les deadlocks
  6. Utiliser SAVEPOINTs pour les opérations partiellement tolérantes aux erreurs
  7. En PostgreSQL, les DDL (ALTER TABLE...) sont aussi transactionnels

================================================================================
FIN DE LA PARTIE 11 — TRANSACTIONS
================================================================================
Chapitres couverts : 43 (BEGIN), 44 (COMMIT), 45 (ROLLBACK + SAVEPOINT)
Prochaine partie : PARTIE 12 — OPTIMISATION AVANCÉE
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 12
OPTIMISATION AVANCÉE DES REQUÊTES
Chapitres 46 à 48
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Avancé
================================================================================

TABLE DES MATIÈRES — PARTIE 12
════════════════════════════════
  Chapitre 46 : Query Optimization — Principes et stratégies
  Chapitre 47 : Explain Plan — Lire et interpréter les plans (approfondissement)
  Chapitre 48 : Indexing avancé — Stratégies pour les tables à fort volume


================================================================================
CHAPITRE 46 : QUERY OPTIMIZATION — PRINCIPES ET STRATÉGIES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.1 POURQUOI L'OPTIMISATION EST CRITIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En développement (table de 100 lignes) : toutes les requêtes semblent rapides.
En production (table de 10 millions de lignes) : une requête mal écrite peut
bloquer un serveur pendant des minutes et rendre l'application inutilisable.

Impact d'une requête non optimisée :
  - Sequential Scan sur 10M lignes : 5-30 secondes
  - Même requête avec bon index  : 1-5 millisecondes
  - Facteur d'amélioration       : ×5000 à ×30000

Un ingénieur logiciel compétent doit comprendre POURQUOI une requête est lente
et COMMENT la corriger — pas juste copier des solutions.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.2 L'OPTIMISEUR DE REQUÊTES POSTGRESQL — FONCTIONNEMENT INTERNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand vous soumettez une requête, PostgreSQL exécute ce pipeline :

    ┌─────────────────────────────────────────────────────────────────────┐
    │ REQUÊTE SQL (texte)                                                  │
    └────────────────────────────────────┬────────────────────────────────┘
                                         v
    ┌─────────────────────────────────────────────────────────────────────┐
    │ PARSER — Analyse syntaxique                                          │
    │  -> Vérifie la syntaxe SQL                                           │
    │  -> Construit l'AST (Abstract Syntax Tree)                           │
    │  -> Résout les noms de tables et colonnes                            │
    └────────────────────────────────────┬────────────────────────────────┘
                                         v
    ┌─────────────────────────────────────────────────────────────────────┐
    │ REWRITER — Réécriture de requête                                     │
    │  -> Étend les vues (remplace la vue par sa définition)               │
    │  -> Applique les règles (rules) définies sur les tables              │
    └────────────────────────────────────┬────────────────────────────────┘
                                         v
    ┌─────────────────────────────────────────────────────────────────────┐
    │ PLANNER / OPTIMIZER — Choix du plan optimal                          │
    │                                                                      │
    │  Entrées :                                                           │
    │  ├── Statistiques (pg_statistic) : distribution des données         │
    │  ├── Index disponibles sur les tables                                │
    │  ├── Paramètres de configuration (work_mem, random_page_cost...)    │
    │  └── Cardinalités estimées (nombre de lignes par table)              │
    │                                                                      │
    │  Processus :                                                         │
    │  ├── Génère tous les plans possibles (ordre des joins, méthodes...)  │
    │  ├── Estime le coût de chaque plan                                   │
    │  └── Choisit le plan au coût minimal estimé                          │
    └────────────────────────────────────┬────────────────────────────────┘
                                         v
    ┌─────────────────────────────────────────────────────────────────────┐
    │ EXECUTOR — Exécution du plan                                         │
    │  -> Lit les données (index scan, seq scan...)                        │
    │  -> Applique les opérations (jointures, agrégations, tris...)        │
    │  -> Retourne le résultat                                             │
    └─────────────────────────────────────────────────────────────────────┘

Paramètres clés de l'optimiseur :

    -- Coût relatif d'une lecture aléatoire (défaut: 4.0)
    -- Si vos données sont souvent en cache RAM : réduire à 1.0-2.0
    SHOW random_page_cost;
    SET random_page_cost = 1.5;  -- Pour SSD avec beaucoup de RAM

    -- Coût relatif d'une lecture séquentielle (défaut: 1.0, la base)
    SHOW seq_page_cost;

    -- Mémoire disponible pour les opérations de tri/hachage (défaut: 4MB)
    SHOW work_mem;
    SET work_mem = '256MB';  -- Plus de mémoire -> tris en RAM (rapides)

    -- Mémoire partagée pour le cache de pages (défaut: 128MB)
    SHOW shared_buffers;
    -- Recommandation : 25% de la RAM disponible

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.3 LES STATISTIQUES — LE CARBURANT DE L'OPTIMISEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'optimiseur prend ses décisions en se basant sur les STATISTIQUES stockées
dans pg_statistic. Ces statistiques comprennent :
  - Le nombre de lignes estimé (reltuples)
  - La largeur moyenne d'une ligne (relpages)
  - Les valeurs les plus fréquentes et leur fréquence
  - L'histogramme de distribution des valeurs
  - La cardinalité estimée (nb de valeurs distinctes)

Si les statistiques sont périmées -> mauvais plan -> requête lente.

Voir les statistiques d'une table :

    SELECT
        tablename,
        attname                 AS colonne,
        n_distinct,             -- nb valeurs distinctes (-1 = cardinalité variable)
        correlation,            -- corrélation physique avec l'ordre des pages (1 = parfait)
        most_common_vals,       -- valeurs les plus fréquentes
        most_common_freqs       -- fréquences correspondantes
    FROM pg_stats
    WHERE tablename = 'commandes'
    ORDER BY attname;

Mettre à jour les statistiques :

    ANALYZE;                   -- Toutes les tables de la base
    ANALYZE commandes;         -- Table spécifique
    ANALYZE clients (segment); -- Colonne spécifique

    -- Le démon autovacuum met à jour les statistiques automatiquement,
    -- mais après un chargement massif de données, forcer ANALYZE manuellement.

Augmenter la précision des statistiques pour une colonne :

    -- La cible par défaut est 100 (buckets d'histogramme)
    -- Pour les colonnes hautement sélectives :
    ALTER TABLE commandes
    ALTER COLUMN date_commande SET STATISTICS 500;
    -- Puis ANALYZE pour recalculer avec la nouvelle précision
    ANALYZE commandes (date_commande);

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.4 STRATÉGIES D'OPTIMISATION — LES 10 RÈGLES D'OR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLE 1 : Sélectionner uniquement les colonnes nécessaires
───────────────────────────────────────────────────────────
    -- [X] Transfère tout (réseau, mémoire inutile)
    SELECT * FROM commandes WHERE id_client = 1;

    -- [OK] Ne transfère que ce dont on a besoin
    SELECT id_commande, date_commande, statut, montant_ttc
    FROM commandes
    WHERE id_client = 1;

RÈGLE 2 : Filtrer tôt avec WHERE (réduire le dataset avant toute opération)
────────────────────────────────────────────────────────────────────────────
    -- [X] Jointure sur toute la table, filtre après
    SELECT cl.nom, co.montant_ttc
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    WHERE cl.segment = 'VIP';

    -- [OK] Si possible, filtrer avant la jointure avec un CTE ou sous-requête
    -- (Dans ce cas précis, l'optimiseur le fait souvent automatiquement,
    -- mais dans des requêtes complexes, structurer explicitement aide)
    WITH vip_clients AS (
        SELECT id_client FROM clients WHERE segment = 'VIP'
    )
    SELECT cl.nom, co.montant_ttc
    FROM vip_clients vc
    JOIN clients cl ON vc.id_client = cl.id_client
    JOIN commandes co ON cl.id_client = co.id_client;

RÈGLE 3 : Indexer les colonnes de jointure et de filtre
────────────────────────────────────────────────────────
    -- Colonnes typiquement à indexer :
    -- • Colonnes dans WHERE : WHERE statut = 'LIVREE'
    -- • Colonnes de FK (JOINs) : ON co.id_client = cl.id_client
    -- • Colonnes dans ORDER BY : ORDER BY date_commande
    -- • Colonnes dans GROUP BY très fréquents

RÈGLE 4 : Éviter les fonctions sur les colonnes indexées dans WHERE
──────────────────────────────────────────────────────────────────
    -- [X] Invalide l'index sur date_commande
    WHERE EXTRACT(YEAR FROM date_commande) = 2024
    WHERE DATE(date_commande) = '2024-01-15'
    WHERE UPPER(nom) = 'DUPONT'

    -- [OK] Utiliser des plages / index d'expression
    WHERE date_commande >= '2024-01-01' AND date_commande < '2025-01-01'
    WHERE nom = 'Dupont'  -- ou créer un index sur UPPER(nom)

RÈGLE 5 : Éviter les conversions de type implicites
────────────────────────────────────────────────────
    -- [X] id_client est INTEGER, '5' est TEXT -> conversion implicite -> index ignoré
    WHERE id_client = '5'

    -- [OK] Utiliser le bon type
    WHERE id_client = 5

RÈGLE 6 : Utiliser EXISTS plutôt que IN sur les grandes sous-requêtes
──────────────────────────────────────────────────────────────────────
    -- Pour les très grandes listes, EXISTS court-circuite (s'arrête à la première)
    -- IN évalue toute la liste
    -- L'optimiseur moderne gère souvent bien les deux, mais EXISTS reste idiomatique

    -- Préférer EXISTS pour les grandes tables :
    SELECT nom FROM clients cl
    WHERE EXISTS (
        SELECT 1 FROM commandes co
        WHERE co.id_client = cl.id_client
          AND co.statut = 'LIVREE'
    );

RÈGLE 7 : Limiter les résultats quand on ne veut pas tout
─────────────────────────────────────────────────────────
    -- Toujours utiliser LIMIT pour les requêtes exploratoires
    SELECT * FROM lignes_commande
    ORDER BY id_ligne
    LIMIT 100;

RÈGLE 8 : Réécrire les sous-requêtes corrélées en JOIN quand possible
──────────────────────────────────────────────────────────────────────
    -- [X] Sous-requête corrélée : s'exécute pour chaque ligne (N fois)
    SELECT p.nom,
           (SELECT SUM(lc.quantite)
            FROM lignes_commande lc
            WHERE lc.id_produit = p.id_produit) AS total_vendu
    FROM produits p;

    -- [OK] JOIN + GROUP BY : une seule passe sur la table
    SELECT p.nom, COALESCE(SUM(lc.quantite), 0) AS total_vendu
    FROM produits p
    LEFT JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    GROUP BY p.id_produit, p.nom;

RÈGLE 9 : Utiliser des CTEs pour clarifier et potentiellement optimiser
────────────────────────────────────────────────────────────────────────
    -- Les CTEs (WITH ...) clarifient la logique
    -- Dans PostgreSQL >= 12, les CTEs non récursives sont inlinées par défaut
    -- (traitées comme des sous-requêtes -> l'optimiseur peut pousser les filtres)
    WITH commandes_2024 AS (
        SELECT * FROM commandes
        WHERE date_commande >= '2024-01-01'
    )
    SELECT id_client, COUNT(*) FROM commandes_2024
    GROUP BY id_client;

RÈGLE 10 : Surveiller et mesurer (ne pas optimiser à l'aveugle)
────────────────────────────────────────────────────────────────
    -- Toujours mesurer AVANT et APRÈS une optimisation
    -- Utiliser EXPLAIN ANALYZE
    -- Utiliser pg_stat_statements pour identifier les requêtes lentes
    -- Profiler en production (pas en dev où les tables sont vides)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.5 REQUÊTES LENTES DANS SHOPFLOW — CAS PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CAS 1 : Rapport journalier lent (GROUP BY sur grande table)

    -- Version lente :
    SELECT
        DATE(date_commande) AS jour,
        COUNT(*) AS nb_commandes,
        SUM(montant_ttc) AS ca
    FROM commandes
    GROUP BY DATE(date_commande)
    ORDER BY jour DESC;
    -- Problème : DATE() sur date_commande invalide l'index

    -- Version optimisée :
    SELECT
        date_commande::DATE AS jour,  -- cast plus rapide que DATE()
        COUNT(*) AS nb_commandes,
        SUM(montant_ttc) AS ca
    FROM commandes
    GROUP BY date_commande::DATE
    ORDER BY jour DESC;
    -- Encore mieux : vue matérialisée actualisée chaque nuit

CAS 2 : Recherche de produits avec filtre texte lent

    -- Version lente (full scan obligatoire) :
    SELECT * FROM produits WHERE nom ILIKE '%usb%';

    -- Version optimisée (extension pg_trgm) :
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    CREATE INDEX idx_produits_nom_trgm ON produits USING gin(nom gin_trgm_ops);
    -- Maintenant la même requête utilise l'index GIN

CAS 3 : Dashboard lent (calculs agrégés à la volée)

    -- Version lente (recalcule tout à chaque affichage) :
    SELECT
        p.nom, cat.nom AS categorie,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS ca
    FROM produits p
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    JOIN commandes co ON lc.id_commande = co.id_commande
    WHERE co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY p.id_produit, p.nom, cat.nom;

    -- Version optimisée (vue matérialisée) :
    CREATE MATERIALIZED VIEW mv_stats_ventes AS
    SELECT
        p.id_produit,
        p.nom AS produit,
        cat.nom AS categorie,
        SUM(lc.quantite) AS quantite_vendue,
        SUM(lc.quantite * lc.prix_unitaire_ht) AS chiffre_affaires,
        COUNT(DISTINCT co.id_commande) AS nb_commandes,
        ROUND(AVG(a.note), 2) AS note_moyenne
    FROM produits p
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    LEFT JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    LEFT JOIN commandes co ON lc.id_commande = co.id_commande
        AND co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    LEFT JOIN avis a ON p.id_produit = a.id_produit
    GROUP BY p.id_produit, p.nom, cat.nom
    WITH DATA;

    CREATE UNIQUE INDEX ON mv_stats_ventes (id_produit);
    CREATE INDEX ON mv_stats_ventes (chiffre_affaires DESC);

    -- Dashboard utilise la vue matérialisée (lecture en millisecondes)
    SELECT produit, categorie, chiffre_affaires, note_moyenne
    FROM mv_stats_ventes
    ORDER BY chiffre_affaires DESC
    LIMIT 20;

    -- Actualisation planifiée (ex: chaque heure)
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_stats_ventes;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46.6 EXERCICES QUERY OPTIMIZATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 46-1 (Facile)
Identifiez et corrigez le problème dans cette requête :
    SELECT * FROM commandes WHERE UPPER(statut) = 'LIVREE';

SOLUTION :
    -- Problème : UPPER(statut) invalide tout index sur statut.
    -- Solution 1 : stocker les données en cohérence (toujours majuscules)
    SELECT * FROM commandes WHERE statut = 'LIVREE';
    -- Solution 2 : créer un index fonctionnel
    CREATE INDEX idx_commandes_statut_upper ON commandes (UPPER(statut));
    -- Alors la requête originale utilise l'index.

EXERCICE 46-2 (Moyen)
Réécrire cette requête pour éviter la sous-requête corrélée :
    SELECT p.nom,
           (SELECT COUNT(*) FROM avis a WHERE a.id_produit = p.id_produit) AS nb_avis,
           (SELECT AVG(note) FROM avis a WHERE a.id_produit = p.id_produit) AS note_moy
    FROM produits p WHERE p.actif = TRUE;

SOLUTION :
    SELECT
        p.nom,
        COUNT(a.id_avis)            AS nb_avis,
        ROUND(AVG(a.note), 2)       AS note_moy
    FROM produits p
    LEFT JOIN avis a ON p.id_produit = a.id_produit
    WHERE p.actif = TRUE
    GROUP BY p.id_produit, p.nom
    ORDER BY nb_avis DESC;
    -- Une seule passe sur avis au lieu de 2 passes par ligne de produits

EXERCICE 46-3 (Avancé)
La requête suivante est utilisée dans un dashboard critique.
Proposez une stratégie complète d'optimisation (index, vue matérialisée,
réécriture) en justifiant chaque choix :
    SELECT cl.segment, cat.nom, SUM(lc.quantite * lc.prix_unitaire_ht),
           COUNT(DISTINCT co.id_client), ROUND(AVG(a.note), 2)
    FROM commandes co
    JOIN clients cl ON co.id_client = cl.id_client
    JOIN lignes_commande lc ON co.id_commande = lc.id_commande
    JOIN produits p ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    LEFT JOIN avis a ON p.id_produit = a.id_produit
    WHERE co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND co.date_commande >= '2024-01-01'
    GROUP BY cl.segment, cat.nom
    ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC;

SOLUTION :
    -- Étape 1 : Index manquants
    CREATE INDEX IF NOT EXISTS idx_commandes_statut_date
        ON commandes (statut, date_commande)
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE');

    -- Étape 2 : Vue matérialisée (dashboard non temps-réel)
    CREATE MATERIALIZED VIEW mv_dashboard_segment_categorie AS
    SELECT
        cl.segment,
        cat.nom                                         AS categorie,
        SUM(lc.quantite * lc.prix_unitaire_ht)          AS ca,
        COUNT(DISTINCT co.id_client)                    AS nb_clients,
        ROUND(AVG(a.note), 2)                           AS note_moy,
        MAX(co.date_commande)                           AS derniere_commande
    FROM commandes co
    JOIN clients cl          ON co.id_client    = cl.id_client
    JOIN lignes_commande lc  ON co.id_commande  = lc.id_commande
    JOIN produits p          ON lc.id_produit   = p.id_produit
    JOIN categories cat      ON p.id_categorie  = cat.id_categorie
    LEFT JOIN avis a         ON p.id_produit    = a.id_produit
    WHERE co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND co.date_commande >= '2024-01-01'
    GROUP BY cl.segment, cat.nom
    WITH DATA;

    CREATE UNIQUE INDEX ON mv_dashboard_segment_categorie (segment, categorie);
    CREATE INDEX ON mv_dashboard_segment_categorie (ca DESC);

    -- Utilisation du dashboard (instantané) :
    SELECT segment, categorie, ca, nb_clients, note_moy
    FROM mv_dashboard_segment_categorie
    ORDER BY ca DESC;


================================================================================
CHAPITRE 47 : EXPLAIN PLAN APPROFONDI
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47.1 EXPLAIN FORMAT JSON — POUR LES OUTILS D'ANALYSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le format JSON est lisible par des outils comme explain.dalibo.com.

    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
    SELECT cl.nom, SUM(co.montant_ttc)
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    WHERE co.statut = 'LIVREE'
    GROUP BY cl.id_client, cl.nom;

Outils en ligne pour visualiser les plans :
  - https://explain.dalibo.com  -> visualisation graphique
  - https://explain.depesz.com -> analyse textuelle colorée
  - pgAdmin 4 -> onglet "Query Plan" avec visualisation graphique

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47.2 EXPLAIN ANALYZE AVEC BUFFERS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'option BUFFERS montre les accès mémoire et disque :

    EXPLAIN (ANALYZE, BUFFERS)
    SELECT * FROM commandes
    WHERE date_commande >= '2024-01-01'
    AND statut = 'LIVREE';

    -- Sortie (exemple) :
    -- Index Scan using idx_commandes_statut_date on commandes
    --   (cost=0.15..8.17 rows=6 width=200)
    --   (actual time=0.025..0.032 rows=6 loops=1)
    --   Buffers: shared hit=4      <- 4 pages lues depuis le cache RAM
    --   Planning Time: 0.3 ms
    --   Execution Time: 0.1 ms

    -- Buffers: shared hit=N   -> pages lues depuis le shared_buffers (RAM)
    -- Buffers: shared read=N  -> pages lues depuis le disque (lent !)
    -- Buffers: local hit=N    -> tables temporaires en RAM
    -- Buffers: temp read=N    -> tables temporaires sur disque (très lent !)

Interprétation :
  shared hit >> shared read  -> données bien cachées en RAM -> performance optimale
  shared read >> shared hit  -> données peu cachées -> considérer augmenter shared_buffers
  temp read > 0              -> tri ou hachage sur disque -> augmenter work_mem

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47.3 PG_STAT_STATEMENTS — IDENTIFIER LES REQUÊTES LENTES EN PRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

pg_stat_statements trace toutes les requêtes exécutées avec leurs statistiques.

Installation :

    -- Dans postgresql.conf :
    shared_preload_libraries = 'pg_stat_statements'
    pg_stat_statements.max = 10000
    pg_stat_statements.track = all

    -- Dans psql :
    CREATE EXTENSION pg_stat_statements;

Requêtes d'analyse :

    -- Top 10 requêtes les plus chronophages (temps total)
    SELECT
        LEFT(query, 120)                              AS requete,
        calls                                         AS nb_executions,
        ROUND(total_exec_time::NUMERIC / 1000, 2)    AS temps_total_sec,
        ROUND(mean_exec_time::NUMERIC, 2)             AS temps_moyen_ms,
        ROUND(stddev_exec_time::NUMERIC, 2)           AS ecart_type_ms,
        rows                                          AS total_lignes_retournees,
        ROUND(rows::NUMERIC / NULLIF(calls, 0), 1)   AS lignes_par_exec
    FROM pg_stat_statements
    ORDER BY total_exec_time DESC
    LIMIT 10;

    -- Top 10 requêtes les plus lentes individuellement
    SELECT
        LEFT(query, 120)                              AS requete,
        calls                                         AS nb_executions,
        ROUND(mean_exec_time::NUMERIC, 2)             AS temps_moyen_ms,
        ROUND(max_exec_time::NUMERIC, 2)              AS temps_max_ms
    FROM pg_stat_statements
    WHERE calls > 10    -- Exclure les requêtes exécutées rarement
    ORDER BY mean_exec_time DESC
    LIMIT 10;

    -- Requêtes qui font beaucoup de lectures disque
    SELECT
        LEFT(query, 120) AS requete,
        calls,
        shared_blks_read AS pages_disque_lues,
        shared_blks_hit  AS pages_cache_hit,
        ROUND(
            shared_blks_hit::NUMERIC /
            NULLIF(shared_blks_hit + shared_blks_read, 0) * 100, 1
        )                AS taux_cache_hit_pct
    FROM pg_stat_statements
    WHERE shared_blks_read > 100
    ORDER BY shared_blks_read DESC
    LIMIT 10;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47.4 PG_STAT_USER_TABLES — SURVEILLANCE DES TABLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    -- Santé des tables : taux de sequential scans vs index scans
    SELECT
        relname                         AS table_nom,
        seq_scan                        AS nb_seq_scans,
        idx_scan                        AS nb_index_scans,
        CASE
            WHEN seq_scan + idx_scan = 0 THEN NULL
            ELSE ROUND(
                idx_scan::NUMERIC / (seq_scan + idx_scan) * 100, 1
            )
        END                             AS pct_index_scan,
        n_live_tup                      AS nb_lignes_vivantes,
        n_dead_tup                      AS nb_lignes_mortes,
        CASE
            WHEN n_live_tup = 0 THEN NULL
            ELSE ROUND(
                n_dead_tup::NUMERIC / n_live_tup * 100, 1
            )
        END                             AS pct_lignes_mortes
    FROM pg_stat_user_tables
    ORDER BY seq_scan DESC;

    -- Interprétation :
    -- pct_index_scan < 80% sur une grande table -> manque d'index potentiel
    -- pct_lignes_mortes > 20% -> VACUUM nécessaire (bloat de table)

    -- Forcer un VACUUM sur une table trop fragmentée :
    VACUUM ANALYZE commandes;
    VACUUM FULL commandes;  -- Récupère l'espace disque (mais bloque la table !)


================================================================================
CHAPITRE 48 : INDEXING AVANCÉ — STRATÉGIES POUR FORT VOLUME
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.1 PARTITIONNEMENT DE TABLE — POUR LES TRÈS GRANDES TABLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le partitionnement divise une grande table en sous-tables (partitions) plus
petites et gérables. PostgreSQL 10+ supporte le partitionnement natif.

Quand partitionner ?
  - Table avec des centaines de millions de lignes
  - Requêtes qui filtrent toujours sur la même colonne (date, région, statut)
  - Besoin de purger des données rapidement (DROP PARTITION au lieu de DELETE)
  - Besoin de déplacer des données anciennes vers un stockage moins cher

PARTITIONNEMENT PAR PLAGE (RANGE) — le plus courant pour les dates :

    -- Table parent (ne contient pas de données directement)
    CREATE TABLE commandes_partitionnees (
        id_commande     SERIAL,
        id_client       INTEGER NOT NULL,
        date_commande   TIMESTAMP NOT NULL,
        statut          VARCHAR(50),
        montant_ttc     DECIMAL(12, 2)
    ) PARTITION BY RANGE (date_commande);

    -- Créer les partitions (une par année)
    CREATE TABLE commandes_2022
    PARTITION OF commandes_partitionnees
    FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');

    CREATE TABLE commandes_2023
    PARTITION OF commandes_partitionnees
    FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');

    CREATE TABLE commandes_2024
    PARTITION OF commandes_partitionnees
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

    -- Créer des index sur chaque partition
    CREATE INDEX ON commandes_2024 (date_commande);
    CREATE INDEX ON commandes_2024 (id_client);

    -- Insérer -> PostgreSQL route automatiquement vers la bonne partition
    INSERT INTO commandes_partitionnees (id_client, date_commande, statut, montant_ttc)
    VALUES (1, '2024-03-15', 'LIVREE', 299.99);

    -- Requête avec filtre de date -> PostgreSQL ne lit que commandes_2024
    EXPLAIN SELECT * FROM commandes_partitionnees
    WHERE date_commande >= '2024-01-01';
    -- Plan : Append -> Seq Scan on commandes_2024 (commandes_2022, 2023 ignorées !)

    -- Purger les vieilles données (instantané !)
    DROP TABLE commandes_2022;  -- Supprime des millions de lignes en millisecondes

PARTITIONNEMENT PAR LISTE (LIST) — pour les valeurs discrètes :

    CREATE TABLE commandes_par_region (
        id_commande INTEGER,
        id_client   INTEGER,
        region      VARCHAR(50)
    ) PARTITION BY LIST (region);

    CREATE TABLE commandes_france
    PARTITION OF commandes_par_region
    FOR VALUES IN ('Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Toulouse');

    CREATE TABLE commandes_international
    PARTITION OF commandes_par_region
    FOR VALUES IN ('Londres', 'Berlin', 'Madrid', 'Amsterdam');

PARTITIONNEMENT PAR HACHAGE (HASH) — pour distribuer uniformément :

    CREATE TABLE commandes_hashees (
        id_commande INTEGER,
        id_client   INTEGER
    ) PARTITION BY HASH (id_client);

    -- 4 partitions de taille approximativement égale
    CREATE TABLE commandes_p0 PARTITION OF commandes_hashees
    FOR VALUES WITH (modulus 4, remainder 0);
    CREATE TABLE commandes_p1 PARTITION OF commandes_hashees
    FOR VALUES WITH (modulus 4, remainder 1);
    CREATE TABLE commandes_p2 PARTITION OF commandes_hashees
    FOR VALUES WITH (modulus 4, remainder 2);
    CREATE TABLE commandes_p3 PARTITION OF commandes_hashees
    FOR VALUES WITH (modulus 4, remainder 3);

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.2 INDEX BRIN — POUR LES TRÈS GRANDES TABLES AVEC DONNÉES CORRÉLÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

BRIN (Block Range INdex) : stocke uniquement les valeurs min/max pour chaque
bloc de pages. Très petit (quelques KB pour des tables de Go), mais utile
uniquement si les données sont corrélées physiquement avec l'ordre d'insertion.

Idéal pour : tables d'événements, logs, données temporelles INSERT-only.

    -- Pour une table de logs où date est corrélée avec l'ordre d'insertion
    CREATE INDEX idx_logs_date_brin ON logs_application USING BRIN (date_log);

    -- Taille comparée :
    -- Index B-tree sur 100M lignes : ~2-3 GB
    -- Index BRIN sur 100M lignes   : ~100-200 KB (×10000 plus petit !)

    -- Quand utiliser BRIN (toutes conditions nécessaires) :
    -- 1. Table très grande (100M+ lignes)
    -- 2. Données corrélées avec l'ordre d'insertion (correlation proche de 1)
    -- 3. Requêtes avec filtre de plage (BETWEEN, >=, <=)
    -- 4. La perte de précision (quelques faux positifs filtrés après) est acceptable

    -- Vérifier la corrélation d'une colonne :
    SELECT attname, correlation
    FROM pg_stats
    WHERE tablename = 'commandes'
    ORDER BY ABS(correlation) DESC;
    -- correlation proche de 1 ou -1 -> BRIN peut être utile
    -- correlation proche de 0 -> BRIN sera inefficace

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.3 INDEX GIN AVEC PG_TRGM — RECHERCHE FULL-TEXT RAPIDE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

pg_trgm décompose les chaînes en trigrammes (groupes de 3 caractères).
Un index GIN sur ces trigrammes permet des LIKE '%...%' rapides.

    CREATE EXTENSION IF NOT EXISTS pg_trgm;

    -- Index sur le nom des produits pour les recherches en %...%
    CREATE INDEX idx_produits_nom_trgm
    ON produits USING gin(nom gin_trgm_ops);

    -- Maintenant ces requêtes utilisent l'index :
    SELECT nom, prix_ht FROM produits WHERE nom ILIKE '%pro%';
    SELECT nom, prix_ht FROM produits WHERE nom ILIKE '%usb%';
    SELECT nom, prix_ht FROM produits WHERE nom % 'MacBok';  -- similarité floue !

    -- La similarité floue (%) trouve les noms similaires (utile pour les typos) :
    SELECT nom, similarity(nom, 'MacBok') AS sim
    FROM produits
    WHERE nom % 'MacBok'     -- seuil de similarité configurable
    ORDER BY sim DESC;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.4 FULL TEXT SEARCH — RECHERCHE SÉMANTIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pour des recherches de type "moteur de recherche" (trouver des mots dans
une description longue), PostgreSQL dispose d'un Full Text Search natif.

    -- Ajouter une colonne de vecteur de recherche sur les produits
    ALTER TABLE produits ADD COLUMN tsv_description TSVECTOR;

    -- Remplir le vecteur (combinaison nom + description, avec poids)
    UPDATE produits
    SET tsv_description = (
        SETWEIGHT(TO_TSVECTOR('french', COALESCE(nom, '')), 'A') ||
        SETWEIGHT(TO_TSVECTOR('french', COALESCE(description, '')), 'B')
    );

    -- Index GIN sur le vecteur
    CREATE INDEX idx_produits_fts ON produits USING GIN(tsv_description);

    -- Trigger pour maintenir le vecteur à jour
    CREATE FUNCTION update_produit_tsv() RETURNS TRIGGER AS $$
    BEGIN
        NEW.tsv_description :=
            SETWEIGHT(TO_TSVECTOR('french', COALESCE(NEW.nom, '')), 'A') ||
            SETWEIGHT(TO_TSVECTOR('french', COALESCE(NEW.description, '')), 'B');
        RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;

    CREATE TRIGGER trig_update_produit_tsv
    BEFORE INSERT OR UPDATE ON produits
    FOR EACH ROW EXECUTE FUNCTION update_produit_tsv();

    -- Requête Full Text Search (avec ranking)
    SELECT
        nom,
        description,
        ts_rank(tsv_description, to_tsquery('french', 'bluetooth & audio')) AS rang
    FROM produits
    WHERE tsv_description @@ to_tsquery('french', 'bluetooth & audio')
    ORDER BY rang DESC;

    -- Syntaxes tsquery :
    -- 'bluetooth & audio'  -> contient les deux mots
    -- 'bluetooth | audio'  -> contient l'un ou l'autre
    -- 'bluetooth & !apple' -> contient bluetooth mais pas apple
    -- 'blue:*'             -> commence par 'blue' (préfixe)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.5 STRATÉGIE COMPLÈTE D'INDEX POUR SHOPFLOW À FORT VOLUME
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    -- ════════════════════════════════════════════
    -- Index de base (clés et FK — déjà créés)
    -- ════════════════════════════════════════════
    -- PRIMARY KEY -> index unique automatique
    -- FK -> toujours indexer les colonnes FK
    CREATE INDEX IF NOT EXISTS idx_commandes_client     ON commandes(id_client);
    CREATE INDEX IF NOT EXISTS idx_commandes_employe    ON commandes(id_employe);
    CREATE INDEX IF NOT EXISTS idx_lignes_commande_cmd  ON lignes_commande(id_commande);
    CREATE INDEX IF NOT EXISTS idx_lignes_commande_prod ON lignes_commande(id_produit);
    CREATE INDEX IF NOT EXISTS idx_produits_categorie   ON produits(id_categorie);
    CREATE INDEX IF NOT EXISTS idx_produits_fournisseur ON produits(id_fournisseur);
    CREATE INDEX IF NOT EXISTS idx_avis_produit         ON avis(id_produit);
    CREATE INDEX IF NOT EXISTS idx_avis_client          ON avis(id_client);
    CREATE INDEX IF NOT EXISTS idx_paiements_commande   ON paiements(id_commande);

    -- ════════════════════════════════════════════
    -- Index de requêtes fréquentes
    -- ════════════════════════════════════════════

    -- Filtre de date sur commandes (rapport journalier, mensuel)
    CREATE INDEX IF NOT EXISTS idx_commandes_date
        ON commandes(date_commande DESC);

    -- Filtre statut + date (tableau de bord opérationnel)
    CREATE INDEX IF NOT EXISTS idx_commandes_statut_date
        ON commandes(statut, date_commande DESC)
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE');

    -- Recherche par email client (connexion, récupération de compte)
    CREATE UNIQUE INDEX IF NOT EXISTS idx_clients_email
        ON clients(LOWER(email));

    -- Filtre segment client (reporting, segmentation)
    CREATE INDEX IF NOT EXISTS idx_clients_segment
        ON clients(segment)
        WHERE segment IS NOT NULL;

    -- Recherche floue sur nom de produit (catalogue, e-commerce)
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    CREATE INDEX IF NOT EXISTS idx_produits_nom_trgm
        ON produits USING GIN(nom gin_trgm_ops);

    -- Produits actifs en stock (catalogue public)
    CREATE INDEX IF NOT EXISTS idx_produits_actif_stock
        ON produits(id_categorie, prix_ht)
        WHERE actif = TRUE AND stock > 0;

    -- ════════════════════════════════════════════
    -- Index couvrants (Index Only Scan)
    -- ════════════════════════════════════════════

    -- Liste des commandes d'un client (page profil client)
    CREATE INDEX IF NOT EXISTS idx_commandes_client_covering
        ON commandes(id_client, date_commande DESC)
        INCLUDE (id_commande, numero_commande, statut, montant_ttc);

    -- Produits par catégorie avec prix (catalogue filtré)
    CREATE INDEX IF NOT EXISTS idx_produits_cat_covering
        ON produits(id_categorie, prix_ht)
        INCLUDE (id_produit, nom, stock)
        WHERE actif = TRUE;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.6 EXERCICES INDEXING AVANCÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 48-1 (Moyen)
La table avis va recevoir 50 millions de lignes. Les requêtes les plus
fréquentes sont :
  a) WHERE id_produit = ? ORDER BY date_avis DESC LIMIT 20
  b) WHERE id_produit = ? AND note >= 4
  c) WHERE verifie = TRUE ORDER BY date_avis DESC LIMIT 100
Proposez les index optimaux pour chacune.

SOLUTION :
    -- Pour a) : index composite avec INCLUDE pour éviter l'accès table
    CREATE INDEX idx_avis_produit_date
        ON avis(id_produit, date_avis DESC)
        INCLUDE (note, titre, commentaire);

    -- Pour b) : index partiel sur note >= 4
    CREATE INDEX idx_avis_produit_bonne_note
        ON avis(id_produit, note)
        WHERE note >= 4;

    -- Pour c) : index partiel sur les avis vérifiés
    CREATE INDEX idx_avis_verifies_date
        ON avis(date_avis DESC)
        WHERE verifie = TRUE;

EXERCICE 48-2 (Avancé)
Proposez une stratégie de partitionnement pour la table lignes_commande
qui contiendra 500 millions de lignes après 5 ans d'activité.
Justifiez le type de partitionnement, la clé de partition, et la granularité.

SOLUTION :
    -- Analyse des patterns de requête :
    -- 1. "Toutes les lignes d'une commande" -> filtre sur id_commande
    -- 2. "Ventes du mois de mars 2024" -> filtre sur date via join commandes
    -- 3. "Produit X vendu en 2024" -> filtre id_produit + date via join

    -- Choix : RANGE sur id_commande (corrélé temporellement, toujours croissant)
    -- Granularité : par tranche de 10M commandes (≈ 1 an d'activité)

    CREATE TABLE lignes_commande_partitionnees (
        id_ligne            SERIAL,
        id_commande         INTEGER NOT NULL,
        id_produit          INTEGER NOT NULL,
        quantite            INTEGER NOT NULL,
        prix_unitaire_ht    DECIMAL(10, 2) NOT NULL,
        taux_tva            DECIMAL(4, 2) DEFAULT 20.00,
        remise_pct          DECIMAL(5, 2) DEFAULT 0
    ) PARTITION BY RANGE (id_commande);

    CREATE TABLE lignes_commande_p1
    PARTITION OF lignes_commande_partitionnees
    FOR VALUES FROM (1) TO (10000001);

    CREATE TABLE lignes_commande_p2
    PARTITION OF lignes_commande_partitionnees
    FOR VALUES FROM (10000001) TO (20000001);

    -- Index sur chaque partition
    CREATE INDEX ON lignes_commande_p1 (id_commande);
    CREATE INDEX ON lignes_commande_p1 (id_produit);
    CREATE INDEX ON lignes_commande_p2 (id_commande);
    CREATE INDEX ON lignes_commande_p2 (id_produit);

    -- Avantage : requête "lignes de la commande 5000000" ne lit que p1
    -- Purge : DROP TABLE lignes_commande_p1 (instantané, récupère des Go)


================================================================================
SYNTHÈSE DE LA PARTIE 12 — OPTIMISATION AVANCÉE
================================================================================

CHECKLIST D'OPTIMISATION
─────────────────────────
  [WHITE_SQUARE] Avez-vous indexé toutes les colonnes FK ?
  [WHITE_SQUARE] Avez-vous indexé les colonnes WHERE fréquentes ?
  [WHITE_SQUARE] Vos fonctions dans WHERE invalident-elles des index ?
  [WHITE_SQUARE] Avez-vous vérifié les statistiques (ANALYZE récent) ?
  [WHITE_SQUARE] Y a-t-il des SELECT * à remplacer ?
  [WHITE_SQUARE] Les sous-requêtes corrélées peuvent-elles être réécrites en JOIN ?
  [WHITE_SQUARE] Y a-t-il des données temporelles bénéficiant du partitionnement ?
  [WHITE_SQUARE] Les tables très utilisées sont-elles vacuumées régulièrement ?
  [WHITE_SQUARE] work_mem est-il suffisant pour éviter les tris sur disque ?
  [WHITE_SQUARE] Avez-vous configuré shared_buffers à 25% de la RAM ?

COMMANDES DE SURVEILLANCE RAPIDE
──────────────────────────────────
    -- Index inutilisés
    SELECT schemaname, tablename, indexname, idx_scan
    FROM pg_stat_user_indexes WHERE idx_scan = 0;

    -- Tables nécessitant un VACUUM
    SELECT relname, n_dead_tup, n_live_tup,
           ROUND(n_dead_tup::NUMERIC/NULLIF(n_live_tup,0)*100,1) AS dead_pct
    FROM pg_stat_user_tables
    WHERE n_dead_tup > 1000
    ORDER BY dead_pct DESC;

    -- Requêtes lentes (via pg_stat_statements)
    SELECT LEFT(query,100), calls, ROUND(mean_exec_time,1) AS ms_moy
    FROM pg_stat_statements
    ORDER BY mean_exec_time DESC LIMIT 10;

================================================================================
FIN DE LA PARTIE 12 — OPTIMISATION AVANCÉE
================================================================================
Chapitres : 46 (Query optimization), 47 (Explain plan), 48 (Indexing avancé)
Prochaine partie : PARTIE 13 — SQL AVANCÉ (Window functions, CTE, Recursive)
================================================================================

================================================================================
GUIDE SQL COMPLET — PARTIE 13
SQL AVANCÉ : WINDOW FUNCTIONS, CTE, REQUÊTES RÉCURSIVES
Chapitres 49 à 51
================================================================================
Base de données : ShopFlow (e-commerce)
Niveau : Avancé
================================================================================

TABLE DES MATIÈRES — PARTIE 13
════════════════════════════════
  Chapitre 49 : Window Functions — Fonctions de fenêtrage
  Chapitre 50 : CTE (Common Table Expressions) — Requêtes structurées
  Chapitre 51 : Requêtes récursives — Hiérarchies et graphes


================================================================================
CHAPITRE 49 : WINDOW FUNCTIONS — FONCTIONS DE FENÊTRAGE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.1 INTRODUCTION — LA RÉVOLUTION DES WINDOW FUNCTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une fonction de fenêtrage (window function) calcule une valeur pour chaque
ligne en tenant compte d'un ensemble de lignes voisines (la "fenêtre").

DIFFÉRENCE FONDAMENTALE avec GROUP BY :
  GROUP BY       : réduit N lignes en M groupes (M lignes dans le résultat)
  WINDOW FUNCTION : retourne N lignes avec une colonne calculée sur le groupe

Exemple : calculer le rang de chaque produit par CA, SANS GROUP BY :

    -- Sans window function (complexe, sous-requête corrélée) :
    SELECT p.nom,
           SUM(lc.quantite * lc.prix_unitaire_ht) AS ca,
           (SELECT COUNT(DISTINCT p2.id_produit)
            FROM produits p2
            JOIN lignes_commande lc2 ON p2.id_produit = lc2.id_produit
            WHERE SUM(lc2.quantite * lc2.prix_unitaire_ht) >
                  SUM(lc.quantite * lc.prix_unitaire_ht)) + 1 AS rang
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    GROUP BY p.id_produit, p.nom;

    -- Avec window function (élégant) :
    SELECT
        p.nom,
        SUM(lc.quantite * lc.prix_unitaire_ht)  AS ca,
        RANK() OVER (ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC) AS rang
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    GROUP BY p.id_produit, p.nom;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.2 SYNTAXE GÉNÉRALE DES WINDOW FUNCTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    fonction_window(arguments) OVER (
        [PARTITION BY colonne1, colonne2, ...]
        [ORDER BY colonne3 [ASC|DESC], ...]
        [frame_clause]
    )

PARTITION BY : divise les lignes en groupes (partitions).
  La fonction opère indépendamment dans chaque partition.
  Comme un GROUP BY mais sans réduire les lignes.

ORDER BY : définit l'ordre dans chaque partition.
  Nécessaire pour les fonctions de rang et les running totals.

frame_clause : définit quelles lignes de la partition sont incluses dans
  la "fenêtre" pour chaque ligne.

    -- Syntaxes de frame clause :
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- cumul depuis le début
    ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING           -- 5 lignes autour
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- toute la partition
    RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING  -- depuis maintenant jusqu'à fin

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.3 FONCTIONS DE RANG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ROW_NUMBER() — Numéro de ligne unique dans la partition
────────────────────────────────────────────────────────
    SELECT
        co.id_commande,
        cl.nom,
        co.date_commande,
        co.montant_ttc,
        ROW_NUMBER() OVER (
            PARTITION BY co.id_client
            ORDER BY co.date_commande
        ) AS numero_commande_du_client
    FROM commandes co
    JOIN clients cl ON co.id_client = cl.id_client
    ORDER BY co.id_client, co.date_commande;

    -- Résultat :
    -- commande | client  | date     | montant | num
    -- ─────────┼─────────┼──────────┼─────────┼────
    -- CMD-001  | Alice   | 2024-01  | 2399    | 1   <- 1ère commande d'Alice
    -- CMD-004  | Alice   | 2024-02  | 249     | 2   <- 2ème commande d'Alice
    -- CMD-002  | Bob     | 2024-01  | 1019    | 1   <- 1ère commande de Bob
    -- CMD-008  | Bob     | 2024-03  | 299     | 2   <- 2ème commande de Bob

RANK() — Rang avec ex-æquo (saute les rangs suivants)
───────────────────────────────────────────────────────
DENSE_RANK() — Rang avec ex-æquo (sans sauter de rangs)
─────────────────────────────────────────────────────────
    SELECT
        p.nom,
        SUM(lc.quantite * lc.prix_unitaire_ht)       AS ca,
        RANK()       OVER (ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC) AS rang_avec_saut,
        DENSE_RANK() OVER (ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC) AS rang_dense,
        ROW_NUMBER() OVER (ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC) AS num_ligne
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    GROUP BY p.id_produit, p.nom
    ORDER BY ca DESC;

    -- Si produit A et B ont le même CA (ex: 1000€) :
    -- RANK()       : A=1, B=1, C=3  (3 est sauté)
    -- DENSE_RANK() : A=1, B=1, C=2  (pas de saut)
    -- ROW_NUMBER() : A=1, B=2, C=3  (toujours unique)

NTILE(N) — Diviser en N groupes de taille égale (percentiles)
───────────────────────────────────────────────────────────────
    SELECT
        cl.nom,
        SUM(co.montant_ttc)                             AS ca_client,
        NTILE(4) OVER (ORDER BY SUM(co.montant_ttc) DESC) AS quartile,
        CASE NTILE(4) OVER (ORDER BY SUM(co.montant_ttc) DESC)
            WHEN 1 THEN 'Top 25% (VIP potentiel)'
            WHEN 2 THEN 'Top 50%'
            WHEN 3 THEN 'Top 75%'
            WHEN 4 THEN 'Bottom 25%'
        END AS segment_ca
    FROM clients cl
    JOIN commandes co ON cl.id_client = co.id_client
    GROUP BY cl.id_client, cl.nom
    ORDER BY ca_client DESC;

PERCENT_RANK() — Rang en pourcentage (0.0 à 1.0)
──────────────────────────────────────────────────
CUME_DIST() — Distribution cumulée (0.0 à 1.0)
────────────────────────────────────────────────
    SELECT
        p.nom,
        ROUND(SUM(lc.quantite * lc.prix_unitaire_ht), 2) AS ca,
        ROUND(PERCENT_RANK() OVER (
            ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht)
        ), 3) AS percent_rank,
        ROUND(CUME_DIST() OVER (
            ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht)
        ), 3) AS cume_dist
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    GROUP BY p.id_produit, p.nom;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.4 FONCTIONS DE NAVIGATION — LAG ET LEAD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LAG(col, n, default)  : valeur de N lignes précédentes dans la partition
LEAD(col, n, default) : valeur de N lignes suivantes dans la partition

Comparaison mois par mois :

    SELECT
        TO_CHAR(DATE_TRUNC('month', co.date_commande), 'YYYY-MM') AS mois,
        SUM(co.montant_ttc)                                        AS ca_mois,
        LAG(SUM(co.montant_ttc), 1, 0)
            OVER (ORDER BY DATE_TRUNC('month', co.date_commande))  AS ca_mois_precedent,
        SUM(co.montant_ttc) -
            LAG(SUM(co.montant_ttc), 1, 0)
            OVER (ORDER BY DATE_TRUNC('month', co.date_commande))  AS delta_ca,
        ROUND(
            (SUM(co.montant_ttc) -
             LAG(SUM(co.montant_ttc), 1, NULL)
             OVER (ORDER BY DATE_TRUNC('month', co.date_commande))) /
            NULLIF(LAG(SUM(co.montant_ttc), 1, NULL)
             OVER (ORDER BY DATE_TRUNC('month', co.date_commande)), 0) * 100,
        1)                                                         AS evolution_pct
    FROM commandes co
    WHERE co.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY DATE_TRUNC('month', co.date_commande)
    ORDER BY DATE_TRUNC('month', co.date_commande);

Analyser les délais entre commandes successives d'un client :

    SELECT
        cl.nom,
        co.id_commande,
        co.date_commande,
        LAG(co.date_commande)
            OVER (PARTITION BY co.id_client ORDER BY co.date_commande) AS commande_precedente,
        co.date_commande -
        LAG(co.date_commande)
            OVER (PARTITION BY co.id_client ORDER BY co.date_commande) AS jours_depuis_precedente
    FROM commandes co
    JOIN clients cl ON co.id_client = cl.id_client
    ORDER BY co.id_client, co.date_commande;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.5 FONCTIONS AGRÉGÉES EN WINDOW — RUNNING TOTALS ET MOYENNES MOBILES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Toutes les fonctions d'agrégation (SUM, COUNT, AVG, MIN, MAX) peuvent être
utilisées comme window functions avec OVER.

Running total (cumul) :

    SELECT
        co.date_commande::DATE            AS jour,
        co.montant_ttc                    AS ca_jour,
        SUM(co.montant_ttc)
            OVER (ORDER BY co.date_commande)  AS ca_cumule,
        COUNT(co.id_commande)
            OVER (ORDER BY co.date_commande)  AS nb_commandes_cumule
    FROM commandes co
    WHERE co.statut NOT IN ('ANNULEE')
    ORDER BY co.date_commande;

Moyenne mobile sur 7 jours :

    WITH ca_par_jour AS (
        SELECT
            date_commande::DATE AS jour,
            SUM(montant_ttc)    AS ca
        FROM commandes
        WHERE statut NOT IN ('ANNULEE')
        GROUP BY date_commande::DATE
    )
    SELECT
        jour,
        ca,
        ROUND(AVG(ca) OVER (
            ORDER BY jour
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ), 2) AS moy_mobile_7j,
        ROUND(AVG(ca) OVER (
            ORDER BY jour
            ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
        ), 2) AS moy_mobile_30j
    FROM ca_par_jour
    ORDER BY jour;

FIRST_VALUE, LAST_VALUE, NTH_VALUE :

    SELECT
        p.nom,
        cat.nom                                                AS categorie,
        p.prix_ht,
        FIRST_VALUE(p.nom) OVER (
            PARTITION BY cat.nom
            ORDER BY p.prix_ht DESC
        )                                                     AS produit_le_plus_cher_cat,
        LAST_VALUE(p.prix_ht) OVER (
            PARTITION BY cat.nom
            ORDER BY p.prix_ht DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
        )                                                     AS prix_le_moins_cher_cat
    FROM produits p
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    WHERE p.actif = TRUE
    ORDER BY cat.nom, p.prix_ht DESC;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.6 CLAUSE WINDOW — RÉUTILISER LA DÉFINITION D'UNE FENÊTRE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand on utilise la même définition OVER plusieurs fois, on peut la nommer :

    SELECT
        co.id_commande,
        co.id_client,
        co.montant_ttc,
        SUM(co.montant_ttc)   OVER w AS ca_cumule_client,
        AVG(co.montant_ttc)   OVER w AS moy_client,
        COUNT(co.id_commande) OVER w AS nb_cmd_cumule_client
    FROM commandes co
    WHERE co.statut NOT IN ('ANNULEE')
    WINDOW w AS (
        PARTITION BY co.id_client
        ORDER BY co.date_commande
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )
    ORDER BY co.id_client, co.date_commande;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49.7 EXERCICES WINDOW FUNCTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 49-1 (Facile)
Pour chaque commande, affichez le rang de cette commande par montant
descendant (globalement, pas par client), ainsi que le percentile.

SOLUTION :
    SELECT
        co.numero_commande,
        cl.nom,
        co.montant_ttc,
        RANK()        OVER (ORDER BY co.montant_ttc DESC) AS rang,
        DENSE_RANK()  OVER (ORDER BY co.montant_ttc DESC) AS rang_dense,
        ROUND(PERCENT_RANK() OVER (ORDER BY co.montant_ttc DESC) * 100, 1) AS percentile
    FROM commandes co
    JOIN clients cl ON co.id_client = cl.id_client
    ORDER BY co.montant_ttc DESC;

EXERCICE 49-2 (Moyen)
Pour chaque produit et par catégorie, calculer :
  - Le rang du produit dans sa catégorie par CA
  - La part du CA du produit dans sa catégorie (%)
  - La part du CA du produit dans le CA total global (%)

SOLUTION :
    SELECT
        cat.nom                                           AS categorie,
        p.nom                                             AS produit,
        ROUND(SUM(lc.quantite * lc.prix_unitaire_ht), 2) AS ca_produit,
        RANK() OVER (
            PARTITION BY cat.nom
            ORDER BY SUM(lc.quantite * lc.prix_unitaire_ht) DESC
        )                                                 AS rang_dans_categorie,
        ROUND(
            SUM(lc.quantite * lc.prix_unitaire_ht) /
            SUM(SUM(lc.quantite * lc.prix_unitaire_ht))
                OVER (PARTITION BY cat.nom) * 100, 1
        )                                                 AS pct_dans_categorie,
        ROUND(
            SUM(lc.quantite * lc.prix_unitaire_ht) /
            SUM(SUM(lc.quantite * lc.prix_unitaire_ht))
                OVER () * 100, 1
        )                                                 AS pct_global
    FROM produits p
    JOIN categories cat     ON p.id_categorie = cat.id_categorie
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    JOIN commandes co       ON lc.id_commande = co.id_commande
    WHERE co.statut NOT IN ('ANNULEE')
    GROUP BY cat.nom, p.id_produit, p.nom
    ORDER BY cat.nom, ca_produit DESC;

EXERCICE 49-3 (Avancé)
Calculez un "score de rétention" pour chaque client : le ratio entre le nombre
de mois où le client a commandé et le nombre de mois depuis son inscription.
Affichez aussi la tendance : est-ce que son CA par commande augmente, diminue,
ou est stable (comparer la dernière commande à la première via LAG).

SOLUTION :
    WITH historique AS (
        SELECT
            co.id_client,
            co.id_commande,
            co.date_commande,
            co.montant_ttc,
            ROW_NUMBER() OVER (
                PARTITION BY co.id_client
                ORDER BY co.date_commande
            ) AS num_commande,
            COUNT(*) OVER (PARTITION BY co.id_client) AS total_commandes,
            FIRST_VALUE(co.montant_ttc) OVER (
                PARTITION BY co.id_client ORDER BY co.date_commande
                ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
            ) AS montant_premiere_commande,
            LAST_VALUE(co.montant_ttc) OVER (
                PARTITION BY co.id_client ORDER BY co.date_commande
                ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
            ) AS montant_derniere_commande
        FROM commandes co
        WHERE statut NOT IN ('ANNULEE')
    )
    SELECT DISTINCT
        cl.nom,
        cl.segment,
        h.total_commandes,
        COUNT(DISTINCT DATE_TRUNC('month', h.date_commande))
            OVER (PARTITION BY h.id_client) AS mois_actif,
        EXTRACT(MONTH FROM AGE(
            MAX(h.date_commande) OVER (PARTITION BY h.id_client),
            cl.date_inscription
        )) + 1                              AS mois_depuis_inscription,
        ROUND(
            COUNT(DISTINCT DATE_TRUNC('month', h.date_commande))
                OVER (PARTITION BY h.id_client)::NUMERIC /
            NULLIF(EXTRACT(MONTH FROM AGE(
                MAX(h.date_commande) OVER (PARTITION BY h.id_client),
                cl.date_inscription
            )) + 1, 0) * 100, 1
        )                                   AS taux_retention_pct,
        CASE
            WHEN h.montant_derniere_commande > h.montant_premiere_commande * 1.2
            THEN '^ Croissant'
            WHEN h.montant_derniere_commande < h.montant_premiere_commande * 0.8
            THEN 'v Décroissant'
            ELSE '-> Stable'
        END                                 AS tendance
    FROM historique h
    JOIN clients cl ON h.id_client = cl.id_client
    ORDER BY taux_retention_pct DESC;


================================================================================
CHAPITRE 50 : CTE — COMMON TABLE EXPRESSIONS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50.1 INTRODUCTION — POURQUOI LES CTE EXISTENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les CTE (WITH ... AS) permettent de :
  1. Décomposer des requêtes complexes en étapes lisibles nommées
  2. Réutiliser un calcul intermédiaire plusieurs fois dans la même requête
  3. Rendre le SQL auto-documenté (chaque CTE a un nom métier clair)
  4. Écrire des requêtes récursives (hiérarchies, graphes)

Analogie : les CTE sont comme des variables dans un programme.
Au lieu d'écrire la même sous-requête 3 fois, on la calcule une fois et
on la nomme.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50.2 SYNTAXE ET EXEMPLES DE BASE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Syntaxe générale :

    WITH
        nom_cte_1 [(col1, col2)] AS (
            SELECT ...
        ),
        nom_cte_2 AS (
            SELECT ... FROM nom_cte_1 ...
        )
    SELECT ... FROM nom_cte_2;

CTE simple — Clarifier une requête complexe :

    -- Sans CTE (difficile à lire) :
    SELECT
        cl.nom,
        (SELECT COUNT(*) FROM commandes co WHERE co.id_client = cl.id_client
         AND co.statut NOT IN ('ANNULEE')) AS nb_commandes,
        (SELECT SUM(co.montant_ttc) FROM commandes co WHERE co.id_client = cl.id_client
         AND co.statut NOT IN ('ANNULEE')) AS ca_total,
        (SELECT MAX(co.date_commande) FROM commandes co WHERE co.id_client = cl.id_client) AS derniere_cmd
    FROM clients cl;

    -- Avec CTE (lisible, une seule passe sur commandes) :
    WITH stats_client AS (
        SELECT
            id_client,
            COUNT(id_commande)          AS nb_commandes,
            SUM(montant_ttc)            AS ca_total,
            MAX(date_commande)          AS derniere_commande
        FROM commandes
        WHERE statut NOT IN ('ANNULEE')
        GROUP BY id_client
    )
    SELECT
        cl.nom,
        COALESCE(sc.nb_commandes, 0)     AS nb_commandes,
        COALESCE(sc.ca_total, 0)         AS ca_total,
        sc.derniere_commande
    FROM clients cl
    LEFT JOIN stats_client sc ON cl.id_client = sc.id_client
    ORDER BY ca_total DESC NULLS LAST;

CTE multiples chaînées :

    WITH
    -- Étape 1 : CA par produit
    ca_produit AS (
        SELECT
            id_produit,
            SUM(quantite * prix_unitaire_ht) AS ca
        FROM lignes_commande lc
        JOIN commandes co ON lc.id_commande = co.id_commande
        WHERE co.statut NOT IN ('ANNULEE')
        GROUP BY id_produit
    ),
    -- Étape 2 : Produits avec leur catégorie et CA
    produits_avec_ca AS (
        SELECT
            p.nom                  AS produit,
            cat.nom                AS categorie,
            COALESCE(cp.ca, 0)     AS chiffre_affaires,
            p.prix_ht * p.stock    AS valeur_stock
        FROM produits p
        JOIN categories cat ON p.id_categorie = cat.id_categorie
        LEFT JOIN ca_produit cp ON p.id_produit = cp.id_produit
        WHERE p.actif = TRUE
    ),
    -- Étape 3 : Classement par catégorie
    classement AS (
        SELECT
            *,
            RANK() OVER (PARTITION BY categorie ORDER BY chiffre_affaires DESC) AS rang
        FROM produits_avec_ca
    )
    -- Requête finale : top 3 par catégorie
    SELECT categorie, produit, chiffre_affaires, rang
    FROM classement
    WHERE rang <= 3
    ORDER BY categorie, rang;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50.3 CTE MATÉRIALISÉES — CONTRÔLER L'OPTIMISATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PostgreSQL 12+ : par défaut les CTE non récursives sont INLINÉES (traitées
comme des sous-requêtes). L'optimiseur peut pousser les filtres.

Pour forcer la matérialisation (exécuter la CTE une seule fois) :

    WITH stats AS MATERIALIZED (
        SELECT id_client, SUM(montant_ttc) AS total
        FROM commandes
        GROUP BY id_client
    )
    SELECT cl.nom, stats.total
    FROM clients cl
    JOIN stats ON cl.id_client = stats.id_client
    WHERE stats.total > 1000;
    -- La CTE stats est calculée UNE SEULE FOIS, puis utilisée
    -- Utile si la CTE est référencée plusieurs fois et le calcul est coûteux

    -- Pour forcer l'inlining (comportement par défaut PG12+) :
    WITH stats AS NOT MATERIALIZED (...)
    ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50.4 CTE DANS INSERT, UPDATE, DELETE (WRITEABLE CTE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les CTE peuvent contenir des instructions DML avec RETURNING.

    -- Archiver et supprimer en une seule opération atomique :
    WITH commandes_archivees AS (
        INSERT INTO commandes_archive
            (id_commande, numero_commande, id_client, montant_ttc,
             date_commande, statut)
        SELECT id_commande, numero_commande, id_client, montant_ttc,
               date_commande, statut
        FROM commandes
        WHERE statut = 'ANNULEE'
          AND date_commande < '2023-01-01'
        RETURNING id_commande
    ),
    -- Supprimer les lignes de commande correspondantes
    lignes_supprimees AS (
        DELETE FROM lignes_commande
        WHERE id_commande IN (SELECT id_commande FROM commandes_archivees)
        RETURNING id_commande
    )
    -- Supprimer les commandes
    DELETE FROM commandes
    WHERE id_commande IN (SELECT id_commande FROM commandes_archivees)
    RETURNING id_commande, numero_commande;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50.5 EXERCICES CTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 50-1 (Facile)
Réécrivez cette requête en utilisant des CTEs pour la rendre plus lisible :
    SELECT cl.nom, co.nb, co.ca, RANK() OVER (ORDER BY co.ca DESC) rang
    FROM clients cl
    JOIN (SELECT id_client, COUNT(*) nb, SUM(montant_ttc) ca
          FROM commandes WHERE statut NOT IN ('ANNULEE') GROUP BY id_client) co
    ON cl.id_client = co.id_client;

SOLUTION :
    WITH commandes_stats AS (
        SELECT
            id_client,
            COUNT(*)         AS nb_commandes,
            SUM(montant_ttc) AS ca_total
        FROM commandes
        WHERE statut NOT IN ('ANNULEE')
        GROUP BY id_client
    ),
    clients_avec_stats AS (
        SELECT
            cl.nom,
            cs.nb_commandes,
            cs.ca_total,
            RANK() OVER (ORDER BY cs.ca_total DESC) AS rang_ca
        FROM clients cl
        JOIN commandes_stats cs ON cl.id_client = cs.id_client
    )
    SELECT nom, nb_commandes, ca_total, rang_ca
    FROM clients_avec_stats
    ORDER BY rang_ca;

EXERCICE 50-2 (Avancé)
Créez un rapport complet "santé de la boutique" avec des CTEs pour :
  - CA du mois courant et du mois précédent
  - Taux de conversion (commandes non-annulées / total commandes)
  - Note moyenne globale
  - Produit le plus vendu du mois
  - Client ayant le plus dépensé du mois

SOLUTION :
    WITH periode AS (
        SELECT
            DATE_TRUNC('month', CURRENT_DATE) AS debut_mois,
            DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' AS fin_mois,
            DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month' AS debut_mois_prec,
            DATE_TRUNC('month', CURRENT_DATE) AS fin_mois_prec
    ),
    ca_mois AS (
        SELECT
            SUM(CASE WHEN date_commande >= p.debut_mois
                     THEN montant_ttc ELSE 0 END) AS ca_mois_courant,
            SUM(CASE WHEN date_commande >= p.debut_mois_prec
                     AND date_commande < p.debut_mois
                     THEN montant_ttc ELSE 0 END) AS ca_mois_precedent,
            COUNT(*) FILTER (WHERE date_commande >= p.debut_mois) AS total_cmd,
            COUNT(*) FILTER (
                WHERE date_commande >= p.debut_mois
                  AND statut NOT IN ('ANNULEE')
            ) AS cmd_non_annulees
        FROM commandes, periode p
    ),
    note_globale AS (
        SELECT ROUND(AVG(note), 2) AS note_moy FROM avis
    ),
    top_produit AS (
        SELECT p.nom AS top_produit, SUM(lc.quantite) AS qtv
        FROM lignes_commande lc
        JOIN commandes co ON lc.id_commande = co.id_commande
        JOIN produits p ON lc.id_produit = p.id_produit, periode per
        WHERE co.date_commande >= per.debut_mois
          AND co.statut NOT IN ('ANNULEE')
        GROUP BY p.id_produit, p.nom
        ORDER BY qtv DESC LIMIT 1
    ),
    top_client AS (
        SELECT cl.nom AS top_client, SUM(co.montant_ttc) AS ca
        FROM commandes co
        JOIN clients cl ON co.id_client = cl.id_client, periode p
        WHERE co.date_commande >= p.debut_mois
          AND co.statut NOT IN ('ANNULEE')
        GROUP BY cl.id_client, cl.nom
        ORDER BY ca DESC LIMIT 1
    )
    SELECT
        TO_CHAR(CURRENT_DATE, 'Month YYYY') AS rapport_du_mois,
        cm.ca_mois_courant,
        cm.ca_mois_precedent,
        ROUND((cm.ca_mois_courant - cm.ca_mois_precedent) /
              NULLIF(cm.ca_mois_precedent, 0) * 100, 1) AS evolution_pct,
        ROUND(cm.cmd_non_annulees::NUMERIC /
              NULLIF(cm.total_cmd, 0) * 100, 1) AS taux_conversion_pct,
        ng.note_moy,
        tp.top_produit,
        tc.top_client
    FROM ca_mois cm, note_globale ng, top_produit tp, top_client tc;


================================================================================
CHAPITRE 51 : REQUÊTES RÉCURSIVES — HIÉRARCHIES ET GRAPHES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.1 INTRODUCTION — POURQUOI LES REQUÊTES RÉCURSIVES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les structures hiérarchiques sont omniprésentes :
  - Organigramme (employé -> manager -> directeur -> PDG)
  - Arborescence de catégories (Électronique -> Audio -> Casques)
  - Commentaires imbriqués (réponse à une réponse à un commentaire)
  - Nomenclature de pièces (composant -> sous-composant)
  - Relations de type graphe (chemin le plus court)

Sans CTE récursive, naviguer une hiérarchie nécessite plusieurs requêtes
en boucle dans l'application. La CTE récursive fait tout en SQL.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.2 SYNTAXE DES CTE RÉCURSIVES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    WITH RECURSIVE nom_cte AS (
        -- ANCRE : cas de base (point de départ)
        SELECT ...
        UNION ALL
        -- PARTIE RÉCURSIVE : fait référence à nom_cte
        SELECT ... FROM ... JOIN nom_cte ON ...
    )
    SELECT ... FROM nom_cte;

Mécanisme d'exécution :
  1. Exécuter l'ancre -> résultat initial R0
  2. Exécuter la partie récursive avec nom_cte = R0 -> résultat R1
  3. Exécuter la partie récursive avec nom_cte = R1 -> résultat R2
  4. Continuer jusqu'à ce que la partie récursive retourne 0 lignes
  5. Résultat final = R0 UNION ALL R1 UNION ALL R2 UNION ALL ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.3 HIÉRARCHIE D'EMPLOYÉS — ORGANIGRAMME COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Rappel de la table employes dans ShopFlow :
  - id_employe : clé primaire
  - id_manager : FK vers employes.id_employe (NULL = PDG/racine)

Afficher l'organigramme complet avec niveau hiérarchique :

    WITH RECURSIVE organigramme AS (
        -- ANCRE : trouver le PDG (id_manager IS NULL)
        SELECT
            id_employe,
            prenom || ' ' || nom    AS employe,
            poste,
            id_manager,
            0                       AS niveau,
            ARRAY[id_employe]       AS chemin,
            prenom || ' ' || nom    AS chemin_texte
        FROM employes
        WHERE id_manager IS NULL

        UNION ALL

        -- PARTIE RÉCURSIVE : trouver les subordonnés directs
        SELECT
            e.id_employe,
            e.prenom || ' ' || e.nom AS employe,
            e.poste,
            e.id_manager,
            o.niveau + 1,
            o.chemin || e.id_employe,
            o.chemin_texte || ' -> ' || e.prenom || ' ' || e.nom
        FROM employes e
        JOIN organigramme o ON e.id_manager = o.id_employe
    )
    SELECT
        REPEAT('    ', niveau) || employe AS organigramme_indenté,
        poste,
        niveau,
        chemin_texte
    FROM organigramme
    ORDER BY chemin;

    -- Résultat :
    -- organigramme_indenté           | poste                 | niveau
    -- ───────────────────────────────┼───────────────────────┼───────
    -- Sophie Girard                  | Directrice Commerciale |     0
    --     Pierre Dubois              | Commercial Senior      |     1
    --         Marie Lefebvre         | Commercial Junior      |     2
    --     Antoine Rousseau           | Resp. Logistique       |     1

Trouver tous les subordonnés d'un manager donné (directement ou non) :

    WITH RECURSIVE equipe AS (
        -- Point de départ : le manager cible
        SELECT id_employe, prenom || ' ' || nom AS employe, 0 AS niveau
        FROM employes
        WHERE id_employe = 1  -- Sophie Girard (Directrice)

        UNION ALL

        SELECT e.id_employe, e.prenom || ' ' || e.nom, eq.niveau + 1
        FROM employes e
        JOIN equipe eq ON e.id_manager = eq.id_employe
    )
    SELECT employe, niveau
    FROM equipe
    WHERE niveau > 0  -- Exclure le manager lui-même
    ORDER BY niveau, employe;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.4 HIÉRARCHIE DE CATÉGORIES — ARBORESCENCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La table categories a une auto-référence via id_parent.

    -- Afficher l'arborescence complète des catégories
    WITH RECURSIVE arbre_categories AS (
        -- Catégories racines (pas de parent)
        SELECT
            id_categorie,
            nom,
            id_parent,
            0           AS profondeur,
            nom         AS chemin_complet,
            nom         AS breadcrumb
        FROM categories
        WHERE id_parent IS NULL

        UNION ALL

        SELECT
            c.id_categorie,
            c.nom,
            c.id_parent,
            ac.profondeur + 1,
            ac.chemin_complet || ' > ' || c.nom,
            REPEAT('  ', ac.profondeur + 1) || c.nom
        FROM categories c
        JOIN arbre_categories ac ON c.id_parent = ac.id_categorie
    )
    SELECT
        breadcrumb,
        chemin_complet,
        profondeur
    FROM arbre_categories
    ORDER BY chemin_complet;

    -- Compter les produits dans une catégorie ET toutes ses sous-catégories :
    WITH RECURSIVE sous_categories AS (
        SELECT id_categorie FROM categories WHERE id_categorie = 1  -- Électronique
        UNION ALL
        SELECT c.id_categorie
        FROM categories c
        JOIN sous_categories sc ON c.id_parent = sc.id_categorie
    )
    SELECT COUNT(*) AS nb_produits_dans_arbre
    FROM produits
    WHERE id_categorie IN (SELECT id_categorie FROM sous_categories)
    AND actif = TRUE;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.5 GÉNÉRATION DE SÉQUENCES — GENERATE_SERIES ET CTE RÉCURSIVES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les CTE récursives peuvent générer des séquences de nombres ou de dates.

    -- Générer les nombres de 1 à 100
    WITH RECURSIVE serie AS (
        SELECT 1 AS n
        UNION ALL
        SELECT n + 1 FROM serie WHERE n < 100
    )
    SELECT n FROM serie;

    -- Calendrier complet d'une année avec LEFT JOIN commandes (rapport complet)
    WITH RECURSIVE jours_2024 AS (
        SELECT '2024-01-01'::DATE AS jour
        UNION ALL
        SELECT jour + 1 FROM jours_2024 WHERE jour < '2024-12-31'
    )
    SELECT
        j.jour,
        TO_CHAR(j.jour, 'Day')              AS jour_semaine,
        COUNT(co.id_commande)               AS nb_commandes,
        COALESCE(SUM(co.montant_ttc), 0)    AS ca
    FROM jours_2024 j
    LEFT JOIN commandes co ON co.date_commande::DATE = j.jour
        AND co.statut NOT IN ('ANNULEE')
    GROUP BY j.jour
    ORDER BY j.jour;

    -- Note : PostgreSQL dispose de generate_series() (plus efficace) :
    SELECT generate_series('2024-01-01'::DATE, '2024-12-31'::DATE, '1 day') AS jour;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.6 PROTECTION CONTRE LES BOUCLES INFINIES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Une CTE récursive peut boucler indéfiniment si les données forment un cycle
(A -> B -> C -> A). Protection :

    -- Option 1 : limiter la profondeur
    WITH RECURSIVE traversal AS (
        SELECT id_employe, 0 AS depth FROM employes WHERE id_manager IS NULL
        UNION ALL
        SELECT e.id_employe, t.depth + 1
        FROM employes e
        JOIN traversal t ON e.id_manager = t.id_employe
        WHERE t.depth < 10  -- <- LIMITE DE PROFONDEUR
    )
    ...

    -- Option 2 : détecter les cycles avec un tableau de chemin
    WITH RECURSIVE traversal AS (
        SELECT id_employe, ARRAY[id_employe] AS visited
        FROM employes WHERE id_manager IS NULL
        UNION ALL
        SELECT e.id_employe, t.visited || e.id_employe
        FROM employes e
        JOIN traversal t ON e.id_manager = t.id_employe
        WHERE NOT e.id_employe = ANY(t.visited)  -- <- Évite les cycles
    )
    ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51.7 EXERCICES REQUÊTES RÉCURSIVES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EXERCICE 51-1 (Moyen)
Afficher pour chaque employé la chaîne hiérarchique complète (breadcrumb)
depuis le PDG jusqu'à lui, et son niveau dans l'organigramme.

SOLUTION :
    WITH RECURSIVE chaine AS (
        SELECT
            id_employe,
            prenom || ' ' || nom AS employe,
            id_manager,
            0 AS niveau,
            prenom || ' ' || nom AS breadcrumb
        FROM employes
        WHERE id_manager IS NULL

        UNION ALL

        SELECT
            e.id_employe,
            e.prenom || ' ' || e.nom,
            e.id_manager,
            ch.niveau + 1,
            ch.breadcrumb || ' > ' || e.prenom || ' ' || e.nom
        FROM employes e
        JOIN chaine ch ON e.id_manager = ch.id_employe
    )
    SELECT employe, niveau, breadcrumb
    FROM chaine
    ORDER BY breadcrumb;

EXERCICE 51-2 (Avancé)
Pour la table categories de ShopFlow, construire une requête qui :
  1. Affiche l'arborescence complète
  2. Pour chaque catégorie, compte le nombre de produits actifs directs
     ET le nombre total de produits actifs dans toute la sous-arborescence
  3. Calcule le CA associé à chaque catégorie (direct et cumulé)

SOLUTION :
    WITH RECURSIVE arbre AS (
        -- Racines
        SELECT id_categorie, nom, id_parent, 0 AS niveau, ARRAY[id_categorie] AS chemin
        FROM categories WHERE id_parent IS NULL
        UNION ALL
        SELECT c.id_categorie, c.nom, c.id_parent, a.niveau + 1, a.chemin || c.id_categorie
        FROM categories c
        JOIN arbre a ON c.id_parent = a.id_categorie
    ),
    -- Pour chaque nœud de l'arbre, trouver tous ses descendants
    descendants AS (
        SELECT a.id_categorie AS categorie_parente, a2.id_categorie AS categorie_descendante
        FROM arbre a
        JOIN arbre a2 ON a.id_categorie = ANY(a2.chemin)
    ),
    -- CA par catégorie directe
    ca_direct AS (
        SELECT p.id_categorie, COUNT(p.id_produit) AS nb_produits, SUM(lc.quantite * lc.prix_unitaire_ht) AS ca
        FROM produits p
        LEFT JOIN lignes_commande lc ON p.id_produit = lc.id_produit
        LEFT JOIN commandes co ON lc.id_commande = co.id_commande AND co.statut NOT IN ('ANNULEE')
        WHERE p.actif = TRUE
        GROUP BY p.id_categorie
    )
    SELECT
        REPEAT('  ', a.niveau) || a.nom AS categorie,
        COALESCE(cd.nb_produits, 0) AS produits_directs,
        COUNT(DISTINCT p_desc.id_produit) AS produits_sous_arbre,
        ROUND(COALESCE(cd.ca, 0), 2) AS ca_direct,
        ROUND(COALESCE(SUM(cd2.ca), 0), 2) AS ca_sous_arbre
    FROM arbre a
    LEFT JOIN ca_direct cd ON a.id_categorie = cd.id_categorie
    LEFT JOIN descendants d ON a.id_categorie = d.categorie_parente
    LEFT JOIN produits p_desc ON d.categorie_descendante = p_desc.id_categorie AND p_desc.actif = TRUE
    LEFT JOIN ca_direct cd2 ON d.categorie_descendante = cd2.id_categorie
    GROUP BY a.id_categorie, a.nom, a.niveau, a.chemin, cd.nb_produits, cd.ca
    ORDER BY a.chemin;


================================================================================
SYNTHÈSE DE LA PARTIE 13 — SQL AVANCÉ
================================================================================

TABLEAU RÉCAPITULATIF — WINDOW FUNCTIONS
─────────────────────────────────────────
  Fonction           | Description                           | Nécessite ORDER BY
  ───────────────────┼───────────────────────────────────────┼───────────────────
  ROW_NUMBER()       | Numéro unique dans la partition        | Oui
  RANK()             | Rang avec ex-æquo (saute les rangs)   | Oui
  DENSE_RANK()       | Rang avec ex-æquo (sans saut)         | Oui
  NTILE(n)           | Divise en n groupes                   | Oui
  PERCENT_RANK()     | Rang en % [0,1]                       | Oui
  CUME_DIST()        | Distribution cumulée [0,1]            | Oui
  LAG(col, n)        | Valeur N lignes avant                 | Oui
  LEAD(col, n)       | Valeur N lignes après                 | Oui
  FIRST_VALUE(col)   | Première valeur de la fenêtre         | Oui
  LAST_VALUE(col)    | Dernière valeur de la fenêtre         | Oui
  SUM / AVG / COUNT  | Agrégats en mode window               | Optionnel

RÈGLES D'OR
────────────
  1. WINDOW FUNCTION ≠ GROUP BY : toutes les lignes source sont conservées
  2. PARTITION BY divise la "fenêtre" en groupes (comme GROUP BY mais sans réduire)
  3. ORDER BY dans OVER est indépendant du ORDER BY final de la requête
  4. frame_clause par défaut : RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  5. CTE multiples -> lire comme des étapes de traitement (haut -> bas)
  6. WITH RECURSIVE -> toujours prévoir un cas d'arrêt pour éviter les boucles
  7. UNION ALL dans les CTE récursives (UNION supprimerait des lignes légitimes)

================================================================================
FIN DE LA PARTIE 13 — SQL AVANCÉ
================================================================================
Chapitres : 49 (Window Functions), 50 (CTE), 51 (Requêtes récursives)
Prochaine partie : PARTIE 14 — DATA ANALYSIS AVEC SQL
================================================================================

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                  PARTIE 14 — DATA ANALYSIS AVEC SQL                             ║
║                         Chapitres 52 à 54                                       ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Avancé
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 14
════════════════════════════════
  Chapitre 52 : KPIs — Indicateurs clés de performance en SQL
  Chapitre 53 : Dashboards SQL — Construire des tableaux de bord analytiques
  Chapitre 54 : Analyse des ventes — Rapports avancés et insights business

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 52 — KPIs : INDICATEURS CLÉS DE PERFORMANCE EN SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

52.1 INTRODUCTION PÉDAGOGIQUE
──────────────────────────────

Qu'est-ce qu'un KPI ?
  Un KPI (Key Performance Indicator) est une mesure quantitative qui évalue
  l'efficacité d'un processus ou d'une activité par rapport à un objectif.
  En SQL, les KPIs sont calculés à partir des données brutes de la base.

Pourquoi les KPIs existent ?
  Les entreprises génèrent des millions de transactions. Les KPIs transforment
  cette masse de données en un nombre actionnable : "Notre taux de conversion
  est de 3.2%" ou "Notre panier moyen a augmenté de 15%".

Dans quels cas les utilise-t-on ?
  -> Direction : suivi mensuel de la performance globale
  -> Marketing : mesure des campagnes et de l'acquisition
  -> Produit : taux d'utilisation, rétention, satisfaction
  -> Finance : revenus, marges, coûts

52.2 LES FAMILLES DE KPIs EN E-COMMERCE
─────────────────────────────────────────

KPIs DE VOLUME (combien ?)
  - Nombre de commandes par période
  - Nombre de nouveaux clients
  - Nombre de produits vendus
  - Nombre de visites (si on a la table sessions)

KPIs FINANCIERS (combien d'argent ?)
  - Chiffre d'affaires (CA) brut
  - Chiffre d'affaires net (après remises, retours)
  - Marge brute
  - Panier moyen (AOV : Average Order Value)

KPIs DE QUALITÉ (comment ?)
  - Taux de conversion (commandes / visites)
  - Taux d'annulation
  - Taux de retour
  - Note moyenne des produits
  - Taux de satisfaction client (NPS si disponible)

KPIs DE RÉTENTION (fidélité ?)
  - Taux de réachat (clients ayant passé 2+ commandes)
  - Customer Lifetime Value (CLV / LTV)
  - Taux de churn (clients perdus)
  - DAU/MAU (Daily/Monthly Active Users)

52.3 COMMENT ÇA FONCTIONNE INTERNEMENT ?
──────────────────────────────────────────

Les KPIs sont calculés par le moteur SQL via :

  1. AGRÉGATIONS  -> SUM(), COUNT(), AVG(), MIN(), MAX()
  2. FILTRES      -> WHERE clause pour la période ou segment
  3. JOINTURES    -> combiner plusieurs tables
  4. CTEs         -> décomposer les calculs complexes
  5. WINDOW FUNC  -> comparaisons période-sur-période (LAG, LEAD)
  6. CASE WHEN    -> segmentation conditionnelle

Le pipeline d'un KPI :
  Données brutes -> Nettoyage (filtres) -> Agrégation -> Calcul -> Formatage

52.4 IMPLÉMENTATION : KPIs FONDAMENTAUX SHOPFLOW
──────────────────────────────────────────────────

────────────────────────────────────────
KPI 1 : Chiffre d'affaires total
────────────────────────────────────────

-- CA brut : somme de toutes les lignes vendues
-- On calcule le CA à partir des lignes de commande pour avoir le détail produit
-- plutôt que du montant_ttc de la commande (qui peut inclure des frais de port)

SELECT
    DATE_TRUNC('month', c.date_commande)          AS mois,
    COUNT(DISTINCT c.id_commande)                 AS nb_commandes,
    COUNT(DISTINCT c.id_client)                   AS nb_clients_uniques,
    SUM(lc.quantite * lc.prix_unitaire_ht)        AS ca_ht,
    SUM(lc.quantite * lc.prix_unitaire_ht
        * (1 + lc.taux_tva / 100))                AS ca_ttc,
    ROUND(
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100))
        / COUNT(DISTINCT c.id_commande),
        2
    )                                             AS panier_moyen_ttc
FROM commandes c
JOIN lignes_commande lc ON c.id_commande = lc.id_commande
WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
  AND c.date_commande >= DATE_TRUNC('year', NOW())  -- Année en cours
GROUP BY DATE_TRUNC('month', c.date_commande)
ORDER BY mois;

-- Résultat exemple :
-- mois       | nb_commandes | nb_clients | ca_ht     | ca_ttc    | panier_moyen_ttc
-- -----------+--------------+------------+-----------+-----------+------------------
-- 2024-01-01 |          47  |         38 | 28 430.50 | 34 116.60 |           725.89
-- 2024-02-01 |          52  |         41 | 31 200.80 | 37 440.96 |           720.02
-- 2024-03-01 |          61  |         48 | 38 750.20 | 46 500.24 |           762.30

────────────────────────────────────────
KPI 2 : Panier moyen (AOV)
────────────────────────────────────────

-- L'AOV (Average Order Value) est l'un des KPIs les plus suivis en e-commerce.
-- Un AOV élevé signifie que les clients dépensent plus à chaque commande.
-- Stratégie pour l'augmenter : upsell, cross-sell, seuil de livraison gratuite.

WITH aov_par_periode AS (
    SELECT
        DATE_TRUNC('month', c.date_commande)    AS mois,
        ROUND(AVG(c.montant_ttc), 2)             AS aov_courant
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY DATE_TRUNC('month', c.date_commande)
)
SELECT
    TO_CHAR(mois, 'Month YYYY')                  AS periode,
    aov_courant,
    LAG(aov_courant) OVER (ORDER BY mois)        AS aov_precedent,
    ROUND(
        (aov_courant - LAG(aov_courant) OVER (ORDER BY mois))
        / NULLIF(LAG(aov_courant) OVER (ORDER BY mois), 0) * 100,
        1
    )                                            AS variation_pct
FROM aov_par_periode
ORDER BY mois;

-- LAG() permet de comparer chaque mois au précédent automatiquement.
-- NULLIF(..., 0) évite la division par zéro si le mois précédent avait un AOV nul.

────────────────────────────────────────
KPI 3 : Taux de conversion
────────────────────────────────────────

-- Le taux de conversion mesure le % de clients inscrits ayant commandé.
-- (En e-commerce réel, on mesure visiteurs -> acheteurs. Ici : inscrits -> acheteurs.)

WITH clients_inscrits AS (
    SELECT
        DATE_TRUNC('month', date_inscription)   AS mois_inscription,
        COUNT(*)                                AS nb_inscrits
    FROM clients
    GROUP BY DATE_TRUNC('month', date_inscription)
),
clients_ayant_commande AS (
    SELECT
        DATE_TRUNC('month', cl.date_inscription) AS mois_inscription,
        COUNT(DISTINCT cl.id_client)             AS nb_acheteurs
    FROM clients cl
    WHERE EXISTS (
        SELECT 1 FROM commandes c
        WHERE c.id_client = cl.id_client
          AND c.statut NOT IN ('ANNULEE')
    )
    GROUP BY DATE_TRUNC('month', cl.date_inscription)
)
SELECT
    TO_CHAR(ci.mois_inscription, 'YYYY-MM')      AS mois,
    ci.nb_inscrits,
    COALESCE(cac.nb_acheteurs, 0)                AS nb_acheteurs,
    ROUND(
        COALESCE(cac.nb_acheteurs, 0)::DECIMAL
        / NULLIF(ci.nb_inscrits, 0) * 100,
        1
    )                                            AS taux_conversion_pct
FROM clients_inscrits ci
LEFT JOIN clients_ayant_commande cac
    ON ci.mois_inscription = cac.mois_inscription
ORDER BY ci.mois_inscription;

────────────────────────────────────────
KPI 4 : Customer Lifetime Value (CLV)
────────────────────────────────────────

-- Le CLV mesure la valeur totale qu'un client génère pour l'entreprise
-- sur toute sa durée de vie.
-- Formule simplifiée : CLV = Panier moyen × Fréquence d'achat × Durée de vie

WITH stats_client AS (
    SELECT
        c.id_client,
        COUNT(DISTINCT c.id_commande)                   AS nb_commandes,
        SUM(c.montant_ttc)                              AS ca_total,
        ROUND(AVG(c.montant_ttc), 2)                    AS panier_moyen,
        MIN(c.date_commande)                            AS premiere_commande,
        MAX(c.date_commande)                            AS derniere_commande,
        -- Durée de vie en mois (au moins 1 mois)
        GREATEST(
            EXTRACT(DAYS FROM MAX(c.date_commande) - MIN(c.date_commande)) / 30,
            1
        )                                               AS duree_vie_mois
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY c.id_client
),
clv_par_client AS (
    SELECT
        cl.id_client,
        cl.prenom || ' ' || cl.nom                      AS client,
        cl.segment,
        sc.nb_commandes,
        ROUND(sc.ca_total, 2)                           AS ca_total,
        sc.panier_moyen,
        sc.duree_vie_mois,
        -- Fréquence : commandes par mois
        ROUND(sc.nb_commandes / sc.duree_vie_mois, 2)   AS freq_mensuelle,
        -- CLV projeté sur 12 mois
        ROUND(
            sc.panier_moyen
            * (sc.nb_commandes / sc.duree_vie_mois)
            * 12,
            2
        )                                               AS clv_12_mois
    FROM stats_client sc
    JOIN clients cl ON sc.id_client = cl.id_client
)
SELECT
    client,
    segment,
    nb_commandes,
    ca_total,
    panier_moyen,
    freq_mensuelle,
    clv_12_mois,
    NTILE(4) OVER (ORDER BY clv_12_mois DESC)           AS quartile_clv
    -- 1 = top 25%, 4 = bottom 25%
FROM clv_par_client
ORDER BY clv_12_mois DESC;

────────────────────────────────────────
KPI 5 : Taux d'annulation et de remboursement
────────────────────────────────────────

SELECT
    DATE_TRUNC('month', date_commande)           AS mois,
    COUNT(*)                                     AS total_commandes,
    COUNT(*) FILTER (WHERE statut = 'ANNULEE')   AS nb_annulees,
    COUNT(*) FILTER (WHERE statut = 'REMBOURSEE') AS nb_remboursees,
    ROUND(
        COUNT(*) FILTER (WHERE statut = 'ANNULEE')::DECIMAL
        / COUNT(*) * 100, 1
    )                                            AS taux_annulation_pct,
    ROUND(
        COUNT(*) FILTER (WHERE statut = 'REMBOURSEE')::DECIMAL
        / COUNT(*) * 100, 1
    )                                            AS taux_remboursement_pct,
    ROUND(
        (COUNT(*) FILTER (WHERE statut IN ('ANNULEE', 'REMBOURSEE')))::DECIMAL
        / COUNT(*) * 100, 1
    )                                            AS taux_perte_total_pct
FROM commandes
GROUP BY DATE_TRUNC('month', date_commande)
ORDER BY mois;

-- Un taux d'annulation > 5% ou de remboursement > 3% mérite investigation.

────────────────────────────────────────
KPI 6 : Net Promoter Score (NPS) proxy via les avis
────────────────────────────────────────

-- On n'a pas de vraie question NPS, mais les avis (1-5 étoiles) permettent
-- un proxy : promoteurs (5*), passifs (4*), détracteurs (1-3*)

WITH nps_classification AS (
    SELECT
        DATE_TRUNC('month', date_avis)           AS mois,
        CASE
            WHEN note = 5               THEN 'promoteur'
            WHEN note = 4               THEN 'passif'
            ELSE                             'detracteur'
        END                                      AS classification,
        COUNT(*)                                 AS nb
    FROM avis
    GROUP BY DATE_TRUNC('month', date_avis), classification
),
nps_pivot AS (
    SELECT
        mois,
        SUM(nb)                                  AS total_avis,
        SUM(nb) FILTER (WHERE classification = 'promoteur')   AS promoteurs,
        SUM(nb) FILTER (WHERE classification = 'passif')      AS passifs,
        SUM(nb) FILTER (WHERE classification = 'detracteur')  AS detracteurs
    FROM nps_classification
    GROUP BY mois
)
SELECT
    TO_CHAR(mois, 'YYYY-MM')                     AS periode,
    total_avis,
    promoteurs,
    passifs,
    detracteurs,
    -- NPS = %promoteurs - %détracteurs (échelle -100 à +100)
    ROUND(
        (promoteurs::DECIMAL / total_avis * 100)
        - (detracteurs::DECIMAL / total_avis * 100),
        0
    )                                            AS nps_score
FROM nps_pivot
ORDER BY mois;

-- NPS > 50 = excellent, 30-50 = bon, 0-30 = à améliorer, < 0 = problème

52.5 KPIs DE CROISSANCE : COMPARAISONS PÉRIODE SUR PÉRIODE
────────────────────────────────────────────────────────────

-- Comparer mois actuel vs même mois année précédente (YoY) et mois précédent (MoM)

WITH ca_mensuel AS (
    SELECT
        DATE_TRUNC('month', c.date_commande)     AS mois,
        SUM(c.montant_ttc)                       AS ca
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY DATE_TRUNC('month', c.date_commande)
)
SELECT
    TO_CHAR(mois, 'YYYY-MM')                     AS periode,
    ROUND(ca, 2)                                 AS ca_actuel,
    -- Mois précédent (MoM)
    ROUND(LAG(ca, 1) OVER (ORDER BY mois), 2)    AS ca_mois_prec,
    ROUND(
        (ca - LAG(ca, 1) OVER (ORDER BY mois))
        / NULLIF(LAG(ca, 1) OVER (ORDER BY mois), 0) * 100,
        1
    )                                            AS evolution_mom_pct,
    -- Même mois année précédente (YoY)
    ROUND(LAG(ca, 12) OVER (ORDER BY mois), 2)   AS ca_meme_mois_an_dernier,
    ROUND(
        (ca - LAG(ca, 12) OVER (ORDER BY mois))
        / NULLIF(LAG(ca, 12) OVER (ORDER BY mois), 0) * 100,
        1
    )                                            AS evolution_yoy_pct
FROM ca_mensuel
ORDER BY mois DESC;

52.6 BONNES PRATIQUES KPIs
────────────────────────────

1. DÉFINIR clairement le numérateur et le dénominateur de chaque KPI
   avant d'écrire le SQL. Ambiguïté = KPIs différents selon les équipes.

2. EXCLURE systématiquement les commandes annulées et remboursées
   (sauf si vous calculez le taux d'annulation lui-même).

3. FIGER les définitions dans des vues ou des CTEs nommées pour
   garantir la cohérence entre les rapports.

4. DOCUMENTER les hypothèses : "CA calculé HT, hors frais de port,
   commandes livrées uniquement depuis le 2024-01-01".

5. VALIDER contre les chiffres comptables : votre SQL doit produire
   le même résultat que la comptabilité pour le CA.

52.7 ERREURS FRÉQUENTES DANS LE CALCUL DE KPIs
─────────────────────────────────────────────────

Erreur 1 : Double comptage avec les JOIN
  -- MAUVAIS : une commande avec 3 lignes compte 3 fois dans SUM(montant_ttc)
  SELECT SUM(c.montant_ttc) AS ca
  FROM commandes c
  JOIN lignes_commande lc ON c.id_commande = lc.id_commande;
  -- montant_ttc est multiplié par le nombre de lignes !

  -- CORRECT 1 : agréger au niveau lignes (CA produit par produit)
  SELECT SUM(lc.quantite * lc.prix_unitaire_ht * 1.2) AS ca_ttc
  FROM lignes_commande lc
  JOIN commandes c ON lc.id_commande = c.id_commande
  WHERE c.statut NOT IN ('ANNULEE');

  -- CORRECT 2 : calculer le CA au niveau commande
  SELECT SUM(DISTINCT c.montant_ttc) AS ca_ttc -- DISTINCT si nécessaire
  FROM commandes c
  JOIN lignes_commande lc ON c.id_commande = lc.id_commande;
  -- Attention: SUM(DISTINCT ...) peut être trompeur

  -- MEILLEURE APPROCHE : ne pas JOIN si seule la table commandes est nécessaire
  SELECT SUM(montant_ttc) AS ca_ttc FROM commandes
  WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE');

Erreur 2 : Oublier les commandes sans lignes (commandes vides)
  -- Si vous calculez depuis lignes_commande, vous manquez les commandes vides.
  -- Solution : partir de commandes et LEFT JOIN vers lignes_commande.

Erreur 3 : Confondre COUNT(*) et COUNT(DISTINCT id_client)
  -- COUNT(*) = nombre de lignes (pas de clients uniques)
  -- COUNT(DISTINCT id_client) = nombre de clients ayant commandé

Erreur 4 : Ne pas gérer les NULL dans les calculs de taux
  -- NULLIF() protège contre la division par zéro
  -- COALESCE() remplace les NULL par 0 dans les SUM/AVG

52.8 EXERCICES PRATIQUES — KPIs
─────────────────────────────────

NIVEAU FACILE :

Ex1 : Calculez le CA HT et TTC total du mois de mars 2024
      en excluant les commandes annulées et remboursées.
      Affichez aussi le nombre de commandes et le panier moyen.

Ex2 : Calculez le taux de clients VIP parmi l'ensemble des clients
      (clients dont le segment = 'VIP' / total clients × 100).

Ex3 : Trouvez le mois avec le chiffre d'affaires le plus élevé
      sur l'ensemble des données disponibles.

NIVEAU INTERMÉDIAIRE :

Ex4 : Calculez la note moyenne par catégorie de produit,
      le nombre d'avis, et le pourcentage d'avis positifs (note ≥ 4).
      Triez par note moyenne décroissante.

Ex5 : Calculez le taux de réachat : pourcentage de clients
      ayant passé au moins 2 commandes distinctes.

Ex6 : Pour chaque trimestre de 2024, calculez le CA, le nombre
      de commandes, et la variation par rapport au trimestre précédent.

NIVEAU AVANCÉ :

Ex7 : Calculez le CLV segmenté : CLV moyen par segment client
      (VIP, PREMIUM, STANDARD, NOUVEAU), avec le nombre de clients
      dans chaque segment et le CA total par segment.

Ex8 : Construisez un tableau de "health score" de la base client :
      pour chaque client, calculez un score de 0 à 100 basé sur :
      - Récence : jours depuis la dernière commande (max 40 pts : 0j=40, 365j+=0)
      - Fréquence : nombre de commandes (max 30 pts : 10+=30)
      - Valeur : CA total (max 30 pts : 1000€+=30)
      Classifiez : 70-100 = "Champion", 40-69 = "Régulier", 0-39 = "À risque"

Ex9 : Calculez le Product-Market Fit proxy :
      Pour chaque produit, calculez le taux de clients qui l'ont
      recommandé (avis 5*) vs ceux qui l'ont décrié (avis ≤ 2*).
      Identifiez les produits PMF-positifs (taux recommandation > 60%)
      et les produits problématiques (taux décrié > 30%).

52.9 CORRIGÉS DÉTAILLÉS
─────────────────────────

CORRIGÉ Ex1 (CA mars 2024) :

  SELECT
      COUNT(DISTINCT c.id_commande)               AS nb_commandes,
      COUNT(DISTINCT c.id_client)                 AS nb_clients_uniques,
      ROUND(SUM(lc.quantite * lc.prix_unitaire_ht), 2) AS ca_ht,
      ROUND(SUM(
          lc.quantite * lc.prix_unitaire_ht * (1 + lc.taux_tva / 100)
      ), 2)                                        AS ca_ttc,
      ROUND(AVG(c.montant_ttc), 2)                AS panier_moyen_ttc
  FROM commandes c
  JOIN lignes_commande lc ON c.id_commande = lc.id_commande
  WHERE c.date_commande >= '2024-03-01'
    AND c.date_commande < '2024-04-01'
    AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE');

CORRIGÉ Ex5 (taux de réachat) :

  WITH achats_par_client AS (
      SELECT
          id_client,
          COUNT(DISTINCT id_commande)             AS nb_commandes
      FROM commandes
      WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
      GROUP BY id_client
  )
  SELECT
      COUNT(*)                                    AS total_clients_ayant_commande,
      COUNT(*) FILTER (WHERE nb_commandes >= 2)   AS clients_reacha,
      ROUND(
          COUNT(*) FILTER (WHERE nb_commandes >= 2)::DECIMAL
          / COUNT(*) * 100, 1
      )                                           AS taux_reachat_pct
  FROM achats_par_client;

CORRIGÉ Ex8 (Health Score RFM) :

  WITH derniere_commande AS (
      SELECT
          id_client,
          MAX(date_commande)                      AS derniere_cmd,
          COUNT(DISTINCT id_commande)             AS nb_commandes,
          SUM(montant_ttc)                        AS ca_total
      FROM commandes
      WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
      GROUP BY id_client
  ),
  scores AS (
      SELECT
          dc.id_client,
          cl.prenom || ' ' || cl.nom              AS client,
          -- Récence : 40 pts max, dégression linéaire sur 365 jours
          GREATEST(0,
              ROUND(40 * (1 - LEAST(
                  EXTRACT(DAYS FROM NOW() - dc.derniere_cmd) / 365.0, 1
              )), 0)
          )                                       AS score_recence,
          -- Fréquence : 30 pts max, 10+ commandes = max
          LEAST(30, ROUND(dc.nb_commandes * 3, 0)) AS score_frequence,
          -- Valeur : 30 pts max, 1000€+ = max
          LEAST(30, ROUND(dc.ca_total / 1000.0 * 30, 0)) AS score_valeur
      FROM derniere_commande dc
      JOIN clients cl ON dc.id_client = cl.id_client
  )
  SELECT
      client,
      score_recence,
      score_frequence,
      score_valeur,
      score_recence + score_frequence + score_valeur AS health_score,
      CASE
          WHEN score_recence + score_frequence + score_valeur >= 70 THEN 'Champion'
          WHEN score_recence + score_frequence + score_valeur >= 40 THEN 'Régulier'
          ELSE 'À risque'
      END                                           AS classification
  FROM scores
  ORDER BY health_score DESC;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 53 — DASHBOARDS SQL : TABLEAUX DE BORD ANALYTIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

53.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce qu'un dashboard SQL ?
  Un dashboard est un ensemble de requêtes SQL organisées pour offrir une vue
  synthétique de la performance d'une entreprise. Chaque "widget" du dashboard
  correspond à une requête SQL précise.

Pourquoi les dashboards SQL ?
  Les outils de BI (Tableau, Metabase, PowerBI, Grafana) se connectent
  directement à PostgreSQL et exécutent des requêtes SQL pour afficher
  des graphiques. Maîtriser le SQL derrière chaque widget = maîtriser
  les données affichées.

Architecture typique :

  Application BI (Metabase, Tableau, etc.)
         v requête SQL
  PostgreSQL / ShopFlow
         v résultat
  Graphique affiché dans le dashboard

53.2 DASHBOARD EXÉCUTIF : VUE GLOBALE
───────────────────────────────────────

-- Ce dashboard regroupe les métriques clés sur une seule vue :
-- CA, commandes, clients, produits les plus vendus.

────────────────────────────────────────
Widget 1 : Métriques du mois en cours vs mois précédent
────────────────────────────────────────

WITH mois_actuel AS (
    SELECT
        SUM(c.montant_ttc)                              AS ca,
        COUNT(DISTINCT c.id_commande)                   AS nb_commandes,
        COUNT(DISTINCT c.id_client)                     AS nb_clients_actifs,
        ROUND(AVG(c.montant_ttc), 2)                    AS aov
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND DATE_TRUNC('month', c.date_commande) = DATE_TRUNC('month', NOW())
),
mois_prec AS (
    SELECT
        SUM(c.montant_ttc)                              AS ca,
        COUNT(DISTINCT c.id_commande)                   AS nb_commandes,
        COUNT(DISTINCT c.id_client)                     AS nb_clients_actifs,
        ROUND(AVG(c.montant_ttc), 2)                    AS aov
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND DATE_TRUNC('month', c.date_commande) =
          DATE_TRUNC('month', NOW()) - INTERVAL '1 month'
)
SELECT
    ROUND(ma.ca, 2)                                     AS ca_mois_actuel,
    ROUND(mp.ca, 2)                                     AS ca_mois_precedent,
    ROUND((ma.ca - mp.ca) / NULLIF(mp.ca, 0) * 100, 1) AS variation_ca_pct,
    ma.nb_commandes,
    mp.nb_commandes                                     AS nb_commandes_prec,
    ma.nb_clients_actifs,
    ma.aov,
    mp.aov                                              AS aov_prec,
    ROUND((ma.aov - mp.aov) / NULLIF(mp.aov, 0) * 100, 1) AS variation_aov_pct
FROM mois_actuel ma, mois_prec mp;

────────────────────────────────────────
Widget 2 : Courbe de CA sur 12 mois (données pour graphique linéaire)
────────────────────────────────────────

SELECT
    DATE_TRUNC('month', c.date_commande)                AS mois,
    TO_CHAR(DATE_TRUNC('month', c.date_commande), 'Mon YY') AS label,
    ROUND(SUM(c.montant_ttc), 2)                        AS ca,
    COUNT(DISTINCT c.id_commande)                       AS nb_commandes,
    COUNT(DISTINCT c.id_client)                         AS nb_clients
FROM commandes c
WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
  AND c.date_commande >= NOW() - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', c.date_commande)
ORDER BY mois;

────────────────────────────────────────
Widget 3 : Répartition des ventes par catégorie (camembert / barres)
────────────────────────────────────────

WITH ventes_categorie AS (
    SELECT
        cat.nom                                         AS categorie,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100))                 AS ca_ttc
    FROM lignes_commande lc
    JOIN commandes c    ON lc.id_commande = c.id_commande
    JOIN produits p     ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND c.date_commande >= DATE_TRUNC('year', NOW())
    GROUP BY cat.nom
)
SELECT
    categorie,
    ROUND(ca_ttc, 2)                                   AS ca,
    ROUND(ca_ttc / SUM(ca_ttc) OVER () * 100, 1)       AS part_marche_pct,
    -- Rang par CA
    RANK() OVER (ORDER BY ca_ttc DESC)                 AS rang
FROM ventes_categorie
ORDER BY ca_ttc DESC;

-- SUM(ca_ttc) OVER () = somme totale sur toutes les catégories (window function)
-- Permet de calculer le pourcentage de chaque catégorie dans le total.

────────────────────────────────────────
Widget 4 : Top 10 produits du mois (tableau classement)
────────────────────────────────────────

SELECT
    RANK() OVER (ORDER BY SUM(lc.quantite) DESC)        AS rang,
    p.reference,
    p.nom                                               AS produit,
    cat.nom                                             AS categorie,
    SUM(lc.quantite)                                    AS unites_vendues,
    ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
              * (1 + lc.taux_tva / 100)), 2)           AS ca_ttc,
    ROUND(AVG(
        CASE WHEN a.note IS NOT NULL THEN a.note END
    ), 1)                                               AS note_moyenne
FROM lignes_commande lc
JOIN commandes c    ON lc.id_commande = c.id_commande
JOIN produits p     ON lc.id_produit = p.id_produit
JOIN categories cat ON p.id_categorie = cat.id_categorie
LEFT JOIN avis a    ON p.id_produit = a.id_produit
WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
  AND DATE_TRUNC('month', c.date_commande) = DATE_TRUNC('month', NOW())
GROUP BY p.id_produit, p.reference, p.nom, cat.nom
ORDER BY unites_vendues DESC
LIMIT 10;

────────────────────────────────────────
Widget 5 : Heatmap des commandes par jour de la semaine × heure
────────────────────────────────────────

-- Données pour une heatmap (quel jour/heure est le plus actif ?)
SELECT
    EXTRACT(DOW FROM date_commande)::INTEGER            AS jour_semaine,
    -- 0 = dimanche, 1 = lundi, ..., 6 = samedi
    TO_CHAR(date_commande, 'Day')                       AS nom_jour,
    EXTRACT(HOUR FROM date_commande)::INTEGER           AS heure,
    COUNT(*)                                            AS nb_commandes,
    ROUND(AVG(montant_ttc), 2)                          AS panier_moyen
FROM commandes
WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
  AND date_commande >= NOW() - INTERVAL '90 days'
GROUP BY EXTRACT(DOW FROM date_commande),
         TO_CHAR(date_commande, 'Day'),
         EXTRACT(HOUR FROM date_commande)
ORDER BY jour_semaine, heure;

-- Interprétation : identifie les créneaux où envoyer des emails marketing
-- ou mettre en avant des promotions flash.

────────────────────────────────────────
Widget 6 : Entonnoir de commandes (funnel)
────────────────────────────────────────

-- Visualiser les pertes à chaque étape du processus de commande

SELECT
    statut,
    COUNT(*)                                            AS nb_commandes,
    ROUND(COUNT(*)::DECIMAL / SUM(COUNT(*)) OVER () * 100, 1) AS pct_total,
    ROUND(SUM(montant_ttc), 2)                          AS ca_total,
    CASE statut
        WHEN 'EN_ATTENTE'    THEN 1
        WHEN 'CONFIRMEE'     THEN 2
        WHEN 'EN_PREPARATION' THEN 3
        WHEN 'EXPEDIEE'      THEN 4
        WHEN 'LIVREE'        THEN 5
        WHEN 'ANNULEE'       THEN 6
        WHEN 'REMBOURSEE'    THEN 7
    END                                                 AS ordre_affichage
FROM commandes
GROUP BY statut
ORDER BY ordre_affichage;

53.3 DASHBOARD LOGISTIQUE
──────────────────────────

────────────────────────────────────────
Widget : Délais de livraison par mode
────────────────────────────────────────

SELECT
    -- On suppose qu'on a un champ mode_livraison et date_livraison
    statut,
    COUNT(*)                                            AS nb_commandes,
    ROUND(AVG(
        EXTRACT(DAYS FROM date_livraison - date_commande)
    ), 1)                                               AS delai_moyen_jours,
    ROUND(MIN(
        EXTRACT(DAYS FROM date_livraison - date_commande)
    ), 0)                                               AS delai_min_jours,
    ROUND(MAX(
        EXTRACT(DAYS FROM date_livraison - date_commande)
    ), 0)                                               AS delai_max_jours,
    -- Percentile 95 : 95% des livraisons sont en dessous de ce délai
    PERCENTILE_CONT(0.95) WITHIN GROUP (
        ORDER BY EXTRACT(DAYS FROM date_livraison - date_commande)
    )                                                   AS p95_delai_jours
FROM commandes
WHERE statut = 'LIVREE'
  AND date_livraison IS NOT NULL
GROUP BY statut;

────────────────────────────────────────
Widget : Alertes stock critique
────────────────────────────────────────

SELECT
    p.reference,
    p.nom                                               AS produit,
    cat.nom                                             AS categorie,
    p.stock                                             AS stock_actuel,
    p.stock_min,
    CASE
        WHEN p.stock = 0                THEN '[ROUGE] RUPTURE'
        WHEN p.stock < p.stock_min      THEN '[ORANGE] CRITIQUE'
        WHEN p.stock < p.stock_min * 2  THEN '[JAUNE] FAIBLE'
        ELSE                                 '[VERT] OK'
    END                                                 AS alerte,
    -- Ventes moyennes/jour sur les 30 derniers jours
    ROUND(COALESCE(stats.unites_30j / 30.0, 0), 1)     AS ventes_jour,
    -- Nombre de jours de stock restant
    CASE
        WHEN COALESCE(stats.unites_30j / 30.0, 0) = 0 THEN NULL
        ELSE ROUND(p.stock / (stats.unites_30j / 30.0), 0)
    END                                                 AS jours_de_stock
FROM produits p
JOIN categories cat ON p.id_categorie = cat.id_categorie
LEFT JOIN (
    SELECT
        lc.id_produit,
        SUM(lc.quantite)                               AS unites_30j
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND c.date_commande >= NOW() - INTERVAL '30 days'
    GROUP BY lc.id_produit
) stats ON p.id_produit = stats.id_produit
WHERE p.actif = TRUE
ORDER BY
    CASE
        WHEN p.stock = 0           THEN 1
        WHEN p.stock < p.stock_min THEN 2
        ELSE 3
    END,
    jours_de_stock NULLS LAST;

53.4 DASHBOARD MARKETING
─────────────────────────

────────────────────────────────────────
Widget : Acquisition de nouveaux clients par source (simulé)
────────────────────────────────────────

-- On suppose un champ source_acquisition dans la table clients
-- (SEO, Paid, Email, Direct, Referral)

SELECT
    -- Si pas de champ source, on segmente par période d'inscription
    segment,
    DATE_TRUNC('month', date_inscription)               AS mois,
    COUNT(*)                                            AS nouveaux_clients,
    -- Revenu généré par ces clients dans leur premier mois
    COALESCE(SUM(commandes_premier_mois.ca), 0)         AS ca_premier_mois
FROM clients cl
LEFT JOIN (
    SELECT
        c.id_client,
        SUM(c.montant_ttc)                             AS ca
    FROM commandes c
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND DATE_TRUNC('month', c.date_commande)
          = (SELECT DATE_TRUNC('month', MIN(date_inscription))
             FROM clients WHERE id_client = c.id_client)
    GROUP BY c.id_client
) commandes_premier_mois ON cl.id_client = commandes_premier_mois.id_client
GROUP BY segment, DATE_TRUNC('month', date_inscription)
ORDER BY mois DESC, nouveaux_clients DESC;

53.5 VUES MATÉRIALISÉES POUR LES DASHBOARDS
─────────────────────────────────────────────

-- Les dashboards sont souvent consultés par de nombreux utilisateurs.
-- Pour éviter que chaque consultation recalcule les KPIs sur des millions
-- de lignes, on utilise des vues matérialisées rafraîchies régulièrement.

-- Vue matérialisée : rapport quotidien pré-calculé
CREATE MATERIALIZED VIEW mv_kpi_quotidien AS
WITH base AS (
    SELECT
        DATE_TRUNC('day', c.date_commande)              AS jour,
        c.id_commande,
        c.id_client,
        c.montant_ttc,
        c.statut
    FROM commandes c
    WHERE c.date_commande >= NOW() - INTERVAL '13 months'
)
SELECT
    jour,
    COUNT(DISTINCT id_commande) FILTER (WHERE statut NOT IN ('ANNULEE'))  AS nb_commandes,
    COUNT(DISTINCT id_client)   FILTER (WHERE statut NOT IN ('ANNULEE'))  AS nb_clients_actifs,
    ROUND(SUM(montant_ttc)      FILTER (WHERE statut NOT IN ('ANNULEE','REMBOURSEE')), 2) AS ca,
    ROUND(AVG(montant_ttc)      FILTER (WHERE statut NOT IN ('ANNULEE','REMBOURSEE')), 2) AS aov,
    COUNT(DISTINCT id_commande) FILTER (WHERE statut = 'ANNULEE') AS nb_annulees
FROM base
GROUP BY jour
WITH DATA;

CREATE UNIQUE INDEX ON mv_kpi_quotidien (jour);

-- Rafraîchir chaque nuit (sans blocage grâce à CONCURRENTLY)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_kpi_quotidien;

-- Maintenant les dashboards lisent mv_kpi_quotidien = instantané
SELECT * FROM mv_kpi_quotidien
WHERE jour >= NOW() - INTERVAL '30 days'
ORDER BY jour DESC;

53.6 EXERCICES PRATIQUES — DASHBOARDS SQL
──────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Écrivez la requête SQL pour le widget "Répartition des commandes
      par statut aujourd'hui" (camembert). Affichez : statut, nombre,
      pourcentage du total.

Ex2 : Construisez le widget "Top 5 fournisseurs par CA cette année",
      avec le nombre de produits vendus et la note moyenne.

Ex3 : Créez le widget "Nouveaux clients ce mois vs mois dernier"
      (comptage + variation en %).

NIVEAU INTERMÉDIAIRE :

Ex4 : Construisez le widget "Heatmap des ventes : CA par jour de la
      semaine et par semaine du mois". Résultat : une grille
      7 jours × 4-5 semaines avec le CA dans chaque case.

Ex5 : Widget "Évolution du stock moyen par catégorie sur les
      3 derniers mois". Calculez la variation stock_actuel
      vs il y a 1 mois et 3 mois.

Ex6 : Dashboard "Performance employés" : pour chaque employé,
      affichez le CA généré, le nombre de commandes, le panier moyen,
      et un badge (Bronze < 5000€, Silver 5000-15000€, Gold > 15000€).

NIVEAU AVANCÉ :

Ex7 : Widget "Matrice RFM" (Récence × Fréquence × Valeur).
      Segmentez les clients en 3×3×3 = 27 cellules RFM. Affichez
      les 9 segments les plus peuplés avec leur CA moyen.

Ex8 : Dashboard "Prévision vs Réel" : comparez le CA des 30 derniers
      jours à une prévision simple (moyenne des 3 mois précédents
      ajustée par le mois de l'année via un coefficient saisonnier).

Ex9 : Construisez une vue matérialisée mv_dashboard_commercial rafraîchissable
      sans blocage, contenant les KPIs mensuels des 24 derniers mois :
      CA, commandes, clients, AOV, taux annulation. Créez les index
      appropriés et documentez le script de rafraîchissement.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 54 — ANALYSE DES VENTES : RAPPORTS AVANCÉS ET INSIGHTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

54.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que l'analyse des ventes en SQL ?
  L'analyse des ventes transforme les données transactionnelles brutes
  en insights actionnables : quels produits performent, où concentrer
  les efforts commerciaux, quelles tendances émergent.

Pourquoi ça existe ?
  Sans analyse, une entreprise navigue à vue. L'analyse SQL révèle :
  - Les produits stars vs les poids morts
  - Les segments clients profitables vs coûteux
  - Les tendances saisonnières
  - Les opportunités de cross-sell et upsell

54.2 ANALYSE ABC : CLASSER LES PRODUITS PAR CONTRIBUTION
──────────────────────────────────────────────────────────

-- L'analyse ABC classifie les produits selon le principe de Pareto :
-- A : 20% des produits génèrent 80% du CA
-- B : 30% des produits génèrent 15% du CA
-- C : 50% des produits génèrent 5% du CA

WITH ca_par_produit AS (
    SELECT
        p.id_produit,
        p.reference,
        p.nom                                           AS produit,
        cat.nom                                         AS categorie,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100))                 AS ca_ttc,
        SUM(lc.quantite)                               AS unites_vendues
    FROM lignes_commande lc
    JOIN commandes c    ON lc.id_commande = c.id_commande
    JOIN produits p     ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND c.date_commande >= DATE_TRUNC('year', NOW())
    GROUP BY p.id_produit, p.reference, p.nom, cat.nom
),
ca_cumule AS (
    SELECT
        *,
        SUM(ca_ttc) OVER ()                            AS ca_total,
        SUM(ca_ttc) OVER (ORDER BY ca_ttc DESC
                          ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
                                                       AS ca_cumule,
        ROW_NUMBER() OVER (ORDER BY ca_ttc DESC)        AS rang
    FROM ca_par_produit
),
classement AS (
    SELECT
        *,
        ca_cumule / ca_total * 100                     AS pct_cumule,
        ca_ttc / ca_total * 100                        AS pct_individuel
    FROM ca_cumule
)
SELECT
    rang,
    reference,
    produit,
    categorie,
    ROUND(ca_ttc, 2)                                   AS ca,
    unites_vendues,
    ROUND(pct_individuel, 1)                           AS pct_ca,
    ROUND(pct_cumule, 1)                               AS pct_cumule,
    CASE
        WHEN pct_cumule <= 80   THEN 'A — Priorité maximale'
        WHEN pct_cumule <= 95   THEN 'B — Importance secondaire'
        ELSE                         'C — Révision ou retrait'
    END                                                AS classe_abc
FROM classement
ORDER BY rang;

-- Résumé par classe ABC
WITH abc AS (-- [la requête ci-dessus incluse comme CTE]
    SELECT 'A' AS classe, COUNT(*) AS nb_produits, SUM(ca) AS ca
    -- ... (résultat réutilisé)
    UNION ALL SELECT 'B' ...
    UNION ALL SELECT 'C' ...
)
-- Afficher le résumé...

54.3 ANALYSE DE LA SAISONNALITÉ
─────────────────────────────────

-- Identifier les variations saisonnières pour anticiper les stocks et les campagnes

WITH ventes_mensuelles AS (
    SELECT
        EXTRACT(MONTH FROM c.date_commande)::INTEGER    AS mois_num,
        TO_CHAR(c.date_commande, 'Month')               AS mois_nom,
        EXTRACT(YEAR FROM c.date_commande)::INTEGER     AS annee,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100))                 AS ca
    FROM commandes c
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY EXTRACT(MONTH FROM c.date_commande),
             TO_CHAR(c.date_commande, 'Month'),
             EXTRACT(YEAR FROM c.date_commande)
),
moyenne_annuelle AS (
    SELECT annee, AVG(ca) AS ca_moyen_mensuel
    FROM ventes_mensuelles
    GROUP BY annee
)
SELECT
    vm.mois_num,
    vm.mois_nom,
    vm.annee,
    ROUND(vm.ca, 2)                                    AS ca_mois,
    ROUND(ma.ca_moyen_mensuel, 2)                      AS ca_moyen_annuel,
    -- Indice saisonnier : > 1 = mois fort, < 1 = mois faible
    ROUND(vm.ca / NULLIF(ma.ca_moyen_mensuel, 0), 2)   AS indice_saisonnier
FROM ventes_mensuelles vm
JOIN moyenne_annuelle ma USING (annee)
ORDER BY vm.annee, vm.mois_num;

-- Un indice de 1.35 en décembre = 35% au-dessus de la moyenne mensuelle.
-- Cela permet de planifier les stocks et les équipes.

54.4 ANALYSE DE COHORTES (COHORT ANALYSIS)
────────────────────────────────────────────

-- L'analyse de cohortes mesure le comportement des clients sur le long terme,
-- regroupés par leur mois d'inscription (cohorte).
-- Question : "Les clients inscrits en janvier ont-ils plus de réachats que ceux de mars ?"

WITH cohortes AS (
    -- Chaque client appartient à la cohorte de son mois d'inscription
    SELECT
        id_client,
        DATE_TRUNC('month', date_inscription)           AS cohorte_mois
    FROM clients
),
activite_mensuelle AS (
    -- Pour chaque commande, calculer le mois depuis l'inscription (mois 0, 1, 2...)
    SELECT
        c.id_client,
        co.cohorte_mois,
        DATE_TRUNC('month', c.date_commande)            AS mois_achat,
        -- Mois 0 = mois d'inscription, Mois 1 = mois suivant, etc.
        EXTRACT(YEAR FROM AGE(
            DATE_TRUNC('month', c.date_commande),
            co.cohorte_mois
        )) * 12
        + EXTRACT(MONTH FROM AGE(
            DATE_TRUNC('month', c.date_commande),
            co.cohorte_mois
        ))                                              AS mois_depuis_inscription
    FROM commandes c
    JOIN cohortes co ON c.id_client = co.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
),
taille_cohortes AS (
    -- Nombre de clients par cohorte
    SELECT cohorte_mois, COUNT(DISTINCT id_client) AS taille
    FROM cohortes
    GROUP BY cohorte_mois
),
retention_cohorte AS (
    -- Pour chaque cohorte et chaque mois, compter les clients actifs
    SELECT
        am.cohorte_mois,
        am.mois_depuis_inscription,
        COUNT(DISTINCT am.id_client)                   AS clients_actifs
    FROM activite_mensuelle am
    GROUP BY am.cohorte_mois, am.mois_depuis_inscription
)
SELECT
    TO_CHAR(rc.cohorte_mois, 'YYYY-MM')                AS cohorte,
    tc.taille                                          AS taille_cohorte,
    rc.mois_depuis_inscription                         AS mois,
    rc.clients_actifs,
    ROUND(rc.clients_actifs::DECIMAL / tc.taille * 100, 1) AS taux_retention_pct
FROM retention_cohorte rc
JOIN taille_cohortes tc ON rc.cohorte_mois = tc.cohorte_mois
WHERE rc.mois_depuis_inscription <= 12  -- Afficher sur 12 mois
ORDER BY rc.cohorte_mois, rc.mois_depuis_inscription;

-- Exemple de lecture :
-- Cohorte 2024-01, Mois 0 : 38 clients (100%)
-- Cohorte 2024-01, Mois 1 : 22 clients (57.9%)
-- Cohorte 2024-01, Mois 3 : 12 clients (31.6%)
-- -> 68% des clients du mois de janvier n'ont pas commandé le mois suivant.

54.5 ANALYSE MARKET BASKET (PRODUITS ACHETÉS ENSEMBLE)
────────────────────────────────────────────────────────

-- Quels produits sont souvent achetés ensemble ? (recommandations de type "Clients aussi achetés")
-- Technique : calculer les co-occurrences dans les mêmes commandes.

WITH paires_produits AS (
    SELECT
        lc1.id_produit                                 AS produit_a,
        lc2.id_produit                                 AS produit_b,
        COUNT(DISTINCT lc1.id_commande)                AS nb_co_achats
    FROM lignes_commande lc1
    JOIN lignes_commande lc2
        ON lc1.id_commande = lc2.id_commande
        AND lc1.id_produit < lc2.id_produit  -- éviter les doublons et l'auto-paire
    JOIN commandes c ON lc1.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY lc1.id_produit, lc2.id_produit
    HAVING COUNT(DISTINCT lc1.id_commande) >= 2  -- minimum 2 co-achats
),
ventes_produit AS (
    SELECT id_produit, COUNT(DISTINCT id_commande) AS nb_ventes
    FROM lignes_commande
    JOIN commandes USING (id_commande)
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY id_produit
)
SELECT
    pa.nom                                             AS produit_a,
    pb.nom                                             AS produit_b,
    pp.nb_co_achats,
    -- Confiance A->B : P(B | A) = P(A et B) / P(A)
    ROUND(pp.nb_co_achats::DECIMAL / va.nb_ventes * 100, 1) AS confiance_a_vers_b_pct,
    -- Lift : combien de fois plus souvent achetés ensemble que par hasard
    ROUND(
        (pp.nb_co_achats::DECIMAL / (SELECT COUNT(DISTINCT id_commande) FROM commandes))
        / (va.nb_ventes::DECIMAL / (SELECT COUNT(DISTINCT id_commande) FROM commandes))
        / (vb.nb_ventes::DECIMAL / (SELECT COUNT(DISTINCT id_commande) FROM commandes)),
        2
    )                                                  AS lift
FROM paires_produits pp
JOIN produits pa  ON pp.produit_a = pa.id_produit
JOIN produits pb  ON pp.produit_b = pb.id_produit
JOIN ventes_produit va ON pp.produit_a = va.id_produit
JOIN ventes_produit vb ON pp.produit_b = vb.id_produit
ORDER BY pp.nb_co_achats DESC, lift DESC
LIMIT 20;

-- Lift > 1 = achetés ensemble plus souvent que par hasard.
-- Lift = 2.5 -> ces deux produits sont 2.5x plus souvent achetés ensemble.

54.6 ANALYSE DES TENDANCES PAR RÉGRESSION LINÉAIRE SIMPLE
────────────────────────────────────────────────────────────

-- PostgreSQL peut calculer une droite de tendance via REGR_SLOPE et REGR_INTERCEPT.
-- Utile pour prévoir le CA des prochains mois.

WITH ca_mensuel AS (
    SELECT
        -- Transformer les mois en numéros séquentiels pour la régression
        EXTRACT(EPOCH FROM DATE_TRUNC('month', date_commande)) AS t,
        SUM(montant_ttc)                               AS ca
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND date_commande >= NOW() - INTERVAL '12 months'
    GROUP BY DATE_TRUNC('month', date_commande)
)
SELECT
    -- Pente de la tendance (CA supplémentaire par seconde -> convertir en mois)
    ROUND(REGR_SLOPE(ca, t) * 60 * 60 * 24 * 30, 2)   AS croissance_mensuelle_estimee,
    -- Intercept (valeur au temps 0, peu utile directement)
    ROUND(REGR_INTERCEPT(ca, t), 2)                    AS intercept,
    -- R² : qualité du fit (1 = tendance parfaite, 0 = aucune tendance)
    ROUND(REGR_R2(ca, t), 3)                           AS r_carre,
    -- Corrélation
    ROUND(CORR(ca, t), 3)                              AS correlation
FROM ca_mensuel;

-- Si R² > 0.7, la tendance linéaire est significative.
-- croissance_mensuelle_estimee = gain mensuel moyen en euros.

54.7 RAPPORT COMPLET : REVUE MENSUELLE DES VENTES
───────────────────────────────────────────────────

-- Ce rapport consolide les insights principaux pour une revue business mensuelle.
-- Conçu pour être exécuté en début de mois pour le mois précédent.

DO $$
DECLARE
    p_mois DATE := DATE_TRUNC('month', NOW()) - INTERVAL '1 month';
    p_mois_prec DATE := DATE_TRUNC('month', NOW()) - INTERVAL '2 months';
BEGIN
    RAISE NOTICE 'Rapport Mensuel des Ventes — %', TO_CHAR(p_mois, 'Month YYYY');
END $$;

-- Version requête SQL pure :
WITH
  mois_actuel  AS (SELECT DATE_TRUNC('month', NOW() - INTERVAL '1 month') AS debut),
  mois_prec    AS (SELECT DATE_TRUNC('month', NOW() - INTERVAL '2 months') AS debut),
  kpis_courant AS (
      SELECT SUM(montant_ttc) AS ca, COUNT(DISTINCT id_commande) AS nb_cmd,
             COUNT(DISTINCT id_client) AS nb_cl, AVG(montant_ttc) AS aov
      FROM commandes
      WHERE statut NOT IN ('ANNULEE','REMBOURSEE')
        AND date_commande >= (SELECT debut FROM mois_actuel)
        AND date_commande <  (SELECT debut FROM mois_actuel) + INTERVAL '1 month'
  ),
  kpis_prec    AS (
      SELECT SUM(montant_ttc) AS ca, COUNT(DISTINCT id_commande) AS nb_cmd,
             COUNT(DISTINCT id_client) AS nb_cl, AVG(montant_ttc) AS aov
      FROM commandes
      WHERE statut NOT IN ('ANNULEE','REMBOURSEE')
        AND date_commande >= (SELECT debut FROM mois_prec)
        AND date_commande <  (SELECT debut FROM mois_prec) + INTERVAL '1 month'
  )
SELECT
    'SYNTHÈSE MENSUELLE'                               AS section,
    ROUND(kc.ca, 2)                                   AS ca_mois,
    ROUND(kp.ca, 2)                                   AS ca_mois_prec,
    ROUND((kc.ca - kp.ca) / NULLIF(kp.ca, 0) * 100, 1) AS var_ca_pct,
    kc.nb_cmd                                         AS commandes,
    ROUND(kc.aov, 2)                                  AS aov,
    kc.nb_cl                                          AS clients_actifs,
    ROUND((kc.nb_cl - kp.nb_cl)::DECIMAL / NULLIF(kp.nb_cl, 0) * 100, 1) AS var_clients_pct
FROM kpis_courant kc, kpis_prec kp;

54.8 BONNES PRATIQUES D'ANALYSE DES VENTES
────────────────────────────────────────────

1. TOUJOURS exclure les commandes annulées et remboursées
   sauf si l'analyse porte précisément sur ces cas.

2. UTILISER des CTEs pour décomposer les calculs complexes
   en étapes lisibles et vérifiables indépendamment.

3. VALIDER les résultats contre une source de référence
   (comptabilité, export manuel) avant de publier le rapport.

4. DOCUMENTER les hypothèses dans les commentaires SQL :
   -- "CA calculé TTC, commandes livrées uniquement, année fiscale 2024"

5. GÉRER les bords temporels avec soin :
   -- Toujours utiliser >= date_debut AND < date_fin (jamais BETWEEN pour les timestamps)
   -- DATE_TRUNC pour la granularité correcte

6. TESTER avec des données connues avant de déployer en production.

7. CRÉER des vues matérialisées pour les rapports consultés fréquemment.

54.9 EXERCICES PRATIQUES — ANALYSE DES VENTES
───────────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Classez les 5 produits les plus vendus du dernier trimestre
      (en unités). Affichez le rang, le nom, la catégorie,
      les unités vendues et le CA généré.

Ex2 : Calculez la répartition des ventes par segment client
      (VIP, PREMIUM, STANDARD, NOUVEAU) : CA total par segment,
      nombre de commandes, panier moyen.

Ex3 : Identifiez les 3 mois avec le CA le plus élevé et les 3 mois
      avec le CA le plus faible sur les 2 dernières années.

NIVEAU INTERMÉDIAIRE :

Ex4 : Réalisez une analyse ABC simplifiée sur les catégories de produits
      (pas les produits individuels) pour l'année en cours.
      Classe A : catégories représentant les 75% premiers du CA.
      Classe B : de 75% à 95%.
      Classe C : le reste.

Ex5 : Calculez le taux de pénétration croisée :
      quel % de clients ayant acheté dans la catégorie "Informatique"
      ont aussi acheté dans la catégorie "Audio" ?

Ex6 : Construisez un rapport "Nouvelles vs Récurrentes" :
      pour chaque mois, calculez le CA provenant des nouveaux clients
      (première commande) vs les clients existants.

NIVEAU AVANCÉ :

Ex7 : Implémentez une analyse de cohortes simplifiée sur 6 mois :
      pour chaque cohorte mensuelle, affichez le taux de rétention
      (% de clients ayant passé au moins une autre commande)
      aux mois 1, 2, 3, 6.

Ex8 : Trouvez les 10 meilleures paires de produits achetés ensemble
      (market basket analysis) avec leur score de lift.
      Expliquez comment ShopFlow pourrait utiliser ces résultats
      pour ses recommandations.

Ex9 : Construisez un rapport "Prévision vs Réel" pour les 6 derniers mois.
      La prévision = moyenne du même mois sur les 2 années précédentes.
      Affichez : mois, CA réel, CA prévu, écart absolu, écart en %.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 14
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 52 : Calculer les KPIs essentiels (CA, AOV, CLV, taux de
                   conversion, taux de réachat, NPS proxy) avec SQL.
                   Comparaisons période sur période avec LAG().

  [OK] Chapitre 53 : Construire des dashboards SQL complets.
                   Widgets pour BI tools, vues matérialisées pour la performance.
                   Dashboards exécutif, logistique et marketing.

  [OK] Chapitre 54 : Analyse avancée des ventes.
                   Classification ABC, saisonnalité, cohortes, market basket,
                   régression linéaire, rapport mensuel complet.

POINTS CLÉS À RETENIR :
  -> Exclure toujours ANNULEE et REMBOURSEE sauf analyse spécifique
  -> SUM(montant_ttc) depuis commandes vs SUM(qté × prix) depuis lignes_commande
    donnent des résultats différents — choisir selon le besoin
  -> Les vues matérialisées sont essentielles pour les dashboards à fort trafic
  -> L'analyse ABC, les cohortes et le market basket sont les 3 analyses
    fondamentales en e-commerce
  -> Documenter les hypothèses est aussi important que le SQL lui-même

PROCHAINE PARTIE :
  Partie 15 — Cas business réels : analyse clients, produits, churn.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 14 — DATA ANALYSIS AVEC SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                   PARTIE 15 — CAS BUSINESS RÉELS                                ║
║                         Chapitres 55 à 57                                       ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Avancé — Professionnel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 15
════════════════════════════════
  Chapitre 55 : Analyse clients — Segmentation, comportements, valeur
  Chapitre 56 : Analyse produits — Performance, rentabilité, cycle de vie
  Chapitre 57 : Churn — Détection, prévention, stratégie de rétention

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 55 — ANALYSE CLIENTS : SEGMENTATION ET COMPORTEMENTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

55.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que l'analyse clients ?
  L'analyse clients consiste à comprendre qui sont vos clients, comment ils
  se comportent, et quelle valeur ils apportent. C'est la base de toute
  stratégie marketing, commerciale et produit.

Pourquoi ça existe ?
  Tous les clients ne sont pas égaux. 20% des clients génèrent souvent 80%
  du CA. Savoir lesquels, pourquoi, et comment les fidéliser est fondamental.

Cas d'usage réels :
  -> Envoyer des emails personnalisés selon le profil d'achat
  -> Proposer des promotions ciblées aux clients à risque de partir
  -> Identifier les ambassadeurs de marque (clients satisfaits qui recommandent)
  -> Allouer le budget publicitaire sur les segments les plus rentables

55.2 SEGMENTATION RFM COMPLÈTE
────────────────────────────────

-- RFM = Récence, Fréquence, Valeur monétaire.
-- C'est le framework de segmentation client le plus utilisé en retail.
-- Chaque dimension est scorée de 1 à 5, donnant 125 segments possibles (5³).

-- Étape 1 : Calculer les métriques RFM brutes par client

WITH rfm_base AS (
    SELECT
        c.id_client,
        cl.prenom || ' ' || cl.nom                     AS client,
        cl.email,
        cl.segment                                     AS segment_actuel,
        -- Récence : jours depuis la dernière commande (plus petit = meilleur)
        EXTRACT(DAYS FROM NOW() - MAX(c.date_commande))::INTEGER AS recence_jours,
        -- Fréquence : nombre de commandes distinctes
        COUNT(DISTINCT c.id_commande)                  AS frequence,
        -- Valeur monétaire : CA total
        ROUND(SUM(c.montant_ttc), 2)                   AS valeur_monetaire
    FROM commandes c
    JOIN clients cl ON c.id_client = cl.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY c.id_client, cl.prenom, cl.nom, cl.email, cl.segment
),

-- Étape 2 : Scorer chaque dimension de 1 à 5 avec NTILE

rfm_scores AS (
    SELECT
        id_client,
        client,
        email,
        segment_actuel,
        recence_jours,
        frequence,
        valeur_monetaire,
        -- Score Récence : 5 = très récent (NTILE inverse car moins de jours = meilleur)
        NTILE(5) OVER (ORDER BY recence_jours DESC)    AS score_r,
        -- Score Fréquence : 5 = achète souvent
        NTILE(5) OVER (ORDER BY frequence ASC)         AS score_f,
        -- Score Valeur : 5 = dépense beaucoup
        NTILE(5) OVER (ORDER BY valeur_monetaire ASC)  AS score_m
    FROM rfm_base
),

-- Étape 3 : Calculer le score RFM global et attribuer un segment

rfm_segments AS (
    SELECT
        *,
        -- Score composite (somme pondérée : R et F plus importants que M)
        ROUND((score_r * 0.35 + score_f * 0.35 + score_m * 0.30), 1) AS rfm_score,
        -- Concaténation pour le code RFM
        CONCAT(score_r::TEXT, score_f::TEXT, score_m::TEXT)            AS rfm_code
    FROM rfm_scores
)

SELECT
    id_client,
    client,
    email,
    segment_actuel,
    recence_jours,
    frequence,
    valeur_monetaire,
    score_r,
    score_f,
    score_m,
    rfm_score,
    rfm_code,
    -- Attribution d'un nom de segment basé sur le score RFM
    CASE
        WHEN rfm_score >= 4.5                          THEN '[TROPHEE] Champions'
        WHEN rfm_score >= 4.0 AND score_r >= 4         THEN '* Clients fidèles'
        WHEN rfm_score >= 4.0 AND score_r < 4          THEN '[SLEEPING_FACE] Anciens fidèles'
        WHEN rfm_score >= 3.0 AND score_r >= 4         THEN '[NOUVEAU] Clients potentiels'
        WHEN rfm_score >= 3.0 AND score_r < 3          THEN '[ATTENTION]  À risque'
        WHEN score_r >= 4 AND rfm_score < 3            THEN '🆕 Nouveaux clients'
        WHEN rfm_score >= 2.0                          THEN '[NEUTRAL_FACE] Clients tièdes'
        ELSE                                                '[ATTENTE] Clients perdus'
    END                                                AS segment_rfm
FROM rfm_segments
ORDER BY rfm_score DESC;

-- Résumé par segment RFM
-- (ajouter GROUP BY segment_rfm après la requête ci-dessus, ou en CTE)

55.3 ANALYSE DU PARCOURS CLIENT
─────────────────────────────────

-- Comprendre comment les clients progressent au fil du temps.

WITH premier_achat AS (
    SELECT
        id_client,
        MIN(date_commande)                             AS date_premier_achat,
        (SELECT montant_ttc FROM commandes c2
         WHERE c2.id_client = c.id_client
         ORDER BY date_commande LIMIT 1)               AS montant_premier_achat
    FROM commandes c
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY id_client
),
deuxieme_achat AS (
    SELECT DISTINCT ON (id_client)
        id_client,
        date_commande                                  AS date_deuxieme_achat,
        montant_ttc                                    AS montant_deuxieme_achat
    FROM commandes c
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND date_commande > (
          SELECT MIN(date_commande) FROM commandes c2
          WHERE c2.id_client = c.id_client
      )
    ORDER BY id_client, date_commande
)
SELECT
    cl.id_client,
    cl.prenom || ' ' || cl.nom                        AS client,
    cl.segment,
    pa.date_premier_achat,
    ROUND(pa.montant_premier_achat, 2)                 AS premier_achat,
    da.date_deuxieme_achat,
    ROUND(da.montant_deuxieme_achat, 2)                AS deuxieme_achat,
    EXTRACT(DAYS FROM da.date_deuxieme_achat
                    - pa.date_premier_achat)::INTEGER  AS jours_entre_achats,
    ROUND(da.montant_deuxieme_achat
          - pa.montant_premier_achat, 2)              AS evolution_panier,
    CASE
        WHEN da.id_client IS NULL                     THEN 'Un seul achat'
        WHEN EXTRACT(DAYS FROM da.date_deuxieme_achat
                             - pa.date_premier_achat) <= 30 THEN 'Réachat rapide'
        WHEN EXTRACT(DAYS FROM da.date_deuxieme_achat
                             - pa.date_premier_achat) <= 90 THEN 'Réachat normal'
        ELSE                                               'Réachat tardif'
    END                                                AS profil_reachat
FROM clients cl
JOIN premier_achat pa ON cl.id_client = pa.id_client
LEFT JOIN deuxieme_achat da ON cl.id_client = da.id_client
ORDER BY pa.date_premier_achat DESC;

55.4 ANALYSE DE LA VALEUR PAR COHORTE DE PREMIER ACHAT
────────────────────────────────────────────────────────

-- Les clients qui font leur premier achat en période de soldes ont-ils
-- une meilleure LTV que ceux qui achètent à prix plein ?

WITH cohorte_premier_achat AS (
    SELECT
        id_client,
        DATE_TRUNC('month', MIN(date_commande))        AS cohorte,
        MIN(date_commande)                             AS date_premier_achat
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY id_client
),
ltv_par_cohorte AS (
    SELECT
        cpa.cohorte,
        COUNT(DISTINCT cpa.id_client)                 AS nb_clients_cohorte,
        -- LTV sur les 3 premiers mois
        ROUND(SUM(c.montant_ttc) FILTER (
            WHERE c.date_commande < cpa.date_premier_achat + INTERVAL '3 months'
        ), 2)                                          AS ltv_3mois,
        -- LTV sur les 6 premiers mois
        ROUND(SUM(c.montant_ttc) FILTER (
            WHERE c.date_commande < cpa.date_premier_achat + INTERVAL '6 months'
        ), 2)                                          AS ltv_6mois,
        -- LTV totale (tout l'historique)
        ROUND(SUM(c.montant_ttc), 2)                  AS ltv_totale
    FROM cohorte_premier_achat cpa
    JOIN commandes c ON cpa.id_client = c.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cpa.cohorte
)
SELECT
    TO_CHAR(cohorte, 'YYYY-MM')                        AS mois_cohorte,
    nb_clients_cohorte,
    ROUND(ltv_3mois / nb_clients_cohorte, 2)           AS ltv_moyen_3mois,
    ROUND(ltv_6mois / nb_clients_cohorte, 2)           AS ltv_moyen_6mois,
    ROUND(ltv_totale / nb_clients_cohorte, 2)          AS ltv_moyen_total
FROM ltv_par_cohorte
ORDER BY cohorte;

55.5 PROFIL DES CLIENTS CHAMPIONS
───────────────────────────────────

-- Qui sont nos meilleurs clients ? Quels traits partagent-ils ?

WITH top_clients AS (
    SELECT id_client
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY id_client
    ORDER BY SUM(montant_ttc) DESC
    LIMIT 20  -- Top 20 clients
),
profil AS (
    SELECT
        cl.segment,
        EXTRACT(YEAR FROM AGE(NOW(), cl.date_naissance))::INTEGER AS age,
        cl.adresse_ville,
        -- Catégorie préférée
        (
            SELECT cat.nom
            FROM lignes_commande lc2
            JOIN commandes c2 ON lc2.id_commande = c2.id_commande
            JOIN produits p2 ON lc2.id_produit = p2.id_produit
            JOIN categories cat ON p2.id_categorie = cat.id_categorie
            WHERE c2.id_client = cl.id_client
              AND c2.statut NOT IN ('ANNULEE', 'REMBOURSEE')
            GROUP BY cat.nom
            ORDER BY SUM(lc2.quantite * lc2.prix_unitaire_ht) DESC
            LIMIT 1
        )                                                          AS categorie_preferee,
        -- Mode de paiement préféré
        (
            SELECT p.mode_paiement
            FROM paiements p
            JOIN commandes c ON p.id_commande = c.id_commande
            WHERE c.id_client = cl.id_client AND p.statut = 'VALIDE'
            GROUP BY p.mode_paiement
            ORDER BY COUNT(*) DESC
            LIMIT 1
        )                                                          AS mode_paiement_prefere
    FROM clients cl
    WHERE cl.id_client IN (SELECT id_client FROM top_clients)
      AND cl.date_naissance IS NOT NULL
)
SELECT
    categorie_preferee,
    mode_paiement_prefere,
    ROUND(AVG(age), 0)                                 AS age_moyen,
    COUNT(*)                                           AS nb_clients_top,
    -- Distribution des segments
    STRING_AGG(DISTINCT segment, ', ')                 AS segments
FROM profil
GROUP BY categorie_preferee, mode_paiement_prefere
ORDER BY nb_clients_top DESC;

55.6 ANALYSE GÉOGRAPHIQUE DES CLIENTS
───────────────────────────────────────

-- Où sont nos clients les plus précieux ?

SELECT
    cl.adresse_ville                                   AS ville,
    cl.adresse_cp,
    COUNT(DISTINCT cl.id_client)                       AS nb_clients,
    COUNT(DISTINCT c.id_commande)                      AS nb_commandes,
    ROUND(SUM(c.montant_ttc), 2)                       AS ca_total,
    ROUND(AVG(c.montant_ttc), 2)                       AS panier_moyen,
    COUNT(DISTINCT cl.id_client) FILTER (
        WHERE cl.segment = 'VIP'
    )                                                  AS nb_vip,
    ROUND(
        COUNT(DISTINCT cl.id_client) FILTER (WHERE cl.segment = 'VIP')
        ::DECIMAL / COUNT(DISTINCT cl.id_client) * 100, 1
    )                                                  AS pct_vip
FROM clients cl
LEFT JOIN commandes c ON cl.id_client = c.id_client
    AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
WHERE cl.adresse_ville IS NOT NULL
GROUP BY cl.adresse_ville, cl.adresse_cp
HAVING COUNT(DISTINCT cl.id_client) >= 2  -- au moins 2 clients dans la ville
ORDER BY ca_total DESC NULLS LAST;

55.7 EXERCICES PRATIQUES — ANALYSE CLIENTS
────────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Calculez le nombre de clients par segment (VIP, PREMIUM, STANDARD,
      NOUVEAU) avec le CA moyen par client dans chaque segment.

Ex2 : Identifiez les 10 clients qui ont passé le plus grand nombre
      de commandes, avec leur CA total et leur segment.

Ex3 : Calculez l'âge moyen des clients par segment.
      Attention : certains clients n'ont pas de date de naissance.

NIVEAU INTERMÉDIAIRE :

Ex4 : Construisez un rapport "Premier achat : succès ou échec".
      Pour chaque mois, calculez le nombre de nouveaux clients
      et le % qui ont repassé une commande dans les 30 jours suivants.

Ex5 : Identifiez les clients "bi-catégories" : ceux qui ont acheté
      dans exactement 2 catégories différentes. Listez les 5 paires
      de catégories les plus populaires chez ces clients.

Ex6 : Créez un rapport "Valeur par ancienneté" : regroupez les clients
      par tranches d'ancienneté (0-6 mois, 6-12 mois, 1-2 ans, 2+ ans)
      et calculez le CA moyen, la fréquence moyenne, et le taux de VIP.

NIVEAU AVANCÉ :

Ex7 : Implémentez la segmentation RFM complète et générez un rapport
      résumé par segment : nombre de clients, CA total, CA moyen,
      recence moyenne, frequence moyenne. Triez par CA total décroissant.

Ex8 : Construisez un "Client 360" : une vue unique par client comprenant
      toutes ses métriques clés : ancienneté, nb commandes, CA total,
      panier moyen, recence, note moyenne donnée, catégorie préférée,
      fournisseur préféré, et score de risque de churn (0-100).

Ex9 : Analysez la "contagion" entre clients : y a-t-il des clients qui
      ont recommandé l'application (identifiable si deux clients de la
      même ville ou même code postal ont les mêmes dates d'inscription
      à ±7 jours) ? Listez ces paires potentielles de parrainage.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 56 — ANALYSE PRODUITS : PERFORMANCE ET CYCLE DE VIE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

56.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que l'analyse produits ?
  L'analyse produits évalue les performances de chaque produit du catalogue :
  ventes, marges, stock, avis, tendances. Elle guide les décisions sur quels
  produits promouvoir, retirer, réapprovisionner ou reprioriser.

Pourquoi ça existe ?
  Un catalogue de 15 produits peut cacher des trésors et des poids morts.
  Sans analyse, vous investissez autant en stock et en marketing pour un produit
  qui ne vend pas que pour un bestseller.

56.2 SCORECARD PRODUIT COMPLET
───────────────────────────────

-- Vue 360° de chaque produit : ventes, marges, stock, avis, tendances.

WITH ventes_30j AS (
    SELECT lc.id_produit, SUM(lc.quantite) AS unites_30j,
           SUM(lc.quantite * lc.prix_unitaire_ht * (1 + lc.taux_tva/100)) AS ca_30j
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE','REMBOURSEE')
      AND c.date_commande >= NOW() - INTERVAL '30 days'
    GROUP BY lc.id_produit
),
ventes_90j AS (
    SELECT lc.id_produit, SUM(lc.quantite) AS unites_90j,
           SUM(lc.quantite * lc.prix_unitaire_ht * (1 + lc.taux_tva/100)) AS ca_90j
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE','REMBOURSEE')
      AND c.date_commande >= NOW() - INTERVAL '90 days'
    GROUP BY lc.id_produit
),
avis_stats AS (
    SELECT id_produit,
           COUNT(*) AS nb_avis,
           ROUND(AVG(note), 2) AS note_moyenne,
           COUNT(*) FILTER (WHERE note >= 4) AS avis_positifs,
           COUNT(*) FILTER (WHERE note <= 2) AS avis_negatifs
    FROM avis
    GROUP BY id_produit
)
SELECT
    p.reference,
    p.nom                                              AS produit,
    cat.nom                                            AS categorie,
    f.raison_sociale                                   AS fournisseur,
    -- Prix et marges
    p.prix_ht                                          AS prix_ht,
    ROUND(p.prix_ht * 1.2, 2)                         AS prix_ttc,
    -- Stock
    p.stock                                            AS stock_actuel,
    p.stock_min,
    CASE
        WHEN p.stock = 0               THEN 'RUPTURE'
        WHEN p.stock < p.stock_min     THEN 'CRITIQUE'
        WHEN p.stock < p.stock_min * 2 THEN 'FAIBLE'
        ELSE                                'OK'
    END                                                AS etat_stock,
    -- Ventes
    COALESCE(v30.unites_30j, 0)                       AS unites_30j,
    COALESCE(v30.ca_30j, 0)                           AS ca_30j,
    COALESCE(v90.unites_90j, 0)                       AS unites_90j,
    COALESCE(v90.ca_90j, 0)                           AS ca_90j,
    -- Couverture stock (jours restants au rythme actuel)
    CASE
        WHEN COALESCE(v30.unites_30j, 0) = 0 THEN NULL
        ELSE ROUND(p.stock / (v30.unites_30j / 30.0), 0)
    END                                                AS jours_couverture,
    -- Avis
    COALESCE(a.nb_avis, 0)                            AS nb_avis,
    COALESCE(a.note_moyenne, 0)                       AS note_moyenne,
    COALESCE(a.avis_positifs, 0)                      AS avis_positifs,
    COALESCE(a.avis_negatifs, 0)                      AS avis_negatifs,
    -- Tendance (30j vs 30-60j précédents)
    CASE
        WHEN COALESCE(v30.unites_30j, 0) = 0 AND COALESCE(v90.unites_90j, 0) = 0
            THEN 'Aucune vente'
        WHEN COALESCE(v30.unites_30j, 0) >
             (COALESCE(v90.unites_90j, 0) - COALESCE(v30.unites_30j, 0)) * 0.5
            THEN '[HAUSSE] En hausse'
        WHEN COALESCE(v30.unites_30j, 0) <
             (COALESCE(v90.unites_90j, 0) - COALESCE(v30.unites_30j, 0)) * 0.5
            THEN '[BAISSE] En baisse'
        ELSE '->  Stable'
    END                                                AS tendance
FROM produits p
JOIN categories cat       ON p.id_categorie = cat.id_categorie
JOIN fournisseurs f        ON p.id_fournisseur = f.id_fournisseur
LEFT JOIN ventes_30j v30   ON p.id_produit = v30.id_produit
LEFT JOIN ventes_90j v90   ON p.id_produit = v90.id_produit
LEFT JOIN avis_stats a     ON p.id_produit = a.id_produit
WHERE p.actif = TRUE
ORDER BY COALESCE(v30.ca_30j, 0) DESC;

56.3 ANALYSE DE LA MARGE PAR PRODUIT ET CATÉGORIE
────────────────────────────────────────────────────

-- La marge brute = CA - coût des marchandises vendues (COGS)
-- COGS = stock vendu × prix d'achat (si on a le prix_achat dans produits)

-- Note : ShopFlow stocke prix_ht (prix de vente HT) dans produits.
-- En production, on aurait aussi prix_achat pour calculer la marge réelle.
-- Ici on simule avec une marge de 30% sur le prix de vente HT.

WITH marge_par_produit AS (
    SELECT
        p.id_produit,
        p.nom,
        cat.nom                                        AS categorie,
        -- CA généré
        SUM(lc.quantite * lc.prix_unitaire_ht)        AS ca_ht,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100))                AS ca_ttc,
        -- Coût simulé (prix_achat ≈ 65% du prix de vente)
        SUM(lc.quantite * lc.prix_unitaire_ht * 0.65) AS cout_achats,
        -- Marge brute
        SUM(lc.quantite * lc.prix_unitaire_ht * 0.35) AS marge_brute
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    JOIN produits p ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY p.id_produit, p.nom, cat.nom
)
SELECT
    nom,
    categorie,
    ROUND(ca_ht, 2)                                   AS ca_ht,
    ROUND(ca_ttc, 2)                                  AS ca_ttc,
    ROUND(marge_brute, 2)                             AS marge_brute,
    ROUND(marge_brute / NULLIF(ca_ht, 0) * 100, 1)   AS taux_marge_pct,
    -- Rang par marge
    RANK() OVER (ORDER BY marge_brute DESC)           AS rang_marge,
    -- Part dans la marge totale
    ROUND(marge_brute / SUM(marge_brute) OVER () * 100, 1) AS part_marge_pct,
    -- Cumul marge
    ROUND(SUM(marge_brute) OVER (
        ORDER BY marge_brute DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) / SUM(marge_brute) OVER () * 100, 1)            AS marge_cumulee_pct
FROM marge_par_produit
ORDER BY marge_brute DESC;

56.4 ANALYSE DU CYCLE DE VIE PRODUIT
──────────────────────────────────────

-- Chaque produit passe par des phases : lancement, croissance, maturité, déclin.
-- On peut détecter ces phases en comparant les ventes sur différentes fenêtres.

WITH ventes_phases AS (
    SELECT
        lc.id_produit,
        -- 30 premiers jours sur le marché
        SUM(lc.quantite) FILTER (
            WHERE c.date_commande <= p.date_creation + INTERVAL '30 days'
        )                                              AS ventes_lancement,
        -- 31-90 jours
        SUM(lc.quantite) FILTER (
            WHERE c.date_commande > p.date_creation + INTERVAL '30 days'
              AND c.date_commande <= p.date_creation + INTERVAL '90 days'
        )                                              AS ventes_croissance,
        -- 91-180 jours
        SUM(lc.quantite) FILTER (
            WHERE c.date_commande > p.date_creation + INTERVAL '90 days'
              AND c.date_commande <= p.date_creation + INTERVAL '180 days'
        )                                              AS ventes_maturite,
        -- Au-delà de 180 jours
        SUM(lc.quantite) FILTER (
            WHERE c.date_commande > p.date_creation + INTERVAL '180 days'
        )                                              AS ventes_saturation
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    JOIN produits p ON lc.id_produit = p.id_produit
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY lc.id_produit
)
SELECT
    p.nom                                              AS produit,
    cat.nom                                            AS categorie,
    TO_CHAR(p.date_creation, 'YYYY-MM-DD')            AS date_lancement,
    EXTRACT(DAYS FROM NOW() - p.date_creation)::INTEGER AS age_jours,
    COALESCE(vp.ventes_lancement, 0)                  AS v_0_30j,
    COALESCE(vp.ventes_croissance, 0)                 AS v_31_90j,
    COALESCE(vp.ventes_maturite, 0)                   AS v_91_180j,
    COALESCE(vp.ventes_saturation, 0)                 AS v_180j_plus,
    -- Phase actuelle estimée
    CASE
        WHEN EXTRACT(DAYS FROM NOW() - p.date_creation) <= 30
            THEN '[RAPIDE] Lancement'
        WHEN COALESCE(vp.ventes_croissance, 0) > COALESCE(vp.ventes_lancement, 0) * 1.5
          AND EXTRACT(DAYS FROM NOW() - p.date_creation) BETWEEN 31 AND 180
            THEN '[HAUSSE] Croissance'
        WHEN COALESCE(vp.ventes_saturation, 0) < COALESCE(vp.ventes_maturite, 0) * 0.7
          AND EXTRACT(DAYS FROM NOW() - p.date_creation) > 180
            THEN '[BAISSE] Déclin'
        WHEN EXTRACT(DAYS FROM NOW() - p.date_creation) > 90
            THEN '[SCALES]  Maturité'
        ELSE '[?] Indéterminé'
    END                                                AS phase_cycle_vie
FROM produits p
JOIN categories cat ON p.id_categorie = cat.id_categorie
LEFT JOIN ventes_phases vp ON p.id_produit = vp.id_produit
WHERE p.actif = TRUE
ORDER BY p.date_creation DESC;

56.5 ANALYSE DES AVIS : VOICE OF CUSTOMER
───────────────────────────────────────────

-- Les avis clients sont une mine d'or pour améliorer les produits.

WITH analyse_avis AS (
    SELECT
        p.id_produit,
        p.nom                                          AS produit,
        cat.nom                                        AS categorie,
        COUNT(a.id_avis)                              AS total_avis,
        -- Distribution des notes
        COUNT(*) FILTER (WHERE a.note = 5)             AS nb_5etoiles,
        COUNT(*) FILTER (WHERE a.note = 4)             AS nb_4etoiles,
        COUNT(*) FILTER (WHERE a.note = 3)             AS nb_3etoiles,
        COUNT(*) FILTER (WHERE a.note = 2)             AS nb_2etoiles,
        COUNT(*) FILTER (WHERE a.note = 1)             AS nb_1etoile,
        ROUND(AVG(a.note), 2)                          AS note_moyenne,
        -- Avis vérifiés vs non vérifiés
        COUNT(*) FILTER (WHERE a.verifie = TRUE)       AS avis_verifies,
        -- Écart type (régularité des notes)
        ROUND(STDDEV(a.note), 2)                       AS ecart_type_notes
    FROM produits p
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    LEFT JOIN avis a ON p.id_produit = a.id_produit
    GROUP BY p.id_produit, p.nom, cat.nom
)
SELECT
    produit,
    categorie,
    total_avis,
    note_moyenne,
    ecart_type_notes,
    -- Score Wilson (meilleure estimation que la simple moyenne pour les petits échantillons)
    -- Formule : (nb_positifs + z²/2) / (total + z²) - z × sqrt(...) / (total + z²)
    -- z = 1.96 pour IC 95%
    CASE WHEN total_avis = 0 THEN 0 ELSE
        ROUND((
            (COALESCE(nb_5etoiles, 0) + COALESCE(nb_4etoiles, 0) + 1.96*1.96/2)
            / (total_avis + 1.96*1.96)
            - 1.96 * SQRT(
                (COALESCE(nb_5etoiles, 0) + COALESCE(nb_4etoiles, 0)
                 * (1.0 - (COALESCE(nb_5etoiles, 0) + COALESCE(nb_4etoiles, 0))
                 / total_avis::DECIMAL) / total_avis
                + 1.96*1.96 / (4 * total_avis))
                / (total_avis + 1.96*1.96)
        ) * 5, 2)
    END                                                AS score_wilson,
    -- Barre visuelle de la distribution
    LPAD('*', nb_5etoiles, '*') ||
    LPAD('*', nb_1etoile, '*')                       AS visualisation,
    -- Alertes qualité
    CASE
        WHEN total_avis = 0                           THEN '[BLANC] Sans avis'
        WHEN note_moyenne >= 4.5                      THEN '[TROPHEE] Excellent'
        WHEN note_moyenne >= 4.0                      THEN '[OK] Bon'
        WHEN note_moyenne >= 3.0                      THEN '[ATTENTION]  Passable'
        ELSE                                               '[ALERTE] Problème qualité'
    END                                               AS badge_qualite
FROM analyse_avis
ORDER BY
    CASE WHEN total_avis = 0 THEN 1 ELSE 0 END,
    note_moyenne DESC;

56.6 DÉTECTION DES PRODUITS ZOMBIES (INVENDUS)
────────────────────────────────────────────────

-- Un produit zombie est actif dans le catalogue mais ne vend pas.
-- Il coûte de l'espace et de l'attention sans générer de valeur.

WITH derniere_vente AS (
    SELECT
        lc.id_produit,
        MAX(c.date_commande)                           AS derniere_vente_date,
        SUM(lc.quantite)                              AS total_vendu
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY lc.id_produit
)
SELECT
    p.reference,
    p.nom                                              AS produit,
    cat.nom                                            AS categorie,
    p.prix_ht,
    p.stock                                            AS stock_bloque,
    TO_CHAR(p.date_creation, 'YYYY-MM-DD')            AS date_lancement,
    TO_CHAR(dv.derniere_vente_date, 'YYYY-MM-DD')     AS derniere_vente,
    COALESCE(dv.total_vendu, 0)                       AS total_vendu_historique,
    EXTRACT(DAYS FROM NOW() -
        COALESCE(dv.derniere_vente_date, p.date_creation)
    )::INTEGER                                         AS jours_sans_vente,
    -- Valeur du stock immobilisé (coût estimé)
    ROUND(p.stock * p.prix_ht * 0.65, 2)             AS valeur_stock_coût,
    -- Classification
    CASE
        WHEN dv.id_produit IS NULL
            THEN '[SKULL_AND_CROSSBONES]  Jamais vendu'
        WHEN EXTRACT(DAYS FROM NOW() - dv.derniere_vente_date) > 180
            THEN '[ZOMBIE] Zombie (>6 mois)'
        WHEN EXTRACT(DAYS FROM NOW() - dv.derniere_vente_date) > 90
            THEN '[ATTENTION]  Inactif (>3 mois)'
        ELSE '[OK] Actif récemment'
    END                                                AS statut_vente
FROM produits p
JOIN categories cat ON p.id_categorie = cat.id_categorie
LEFT JOIN derniere_vente dv ON p.id_produit = dv.id_produit
WHERE p.actif = TRUE
ORDER BY jours_sans_vente DESC NULLS LAST;

56.7 EXERCICES PRATIQUES — ANALYSE PRODUITS
─────────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Listez les 5 produits les mieux notés (note moyenne ≥ 1 avis minimum)
      avec leur nombre d'avis et leur CA total sur l'ensemble des données.

Ex2 : Calculez pour chaque catégorie : le nombre de produits actifs,
      le prix moyen, le stock total, et le CA total cette année.

Ex3 : Identifiez les produits dont le stock est en rupture (stock = 0)
      mais qui ont eu des ventes dans les 30 derniers jours.

NIVEAU INTERMÉDIAIRE :

Ex4 : Construisez un rapport "Vitesse de rotation du stock" :
      pour chaque produit actif, calculez combien de fois le stock
      "tourne" par an (ventes annuelles / stock moyen).
      Un ratio > 12 = rotation mensuelle (excellent).

Ex5 : Analysez la corrélation entre note moyenne et volume de ventes :
      les produits les mieux notés vendent-ils vraiment plus ?
      Calculez la corrélation (CORR function) entre note_moyenne et unites_vendues.

Ex6 : Identifiez les produits qui dépendent d'un seul fournisseur
      et dont le stock est critique (< stock_min).
      Ce sont des risques de supply chain.

NIVEAU AVANCÉ :

Ex7 : Implémentez une analyse d'élasticité-prix simplifiée :
      pour les produits dont le prix a changé (comparer prix_ht actuel
      vs prix_unitaire_ht dans les anciennes lignes de commande),
      calculez si la variation de prix a impacté le volume de ventes.

Ex8 : Construisez un "score de santé produit" de 0 à 100 basé sur :
      - Ventes (40 pts) : > 50 unités/mois = 40 pts
      - Avis (30 pts) : note ≥ 4.5 = 30 pts
      - Stock (20 pts) : couverture ≥ 30 jours = 20 pts
      - Tendance (10 pts) : ventes 30j > ventes 30j précédents = 10 pts
      Classifiez : ≥80 = "Star", 50-79 = "Viable", <50 = "Problématique"

Ex9 : Réalisez une analyse de cannibalisation entre produits de la même
      catégorie : quand un nouveau produit est lancé, ses ventes
      impactent-elles celles des anciens produits similaires ?
      Comparez les ventes d'un produit avant/après le lancement d'un autre.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 57 — CHURN : DÉTECTION, PRÉVENTION, RÉTENTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

57.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que le churn ?
  Le churn (ou attrition) désigne la perte de clients. Un client "churne"
  quand il cesse d'acheter. En e-commerce, il n'y a pas de contrat, donc
  le churn est implicite (pas d'annulation formelle, juste l'absence d'achat).

Pourquoi c'est critique ?
  Acquérir un nouveau client coûte 5 à 7× plus cher que de fidéliser
  un client existant. Identifier et prévenir le churn = économiser du budget
  marketing et préserver le CA.

Comment définit-on le churn en e-commerce ?
  -> Définition binaire : "n'a pas commandé depuis X jours" (ex: 90 ou 180 jours)
  -> Définition graduelle : score de risque basé sur la récence + fréquence + valeur

  La définition varie selon l'industrie et le cycle d'achat naturel.
  Si vous vendez des matelas, un client qui n'achète pas pendant 5 ans n'est
  peut-être pas churné (il a juste acheté son matelas pour 10 ans !).

57.2 CALCUL DU TAUX DE CHURN
──────────────────────────────

-- Définition churn ShopFlow : client n'ayant pas commandé depuis > 90 jours.
-- Ce seuil doit être adapté selon votre cycle d'achat moyen.

WITH statut_client AS (
    SELECT
        cl.id_client,
        cl.prenom || ' ' || cl.nom                    AS client,
        cl.segment,
        cl.date_inscription,
        MAX(c.date_commande)                           AS derniere_commande,
        EXTRACT(DAYS FROM NOW() - MAX(c.date_commande))::INTEGER AS jours_inactif,
        COUNT(DISTINCT c.id_commande)                  AS nb_commandes,
        SUM(c.montant_ttc)                             AS ltv
    FROM clients cl
    LEFT JOIN commandes c ON cl.id_client = c.id_client
        AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cl.id_client, cl.prenom, cl.nom, cl.segment, cl.date_inscription
)
SELECT
    COUNT(*)                                           AS total_clients,
    COUNT(*) FILTER (WHERE derniere_commande IS NULL)  AS jamais_achete,
    COUNT(*) FILTER (WHERE jours_inactif <= 90)        AS actifs_90j,
    COUNT(*) FILTER (WHERE jours_inactif BETWEEN 91 AND 180) AS a_risque_90_180j,
    COUNT(*) FILTER (WHERE jours_inactif > 180)        AS churnes_180j_plus,
    COUNT(*) FILTER (WHERE derniere_commande IS NOT NULL AND jours_inactif IS NULL) AS actifs,
    -- Taux de churn
    ROUND(
        COUNT(*) FILTER (WHERE jours_inactif > 90 OR derniere_commande IS NULL)
        ::DECIMAL / COUNT(*) * 100, 1
    )                                                  AS taux_churn_pct,
    -- CA à risque (clients 90-180j inactifs)
    ROUND(SUM(ltv) FILTER (WHERE jours_inactif BETWEEN 91 AND 180), 2) AS ca_a_risque
FROM statut_client;

57.3 SCORE DE RISQUE DE CHURN
───────────────────────────────

-- Calculer un score de risque pour prioriser les actions de rétention.
-- Plus le score est élevé, plus le client risque de churner.

WITH historique_client AS (
    SELECT
        cl.id_client,
        cl.prenom || ' ' || cl.nom                    AS client,
        cl.email,
        cl.segment,
        MAX(c.date_commande)                           AS derniere_commande,
        MIN(c.date_commande)                           AS premiere_commande,
        COUNT(DISTINCT c.id_commande)                  AS nb_commandes,
        SUM(c.montant_ttc)                             AS ltv,
        AVG(c.montant_ttc)                             AS panier_moyen,
        -- Inter-achat moyen (jours entre commandes consécutives)
        CASE WHEN COUNT(DISTINCT c.id_commande) >= 2 THEN
            EXTRACT(DAYS FROM MAX(c.date_commande) - MIN(c.date_commande))
            / NULLIF(COUNT(DISTINCT c.id_commande) - 1, 0)
        END                                            AS interachat_moyen_jours
    FROM clients cl
    LEFT JOIN commandes c ON cl.id_client = c.id_client
        AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cl.id_client, cl.prenom, cl.nom, cl.email, cl.segment
),
scores_risque AS (
    SELECT
        id_client,
        client,
        email,
        segment,
        derniere_commande,
        nb_commandes,
        ltv,
        panier_moyen,
        interachat_moyen_jours,
        EXTRACT(DAYS FROM NOW() - derniere_commande)::INTEGER AS jours_inactif,
        -- Composante 1 : Récence (0-40 pts, plus d'inactivité = plus de risque)
        CASE
            WHEN derniere_commande IS NULL         THEN 40
            WHEN EXTRACT(DAYS FROM NOW() - derniere_commande) > 365 THEN 40
            WHEN EXTRACT(DAYS FROM NOW() - derniere_commande) > 180 THEN 35
            WHEN EXTRACT(DAYS FROM NOW() - derniere_commande) > 90  THEN 25
            WHEN EXTRACT(DAYS FROM NOW() - derniere_commande) > 60  THEN 15
            WHEN EXTRACT(DAYS FROM NOW() - derniere_commande) > 30  THEN 5
            ELSE 0
        END                                        AS risque_recence,
        -- Composante 2 : Fréquence déclinante (0-30 pts)
        CASE
            WHEN nb_commandes = 0                  THEN 30
            WHEN nb_commandes = 1                  THEN 20
            WHEN nb_commandes <= 3                 THEN 10
            ELSE 0
        END                                        AS risque_frequence,
        -- Composante 3 : Client à haute valeur = perte importante si churne (0-30 pts)
        CASE
            WHEN ltv > 2000                        THEN 30  -- À prioriser (valeur élevée)
            WHEN ltv > 500                         THEN 20
            WHEN ltv > 100                         THEN 10
            ELSE 5
        END                                        AS enjeu_valeur
    FROM historique_client
    WHERE derniere_commande IS NOT NULL  -- exclure clients sans achat
)
SELECT
    client,
    email,
    segment,
    TO_CHAR(derniere_commande, 'DD/MM/YYYY')          AS derniere_commande,
    jours_inactif,
    nb_commandes,
    ROUND(ltv, 2)                                     AS ltv,
    ROUND(panier_moyen, 2)                            AS panier_moyen,
    risque_recence + risque_frequence                 AS score_risque_churn,
    enjeu_valeur                                      AS score_enjeu,
    risque_recence + risque_frequence + enjeu_valeur  AS score_priorite_action,
    -- Classification
    CASE
        WHEN risque_recence + risque_frequence >= 50  THEN '[ALERTE] Churné probable'
        WHEN risque_recence + risque_frequence >= 35  THEN '[ATTENTION]  Risque élevé'
        WHEN risque_recence + risque_frequence >= 20  THEN '[JAUNE] Risque modéré'
        ELSE                                               '[VERT] Fidèle'
    END                                               AS classification_risque
FROM scores_risque
ORDER BY score_priorite_action DESC;

57.4 DÉTECTION PRÉCOCE DU CHURN AVEC LES SIGNAUX
───────────────────────────────────────────────────

-- Signaux avant-coureurs du churn : baisse du panier, moins de catégories,
-- avis négatifs, contacts avec le service client (si données disponibles).

WITH evolution_comportement AS (
    SELECT
        c.id_client,
        -- Panier moyen des 3 derniers mois
        AVG(c.montant_ttc) FILTER (
            WHERE c.date_commande >= NOW() - INTERVAL '3 months'
        )                                              AS panier_moyen_recent,
        -- Panier moyen des 3-9 mois précédents
        AVG(c.montant_ttc) FILTER (
            WHERE c.date_commande < NOW() - INTERVAL '3 months'
              AND c.date_commande >= NOW() - INTERVAL '9 months'
        )                                              AS panier_moyen_precedent,
        -- Nombre de catégories différentes achetées (récent vs précédent)
        COUNT(DISTINCT p.id_categorie) FILTER (
            WHERE c.date_commande >= NOW() - INTERVAL '3 months'
        )                                              AS categories_recentes,
        COUNT(DISTINCT p.id_categorie) FILTER (
            WHERE c.date_commande < NOW() - INTERVAL '3 months'
              AND c.date_commande >= NOW() - INTERVAL '9 months'
        )                                              AS categories_precedentes,
        -- Avis négatifs récents
        COUNT(a.id_avis) FILTER (
            WHERE a.note <= 2
              AND a.date_avis >= NOW() - INTERVAL '3 months'
        )                                              AS avis_negatifs_recents
    FROM commandes c
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    JOIN produits p ON lc.id_produit = p.id_produit
    LEFT JOIN avis a ON a.id_client = c.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY c.id_client
)
SELECT
    cl.prenom || ' ' || cl.nom                        AS client,
    cl.email,
    cl.segment,
    ROUND(ec.panier_moyen_recent, 2)                  AS panier_recent,
    ROUND(ec.panier_moyen_precedent, 2)               AS panier_precedent,
    ROUND(
        (ec.panier_moyen_recent - ec.panier_moyen_precedent)
        / NULLIF(ec.panier_moyen_precedent, 0) * 100, 1
    )                                                  AS variation_panier_pct,
    ec.categories_recentes,
    ec.categories_precedentes,
    ec.avis_negatifs_recents,
    -- Signaux d'alarme
    CASE
        WHEN ec.panier_moyen_recent < ec.panier_moyen_precedent * 0.6
            THEN '[ALERTE] Panier en forte baisse'
        WHEN ec.categories_recentes < ec.categories_precedentes * 0.5
            THEN '[ATTENTION]  Moins diversifié'
        WHEN ec.avis_negatifs_recents >= 2
            THEN '[ATTENTION]  Avis négatifs récents'
        ELSE '[VERT] Stable'
    END                                                AS signal_principal
FROM evolution_comportement ec
JOIN clients cl ON ec.id_client = cl.id_client
WHERE (
    ec.panier_moyen_recent < ec.panier_moyen_precedent * 0.7
    OR ec.categories_recentes < ec.categories_precedentes
    OR ec.avis_negatifs_recents >= 1
)
AND ec.panier_moyen_precedent IS NOT NULL
ORDER BY ec.avis_negatifs_recents DESC,
         (ec.panier_moyen_recent - ec.panier_moyen_precedent) ASC;

57.5 STRATÉGIES DE RÉTENTION PAR SEGMENT
──────────────────────────────────────────

-- Quel message envoyer à quel client ? SQL pour cibler les campagnes.

-- Segment 1 : Clients récemment inactifs (30-60 jours) -> win-back rapide
SELECT
    cl.prenom || ' ' || cl.nom                        AS client,
    cl.email,
    ROUND(SUM(c.montant_ttc), 2)                      AS ltv,
    MAX(c.date_commande)                               AS derniere_commande,
    EXTRACT(DAYS FROM NOW() - MAX(c.date_commande))::INTEGER AS jours_inactif,
    -- Produit à recommander : catégorie préférée
    (
        SELECT cat.nom
        FROM lignes_commande lc2
        JOIN commandes c2 ON lc2.id_commande = c2.id_commande
        JOIN produits p2 ON lc2.id_produit = p2.id_produit
        JOIN categories cat ON p2.id_categorie = cat.id_categorie
        WHERE c2.id_client = cl.id_client
          AND c2.statut NOT IN ('ANNULEE', 'REMBOURSEE')
        GROUP BY cat.nom
        ORDER BY SUM(lc2.quantite * lc2.prix_unitaire_ht) DESC
        LIMIT 1
    )                                                  AS categorie_preferee,
    'Email win-back : -15% sur votre catégorie préférée' AS action_marketing
FROM clients cl
JOIN commandes c ON cl.id_client = c.id_client
    AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY cl.id_client, cl.prenom, cl.nom, cl.email
HAVING EXTRACT(DAYS FROM NOW() - MAX(c.date_commande)) BETWEEN 30 AND 60
ORDER BY ltv DESC;

-- Segment 2 : Clients à risque élevé (60-90 jours) -> offre urgente
-- (même structure avec HAVING 60-90 jours et offre plus agressive)

-- Segment 3 : Clients perdus (> 90 jours) -> enquête satisfaction
-- (HAVING > 90 jours, email différent : "Revenez, qu'est-ce qui s'est passé ?")

57.6 ANALYSE DE LA RÉTENTION PAR COHORTE (RETENTION MATRIX)
─────────────────────────────────────────────────────────────

-- Matrice de rétention : pour chaque cohorte et chaque mois suivant,
-- quel % de clients ont recacheté ?

WITH cohortes AS (
    SELECT id_client,
           DATE_TRUNC('month', MIN(date_commande)) AS mois_cohorte
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY id_client
),
activite AS (
    SELECT DISTINCT
        co.id_client,
        co.mois_cohorte,
        DATE_TRUNC('month', c.date_commande) AS mois_achat
    FROM cohortes co
    JOIN commandes c ON co.id_client = c.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
),
taille_cohortes AS (
    SELECT mois_cohorte, COUNT(DISTINCT id_client) AS taille
    FROM cohortes GROUP BY mois_cohorte
),
retention AS (
    SELECT
        a.mois_cohorte,
        (EXTRACT(YEAR FROM AGE(a.mois_achat, a.mois_cohorte)) * 12
         + EXTRACT(MONTH FROM AGE(a.mois_achat, a.mois_cohorte)))::INTEGER AS mois_offset,
        COUNT(DISTINCT a.id_client) AS clients_actifs
    FROM activite a
    GROUP BY a.mois_cohorte, mois_offset
)
SELECT
    TO_CHAR(r.mois_cohorte, 'YYYY-MM')                AS cohorte,
    tc.taille,
    -- Mois 0 toujours = 100%
    MAX(CASE WHEN r.mois_offset = 0
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M0 (%)",
    MAX(CASE WHEN r.mois_offset = 1
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M1 (%)",
    MAX(CASE WHEN r.mois_offset = 2
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M2 (%)",
    MAX(CASE WHEN r.mois_offset = 3
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M3 (%)",
    MAX(CASE WHEN r.mois_offset = 6
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M6 (%)",
    MAX(CASE WHEN r.mois_offset = 12
        THEN ROUND(r.clients_actifs::DECIMAL/tc.taille*100,0) END) AS "M12 (%)"
FROM retention r
JOIN taille_cohortes tc ON r.mois_cohorte = tc.mois_cohorte
GROUP BY r.mois_cohorte, tc.taille
ORDER BY r.mois_cohorte;

57.7 IMPACT FINANCIER DU CHURN
────────────────────────────────

-- Quantifier la perte en CA due au churn pour justifier les investissements rétention.

WITH clients_churnes AS (
    SELECT
        id_client,
        -- LTV des 12 mois précédant le "churn" (dernière commande > 90j)
        SUM(montant_ttc)                               AS ltv_historique
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND id_client IN (
          SELECT id_client FROM commandes
          WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
          GROUP BY id_client
          HAVING MAX(date_commande) < NOW() - INTERVAL '90 days'
      )
    GROUP BY id_client
),
impact AS (
    SELECT
        COUNT(*)                                       AS nb_clients_churnes,
        SUM(ltv_historique)                            AS ltv_totale_perdue,
        AVG(ltv_historique)                            AS ltv_moyenne_client_churne,
        -- Estimation CA annuel perdu (si ces clients avaient continué à leur rythme)
        -- Hypothèse : fréquence historique de 3 achats/an, panier moyen historique
        AVG(ltv_historique) * 0.5                      AS ca_annuel_estime_perdu_par_client
    FROM clients_churnes
)
SELECT
    nb_clients_churnes,
    ROUND(ltv_totale_perdue, 2)                        AS ltv_historique_totale,
    ROUND(ltv_moyenne_client_churne, 2)                AS ltv_moyenne_par_client,
    ROUND(ca_annuel_estime_perdu_par_client, 2)        AS ca_annuel_perdu_par_client,
    ROUND(ca_annuel_estime_perdu_par_client
          * nb_clients_churnes, 2)                     AS impact_annuel_total_estime,
    -- Coût maximum justifiable pour retenir un client churné
    ROUND(ltv_moyenne_client_churne * 0.2, 2)          AS budget_retention_max_par_client
    -- On investit max 20% de la LTV pour retenir un client
FROM impact;

57.8 BONNES PRATIQUES ANTI-CHURN
──────────────────────────────────

1. DÉFINIR clairement le seuil de churn adapté à votre métier.
   -> E-commerce généraliste : 90 jours
   -> Produits de luxe (achat rare) : 12-18 mois
   -> Abonnement : à la résiliation

2. AGIR PROACTIVEMENT avant le churn, pas après.
   -> 30-60 jours d'inactivité : win-back offensif
   -> > 90 jours : campagne de réactivation (plus difficile)
   -> > 180 jours : coût de réacquisition comparable à un nouveau client

3. PERSONNALISER les campagnes de rétention.
   -> VIP churné : appel téléphonique + offre exclusive
   -> Client standard : email automatisé avec discount
   -> Clients inactifs depuis leur premier achat : tutoriel d'onboarding

4. MESURER le ROI des campagnes de rétention :
   -- % de clients réactivés par campagne
   -- CA généré par les clients réactivés
   -- Comparer au coût d'acquisition de nouveaux clients équivalents

5. NE PAS confondre churn et saisonnalité :
   -- Un client de saison (achète chaque Noël) n'est pas churné en mars.
   -- Segmenter par fréquence d'achat historique avant de classifier churné.

57.9 EXERCICES PRATIQUES — CHURN
──────────────────────────────────

NIVEAU FACILE :

Ex1 : Calculez le nombre de clients "churned" selon la définition
      "n'a pas commandé depuis > 60 jours". Affichez aussi le CA
      qu'ils représentaient collectivement.

Ex2 : Identifiez les 10 clients à plus haute valeur (LTV) qui sont
      en risque de churn (60-90 jours d'inactivité). Listez leur
      email, LTV, et catégorie préférée pour une campagne ciblée.

Ex3 : Calculez le taux de churn mensuel sur les 12 derniers mois :
      pour chaque mois, combien de clients "actifs ce mois-ci" ne
      l'étaient plus le mois suivant ?

NIVEAU INTERMÉDIAIRE :

Ex4 : Construisez un modèle de scoring churn simplifié à 3 facteurs :
      récence (50%), fréquence de déclin (30%), avis négatifs (20%).
      Générez une liste de clients avec leur score 0-100 et leur
      priorité d'action (Haute / Moyenne / Basse).

Ex5 : Analysez le churn par segment : quel segment a le taux de churn
      le plus élevé ? Le plus faible ? Quelle est la LTV moyenne des
      clients churned par segment ?

Ex6 : Construisez une "carte de chaleur" du churn :
      pour chaque combinaison (segment × mois d'inscription),
      calculez le taux de churn actuel. Identifiez les combinaisons
      les plus à risque.

NIVEAU AVANCÉ :

Ex7 : Implémentez la matrice de rétention complète sur 12 mois.
      Identifiez la cohorte avec le meilleur taux de rétention à M3
      et M6. Analysez ce qui la différencie des autres cohortes
      (premiers produits achetés, panier initial, source...).

Ex8 : Calculez le "Next Best Action" (NBA) pour chaque client à risque :
      basé sur son historique d'achat, quel est le prochain produit
      le plus probable à lui proposer pour le réactiver ?
      (Utilisez les associations de produits du market basket analysis.)

Ex9 : Simulez l'impact d'une campagne de rétention :
      si on réactive 20% des clients à risque (90-180 jours inactifs)
      avec un coût moyen de 15€ par client (email + discount 10%),
      calculez le ROI attendu sur 6 mois basé sur leur LTV historique.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 15
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 55 : Segmentation RFM complète (Récence, Fréquence, Valeur).
                   Analyse du parcours client, cohortes d'acquisition,
                   profil des champions, analyse géographique.

  [OK] Chapitre 56 : Scorecard produit 360°, analyse des marges, cycle de vie,
                   Voice of Customer via les avis, détection des produits zombies.
                   Analyse ABC et rotation des stocks.

  [OK] Chapitre 57 : Définir et calculer le churn. Score de risque churn.
                   Détection précoce via les signaux comportementaux.
                   Segmentation pour les campagnes de rétention.
                   Matrice de rétention par cohorte. Impact financier du churn.

POINTS CLÉS À RETENIR :
  -> RFM = le framework de segmentation client numéro 1 en retail
  -> Le churn implicite (e-commerce sans abonnement) nécessite une définition
    claire du seuil d'inactivité adapté au cycle d'achat de votre métier
  -> Agir à 30-60 jours d'inactivité est 3× plus efficace qu'à 90+ jours
  -> Un produit sans vente depuis 6 mois coûte plus qu'il ne rapporte
  -> La matrice de rétention révèle la "leaky bucket" : combien de clients
    acquis chaque mois s'évaporent rapidement

PROCHAINE PARTIE :
  Partie 16 — Architecture data : Data Warehouse, ETL, pipelines SQL.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 15 — CAS BUSINESS RÉELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                  PARTIE 16 — ARCHITECTURE DATA                                  ║
║                         Chapitres 58 à 60                                       ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Avancé — Architecture
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 16
════════════════════════════════
  Chapitre 58 : Data Warehouse — Architecture et modélisation dimensionnelle
  Chapitre 59 : ETL — Extract, Transform, Load avec SQL
  Chapitre 60 : Pipelines de données — Orchestration et automatisation SQL

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 58 — DATA WAREHOUSE : ARCHITECTURE ET MODÉLISATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

58.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce qu'un Data Warehouse ?
  Un Data Warehouse (entrepôt de données) est une base de données optimisée
  pour l'analyse et les rapports, contrairement à une base OLTP (Online
  Transaction Processing) optimisée pour les transactions en temps réel.

Pourquoi il existe ?
  ShopFlow (base OLTP) est conçue pour traiter rapidement des commandes.
  Elle est normalisée, avec de nombreuses tables et jointures.
  Pour les analyses (calculs sur des millions de lignes, rapports complexes),
  cette structure est trop lente et trop complexe.
  Le Data Warehouse résout cela avec un schéma dénormalisé optimisé pour la lecture.

Différences fondamentales OLTP vs OLAP :

  ┌─────────────────────┬──────────────────────┬──────────────────────┐
  │ Critère             │ OLTP (ShopFlow)       │ OLAP (Data Warehouse)│
  ├─────────────────────┼──────────────────────┼──────────────────────┤
  │ Objectif            │ Transactions courantes│ Analyse historique   │
  │ Modèle              │ Normalisé (3NF)       │ Dénormalisé (étoile) │
  │ Nb tables           │ Beaucoup (FK)         │ Peu (fact + dim)     │
  │ Opérations          │ INSERT/UPDATE/DELETE  │ SELECT principalement│
  │ Taille données      │ Actuelle (Go)         │ Historique (To)      │
  │ Temps réponse       │ Millisecondes         │ Secondes à minutes   │
  │ Utilisateurs        │ App, API              │ Analystes, BI tools  │
  │ Granularité         │ Transaction exacte    │ Agrégée (jour, mois) │
  └─────────────────────┴──────────────────────┴──────────────────────┘

58.2 MODÈLES DE DONNÉES EN DATA WAREHOUSE
──────────────────────────────────────────

SCHÉMA EN ÉTOILE (Star Schema)
  Le plus simple et le plus courant.
  Une table de faits centrale reliée à des tables de dimensions.

  Composants :
  -> Table de FAITS : contient les mesures numériques (CA, quantité, etc.)
                     et les clés étrangères vers les dimensions.
  -> Tables de DIMENSIONS : contiennent les attributs descriptifs
                            (nom client, nom produit, date, etc.).

  Avantage : requêtes simples (1 JOIN par dimension), performant.
  Inconvénient : légère redondance dans les dimensions.

SCHÉMA EN FLOCON (Snowflake Schema)
  Extension du schéma étoile : les dimensions sont normalisées.
  Exemple : dim_date -> dim_mois -> dim_trimestre -> dim_annee.
  Avantage : moins de redondance.
  Inconvénient : plus de JOIN, plus complexe.

GALAXY SCHEMA (Fact Constellation)
  Plusieurs tables de faits partageant des dimensions communes.
  Ex : faits_ventes + faits_stocks partageant dim_produit.

58.3 CONSTRUIRE LE DATA WAREHOUSE SHOPFLOW
────────────────────────────────────────────

-- Structure du DWH ShopFlow
-- Toutes les tables DWH sont dans un schéma séparé "dwh"

CREATE SCHEMA IF NOT EXISTS dwh;

────────────────────────────────────────
DIMENSION 1 : dim_date (calendrier complet)
────────────────────────────────────────

-- La dimension date est la plus importante du DWH.
-- Elle permet de filtrer/grouper par n'importe quelle granularité temporelle
-- sans recalculer à chaque fois.

CREATE TABLE dwh.dim_date (
    date_id         INTEGER PRIMARY KEY,  -- YYYYMMDD (ex: 20240315)
    date_complete   DATE NOT NULL,
    annee           SMALLINT NOT NULL,
    semestre        SMALLINT NOT NULL,    -- 1 ou 2
    trimestre       SMALLINT NOT NULL,    -- 1, 2, 3 ou 4
    mois            SMALLINT NOT NULL,    -- 1 à 12
    mois_nom        VARCHAR(20) NOT NULL, -- Janvier, Février...
    mois_abrege     CHAR(3) NOT NULL,     -- Jan, Fév...
    semaine_annee   SMALLINT NOT NULL,    -- 1 à 53 (ISO)
    jour_mois       SMALLINT NOT NULL,    -- 1 à 31
    jour_semaine    SMALLINT NOT NULL,    -- 1=Lundi à 7=Dimanche (ISO)
    jour_nom        VARCHAR(20) NOT NULL, -- Lundi, Mardi...
    est_weekend     BOOLEAN NOT NULL,
    est_jour_ferie  BOOLEAN DEFAULT FALSE,
    nb_jours_mois   SMALLINT NOT NULL,
    premier_du_mois DATE NOT NULL,
    dernier_du_mois DATE NOT NULL,
    -- Libellés pour les rapports
    label_mois_annee VARCHAR(20) NOT NULL, -- "Mars 2024"
    label_trim_annee VARCHAR(10) NOT NULL  -- "T1 2024"
);

-- Population de la dimension date avec generate_series
INSERT INTO dwh.dim_date
SELECT
    TO_CHAR(d, 'YYYYMMDD')::INTEGER                   AS date_id,
    d::DATE                                            AS date_complete,
    EXTRACT(YEAR    FROM d)::SMALLINT                  AS annee,
    CASE WHEN EXTRACT(MONTH FROM d) <= 6 THEN 1 ELSE 2 END::SMALLINT AS semestre,
    EXTRACT(QUARTER FROM d)::SMALLINT                  AS trimestre,
    EXTRACT(MONTH   FROM d)::SMALLINT                  AS mois,
    TO_CHAR(d, 'TMMonth')                              AS mois_nom,
    TO_CHAR(d, 'TMMon')                                AS mois_abrege,
    EXTRACT(WEEK    FROM d)::SMALLINT                  AS semaine_annee,
    EXTRACT(DAY     FROM d)::SMALLINT                  AS jour_mois,
    EXTRACT(ISODOW  FROM d)::SMALLINT                  AS jour_semaine,
    TO_CHAR(d, 'TMDay')                                AS jour_nom,
    EXTRACT(ISODOW  FROM d) IN (6, 7)                  AS est_weekend,
    FALSE                                              AS est_jour_ferie,
    EXTRACT(DAY FROM (DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day'))
        ::SMALLINT                                     AS nb_jours_mois,
    DATE_TRUNC('month', d)::DATE                       AS premier_du_mois,
    (DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day')::DATE AS dernier_du_mois,
    TO_CHAR(d, 'TMMonth YYYY')                         AS label_mois_annee,
    'T' || EXTRACT(QUARTER FROM d)::TEXT || ' ' || EXTRACT(YEAR FROM d)::TEXT AS label_trim_annee
FROM generate_series('2020-01-01'::DATE, '2030-12-31'::DATE, '1 day') d;

-- Index pour les requêtes fréquentes
CREATE INDEX ON dwh.dim_date (date_complete);
CREATE INDEX ON dwh.dim_date (annee, mois);
CREATE INDEX ON dwh.dim_date (annee, trimestre);

────────────────────────────────────────
DIMENSION 2 : dim_client
────────────────────────────────────────

-- Snapshot de l'état du client au moment de l'analyse
-- (SCD Type 2 simplifié : on garde la version courante)

CREATE TABLE dwh.dim_client (
    client_sk       SERIAL PRIMARY KEY,   -- Surrogate Key (clé technique du DWH)
    id_client       INTEGER NOT NULL,     -- Natural Key (clé du système source)
    code_client     VARCHAR(20),
    prenom          VARCHAR(100),
    nom             VARCHAR(100),
    nom_complet     VARCHAR(200),
    email           VARCHAR(200),
    adresse_ville   VARCHAR(100),
    adresse_cp      VARCHAR(10),
    adresse_pays    VARCHAR(100),
    segment         VARCHAR(50),
    age_annees      SMALLINT,
    tranche_age     VARCHAR(30),          -- "25-34 ans", "35-44 ans"...
    newsletter      BOOLEAN,
    annee_inscription SMALLINT,
    mois_inscription SMALLINT,
    actif           BOOLEAN,
    -- Méta-données DWH
    date_chargement TIMESTAMP DEFAULT NOW(),
    est_actuel      BOOLEAN DEFAULT TRUE
);

-- Chargement initial depuis OLTP
INSERT INTO dwh.dim_client (
    id_client, code_client, prenom, nom, nom_complet, email,
    adresse_ville, adresse_cp, adresse_pays, segment,
    age_annees, tranche_age, newsletter, annee_inscription, mois_inscription, actif
)
SELECT
    id_client,
    code_client,
    prenom,
    nom,
    prenom || ' ' || nom                               AS nom_complet,
    email,
    adresse_ville,
    adresse_cp,
    adresse_pays,
    segment,
    EXTRACT(YEAR FROM AGE(NOW(), date_naissance))::SMALLINT AS age_annees,
    CASE
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 25 THEN '< 25 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 35 THEN '25-34 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 45 THEN '35-44 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 55 THEN '45-54 ans'
        ELSE '55 ans et +'
    END                                                AS tranche_age,
    newsletter,
    EXTRACT(YEAR FROM date_inscription)::SMALLINT      AS annee_inscription,
    EXTRACT(MONTH FROM date_inscription)::SMALLINT     AS mois_inscription,
    actif
FROM clients;

────────────────────────────────────────
DIMENSION 3 : dim_produit
────────────────────────────────────────

CREATE TABLE dwh.dim_produit (
    produit_sk      SERIAL PRIMARY KEY,
    id_produit      INTEGER NOT NULL,
    reference       VARCHAR(50),
    nom             VARCHAR(200),
    categorie_id    INTEGER,
    categorie_nom   VARCHAR(100),          -- Dénormalisé ici (pas de JOIN à faire)
    categorie_parent VARCHAR(100),
    fournisseur_id  INTEGER,
    fournisseur_nom VARCHAR(200),
    fournisseur_pays VARCHAR(100),
    prix_ht_actuel  DECIMAL(10,2),
    actif           BOOLEAN,
    date_creation   DATE,
    date_chargement TIMESTAMP DEFAULT NOW()
);

-- Chargement avec dénormalisation catégorie + fournisseur
INSERT INTO dwh.dim_produit (
    id_produit, reference, nom, categorie_id, categorie_nom, categorie_parent,
    fournisseur_id, fournisseur_nom, fournisseur_pays, prix_ht_actuel, actif, date_creation
)
SELECT
    p.id_produit,
    p.reference,
    p.nom,
    cat.id_categorie,
    cat.nom                                            AS categorie_nom,
    COALESCE(cat_parent.nom, cat.nom)                 AS categorie_parent,
    f.id_fournisseur,
    f.raison_sociale,
    f.pays,
    p.prix_ht,
    p.actif,
    p.date_creation::DATE
FROM produits p
JOIN categories cat       ON p.id_categorie = cat.id_categorie
LEFT JOIN categories cat_parent ON cat.id_parent = cat_parent.id_categorie
JOIN fournisseurs f        ON p.id_fournisseur = f.id_fournisseur;

────────────────────────────────────────
TABLE DE FAITS : fact_lignes_ventes
────────────────────────────────────────

-- La table de faits est le cœur du DWH.
-- Elle contient les mesures et les clés vers les dimensions.
-- Grain : une ligne = une ligne de commande.

CREATE TABLE dwh.fact_lignes_ventes (
    -- Clés de substitution vers les dimensions
    date_sk             INTEGER REFERENCES dwh.dim_date(date_id),
    client_sk           INTEGER REFERENCES dwh.dim_client(client_sk),
    produit_sk          INTEGER REFERENCES dwh.dim_produit(produit_sk),
    -- Clé naturelle (pour les jointures avec le système source si besoin)
    id_commande         INTEGER,
    id_ligne            INTEGER,
    -- Mesures (les KPIs qu'on veut analyser)
    quantite            INTEGER,
    prix_unitaire_ht    DECIMAL(10,2),
    taux_tva            DECIMAL(4,2),
    remise_pct          DECIMAL(5,2),
    montant_ht          DECIMAL(12,2),  -- quantite * prix * (1 - remise%)
    montant_tva         DECIMAL(12,2),
    montant_ttc         DECIMAL(12,2),
    -- Informations sur la commande (dénormalisées pour éviter des JOIN)
    statut_commande     VARCHAR(50),
    -- Méta-données
    date_chargement     TIMESTAMP DEFAULT NOW(),
    -- Clé primaire composite
    PRIMARY KEY (id_commande, id_ligne)
);

-- Chargement de la table de faits
INSERT INTO dwh.fact_lignes_ventes (
    date_sk, client_sk, produit_sk,
    id_commande, id_ligne,
    quantite, prix_unitaire_ht, taux_tva, remise_pct,
    montant_ht, montant_tva, montant_ttc,
    statut_commande
)
SELECT
    -- Jointures pour obtenir les surrogate keys
    TO_CHAR(c.date_commande, 'YYYYMMDD')::INTEGER      AS date_sk,
    dc.client_sk,
    dp.produit_sk,
    lc.id_commande,
    lc.id_ligne,
    lc.quantite,
    lc.prix_unitaire_ht,
    lc.taux_tva,
    COALESCE(lc.remise_pct, 0)                         AS remise_pct,
    ROUND(lc.montant_ligne_ht, 2)                      AS montant_ht,
    ROUND(lc.montant_ligne_ht * lc.taux_tva / 100, 2) AS montant_tva,
    ROUND(lc.montant_ligne_ht * (1 + lc.taux_tva/100), 2) AS montant_ttc,
    c.statut                                           AS statut_commande
FROM lignes_commande lc
JOIN commandes c        ON lc.id_commande = c.id_commande
JOIN dwh.dim_client dc  ON c.id_client = dc.id_client AND dc.est_actuel = TRUE
JOIN dwh.dim_produit dp ON lc.id_produit = dp.id_produit;

-- Index sur les clés de dimension (essentiels pour les JOIN DWH)
CREATE INDEX ON dwh.fact_lignes_ventes (date_sk);
CREATE INDEX ON dwh.fact_lignes_ventes (client_sk);
CREATE INDEX ON dwh.fact_lignes_ventes (produit_sk);
CREATE INDEX ON dwh.fact_lignes_ventes (statut_commande);

58.4 REQUÊTES ANALYTIQUES DANS LE DWH
───────────────────────────────────────

-- Maintenant que le DWH est en place, les requêtes sont simples et rapides.
-- Plus de multiples JOIN complexes sur les tables normalisées OLTP.

-- CA mensuel par catégorie (requête DWH)
SELECT
    dd.label_mois_annee                                AS periode,
    dp.categorie_nom,
    SUM(f.montant_ttc)                                 AS ca_ttc,
    SUM(f.quantite)                                    AS unites_vendues
FROM dwh.fact_lignes_ventes f
JOIN dwh.dim_date dd    ON f.date_sk = dd.date_id
JOIN dwh.dim_produit dp ON f.produit_sk = dp.produit_sk
WHERE f.statut_commande NOT IN ('ANNULEE', 'REMBOURSEE')
  AND dd.annee = 2024
GROUP BY dd.label_mois_annee, dd.mois, dp.categorie_nom
ORDER BY dd.mois, ca_ttc DESC;

-- Sans DWH, cette même requête nécessiterait 4-5 JOIN sur les tables normalisées.
-- Avec DWH : seulement 2 JOIN sur des tables pré-calculées.

-- Performance segmentée par tranche d'âge
SELECT
    dc.tranche_age,
    dc.segment,
    COUNT(DISTINCT f.client_sk)                        AS nb_clients,
    ROUND(AVG(f.montant_ttc), 2)                       AS panier_moyen,
    ROUND(SUM(f.montant_ttc), 2)                       AS ca_total
FROM dwh.fact_lignes_ventes f
JOIN dwh.dim_client dc  ON f.client_sk = dc.client_sk
JOIN dwh.dim_date dd    ON f.date_sk = dd.date_id
WHERE f.statut_commande NOT IN ('ANNULEE', 'REMBOURSEE')
  AND dd.annee = 2024
GROUP BY dc.tranche_age, dc.segment
ORDER BY dc.tranche_age, ca_total DESC;

58.5 SLOWLY CHANGING DIMENSIONS (SCD)
───────────────────────────────────────

-- Un client peut changer de segment (de STANDARD à VIP).
-- Comment gérer cela dans le DWH ?

-- SCD TYPE 1 : Écraser la valeur actuelle (pas d'historique)
-- -> Simple mais on perd l'historique
UPDATE dwh.dim_client
SET segment = 'VIP',
    date_chargement = NOW()
WHERE id_client = 5;

-- SCD TYPE 2 : Conserver l'historique avec des lignes supplémentaires
-- -> Ajouter les colonnes date_debut, date_fin, est_actuel

-- Modification de la table dim_client pour supporter SCD Type 2
ALTER TABLE dwh.dim_client
    ADD COLUMN IF NOT EXISTS date_debut DATE DEFAULT '2000-01-01',
    ADD COLUMN IF NOT EXISTS date_fin   DATE DEFAULT '9999-12-31';

-- Quand le segment d'un client change (ex: id_client = 5 passe à VIP) :
-- Étape 1 : fermer l'ancienne version
UPDATE dwh.dim_client
SET date_fin   = CURRENT_DATE - 1,
    est_actuel = FALSE
WHERE id_client = 5 AND est_actuel = TRUE;

-- Étape 2 : insérer la nouvelle version
INSERT INTO dwh.dim_client (
    id_client, nom_complet, segment, email,
    date_debut, date_fin, est_actuel, date_chargement
)
SELECT
    id_client, prenom || ' ' || nom, 'VIP', email,
    CURRENT_DATE, '9999-12-31'::DATE, TRUE, NOW()
FROM clients WHERE id_client = 5;

-- Avec SCD Type 2, on peut répondre :
-- "Quel était le segment du client 5 lors de sa commande en janvier 2024 ?"
SELECT dc.segment
FROM dwh.fact_lignes_ventes f
JOIN dwh.dim_client dc ON f.client_sk = dc.client_sk
JOIN dwh.dim_date dd   ON f.date_sk = dd.date_id
WHERE dc.id_client = 5
  AND dd.date_complete = '2024-01-15';

58.6 EXERCICES PRATIQUES — DATA WAREHOUSE
──────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Écrivez le SELECT qui peuple la dimension dim_employe depuis la table
      employes OLTP, en incluant le nom du manager (self-join).

Ex2 : Dans le DWH, calculez le CA par jour de la semaine (Lundi à Dimanche)
      sur l'année 2024. Utilisez dim_date.jour_nom.

Ex3 : Ajouter une table de faits agrégée fact_ventes_journalieres
      qui stocke le CA, le nombre de commandes et les clients actifs
      par jour (grain : 1 ligne par jour). Écrivez l'INSERT.

NIVEAU INTERMÉDIAIRE :

Ex4 : Construisez la requête qui détecte les "glissements" SCD Type 2 :
      quels clients ont changé de segment depuis le dernier chargement ?
      (Comparer dwh.dim_client.segment avec clients.segment dans l'OLTP)

Ex5 : Créez une vue dwh.v_ca_pivot qui affiche pour chaque catégorie
      une colonne par trimestre (T1, T2, T3, T4) pour l'année en cours.
      Utilisez le FILTER clause pour le pivot.

Ex6 : Écrivez la requête qui identifie les "trous" dans dim_date :
      des jours où la table de faits a des enregistrements
      mais dim_date n'a pas de correspondance (données orphelines).

NIVEAU AVANCÉ :

Ex7 : Implémentez le calcul du Stock valorisé en fin de mois.
      Créez une table fact_snapshot_stock (snapshot mensuel du stock) :
      pour chaque produit et chaque fin de mois, stocker le stock actuel
      et sa valeur (stock × prix_achat_estimé). Automatiser le calcul.

Ex8 : Construisez un DWH simplifié pour les ressources humaines :
      dim_employe, dim_departement, dim_date + fact_performances (commandes
      par employé par mois). Incluez le CA, le nombre de commandes,
      le panier moyen et le rang dans le département.

Ex9 : Implémentez une stratégie de "late arriving data" :
      des commandes peuvent être insérées avec une date_commande passée
      (rétroactif). Comment détecter et recharger les faits affectés ?
      Écrivez le script de correction.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 59 — ETL : EXTRACT, TRANSFORM, LOAD AVEC SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

59.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que l'ETL ?
  ETL = Extract, Transform, Load.
  C'est le processus qui prend des données d'un système source (OLTP),
  les nettoie et les transforme, puis les charge dans un système cible (DWH).

Pourquoi ça existe ?
  Les données brutes sont rarement prêtes pour l'analyse :
  -> Valeurs manquantes ou incohérentes
  -> Formats différents selon les sources
  -> Doublons et erreurs de saisie
  -> Structures incompatibles

Variante moderne : ELT (Extract, Load, Transform)
  Dans les architectures cloud modernes (Snowflake, BigQuery, dbt),
  on charge d'abord les données brutes, puis on les transforme sur place.
  SQL est alors au cœur de la transformation.

59.2 EXTRACT : EXTRACTION DEPUIS L'OLTP
─────────────────────────────────────────

-- Zone de staging : zone tampon pour les données brutes extraites
-- avant transformation. Elle reflète fidèlement la source.

CREATE SCHEMA IF NOT EXISTS staging;

-- Table de staging pour les commandes (copie brute)
CREATE TABLE staging.stg_commandes (
    id_commande         INTEGER,
    numero_commande     VARCHAR(30),
    id_client           INTEGER,
    id_employe          INTEGER,
    date_commande       TIMESTAMP,
    date_livraison      TIMESTAMP,
    statut              VARCHAR(50),
    montant_ht          DECIMAL(12,2),
    montant_tva         DECIMAL(12,2),
    montant_ttc         DECIMAL(12,2),
    frais_livraison     DECIMAL(8,2),
    note_client         TEXT,
    -- Méta ETL
    date_extraction     TIMESTAMP DEFAULT NOW(),
    source_systeme      VARCHAR(50) DEFAULT 'shopflow_oltp',
    batch_id            INTEGER  -- identifiant du batch d'extraction
);

-- Extraction incrémentale (seulement les données nouvelles/modifiées)
-- On utilise une table de contrôle pour savoir jusqu'où on a extrait

CREATE TABLE IF NOT EXISTS staging.etl_watermark (
    table_source        VARCHAR(100) PRIMARY KEY,
    derniere_extraction TIMESTAMP NOT NULL,
    nb_lignes_extraites INTEGER,
    statut              VARCHAR(20) DEFAULT 'SUCCESS'
);

-- Extraire les nouvelles commandes depuis le dernier watermark
INSERT INTO staging.stg_commandes (
    id_commande, numero_commande, id_client, id_employe,
    date_commande, date_livraison, statut,
    montant_ht, montant_tva, montant_ttc, frais_livraison,
    note_client, batch_id
)
SELECT
    c.id_commande, c.numero_commande, c.id_client, c.id_employe,
    c.date_commande, c.date_livraison, c.statut,
    c.montant_ht, c.montant_tva, c.montant_ttc, c.frais_livraison,
    c.note_client,
    -- Numéro de batch (pour le suivi)
    (SELECT COALESCE(MAX(batch_id), 0) + 1 FROM staging.stg_commandes)
FROM commandes c
WHERE c.date_commande > (
    SELECT COALESCE(derniere_extraction, '2000-01-01'::TIMESTAMP)
    FROM staging.etl_watermark
    WHERE table_source = 'commandes'
);

-- Mettre à jour le watermark après extraction
INSERT INTO staging.etl_watermark (table_source, derniere_extraction, nb_lignes_extraites)
VALUES ('commandes', NOW(), @@ROWCOUNT)
ON CONFLICT (table_source) DO UPDATE
SET derniere_extraction = EXCLUDED.derniere_extraction,
    nb_lignes_extraites = EXCLUDED.nb_lignes_extraites;

59.3 TRANSFORM : NETTOYAGE ET ENRICHISSEMENT
──────────────────────────────────────────────

-- Zone de transformation : les données sont nettoyées et conformées.
-- C'est ici que la logique métier est appliquée.

CREATE SCHEMA IF NOT EXISTS transform;

────────────────────────────────────────
Transformation 1 : Nettoyage des données
────────────────────────────────────────

CREATE TABLE transform.trn_clients AS
SELECT
    id_client,
    -- Nettoyage des noms (trim + initcap)
    INITCAP(TRIM(prenom))                              AS prenom_clean,
    UPPER(TRIM(nom))                                   AS nom_clean,
    -- Email en minuscules, trim
    LOWER(TRIM(email))                                 AS email_clean,
    -- Normaliser le code postal (5 chiffres, zéro padded)
    LPAD(REPLACE(REPLACE(adresse_cp, ' ', ''), '-', ''), 5, '0') AS cp_clean,
    -- Normaliser le téléphone (supprimer espaces/tirets)
    REGEXP_REPLACE(telephone, '[^0-9+]', '', 'g')     AS telephone_clean,
    segment,
    date_naissance,
    -- Calculer l'âge
    EXTRACT(YEAR FROM AGE(NOW(), date_naissance))::SMALLINT AS age_annees,
    -- Segmentation par âge
    CASE
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 18 THEN '< 18 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 25 THEN '18-24 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 35 THEN '25-34 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 45 THEN '35-44 ans'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), date_naissance)) < 55 THEN '45-54 ans'
        ELSE '55 ans +'
    END                                                AS tranche_age,
    date_inscription,
    actif,
    -- Flags de qualité des données
    (email IS NULL OR email NOT LIKE '%@%.%')          AS email_invalide,
    (date_naissance > NOW())                           AS naissance_future,
    (LENGTH(adresse_cp) NOT IN (4, 5))                 AS cp_suspect
FROM clients
WHERE actif = TRUE;  -- On ne charge que les clients actifs

────────────────────────────────────────
Transformation 2 : Calculs de mesures
────────────────────────────────────────

CREATE TABLE transform.trn_lignes_ventes AS
SELECT
    lc.id_ligne,
    lc.id_commande,
    lc.id_produit,
    c.id_client,
    c.date_commande,
    lc.quantite,
    lc.prix_unitaire_ht,
    lc.taux_tva,
    COALESCE(lc.remise_pct, 0)                         AS remise_pct,
    -- Montants calculés
    ROUND(lc.quantite * lc.prix_unitaire_ht
          * (1 - COALESCE(lc.remise_pct, 0) / 100), 2) AS montant_ht,
    ROUND(lc.quantite * lc.prix_unitaire_ht
          * (1 - COALESCE(lc.remise_pct, 0) / 100)
          * lc.taux_tva / 100, 2)                      AS montant_tva,
    ROUND(lc.quantite * lc.prix_unitaire_ht
          * (1 - COALESCE(lc.remise_pct, 0) / 100)
          * (1 + lc.taux_tva / 100), 2)                AS montant_ttc,
    -- Coût simulé (65% du prix HT = coût de revient)
    ROUND(lc.quantite * lc.prix_unitaire_ht * 0.65, 2) AS cout_revient,
    -- Marge brute
    ROUND(lc.quantite * lc.prix_unitaire_ht
          * (1 - COALESCE(lc.remise_pct, 0) / 100) * 0.35, 2) AS marge_brute,
    c.statut                                           AS statut_commande,
    -- Flag commande valide pour les KPIs
    c.statut NOT IN ('ANNULEE', 'REMBOURSEE')           AS est_valide
FROM lignes_commande lc
JOIN commandes c ON lc.id_commande = c.id_commande;

────────────────────────────────────────
Transformation 3 : Validation de qualité
────────────────────────────────────────

-- Rapport de qualité des données : anomalies à signaler avant le chargement

CREATE TABLE staging.etl_qualite AS
SELECT
    'clients' AS table_source,
    'email_invalide' AS type_anomalie,
    COUNT(*) AS nb_lignes,
    ROUND(COUNT(*)::DECIMAL / (SELECT COUNT(*) FROM clients) * 100, 1) AS pct_lignes
FROM clients
WHERE email IS NULL OR email NOT LIKE '%@%.%'

UNION ALL

SELECT 'commandes', 'montant_negatif', COUNT(*),
    ROUND(COUNT(*)::DECIMAL / (SELECT COUNT(*) FROM commandes) * 100, 1)
FROM commandes WHERE montant_ttc < 0

UNION ALL

SELECT 'lignes_commande', 'quantite_nulle', COUNT(*),
    ROUND(COUNT(*)::DECIMAL / (SELECT COUNT(*) FROM lignes_commande) * 100, 1)
FROM lignes_commande WHERE quantite <= 0

UNION ALL

SELECT 'commandes', 'client_orphelin', COUNT(*),
    ROUND(COUNT(*)::DECIMAL / (SELECT COUNT(*) FROM commandes) * 100, 1)
FROM commandes c
WHERE NOT EXISTS (SELECT 1 FROM clients cl WHERE cl.id_client = c.id_client);

-- Afficher le rapport
SELECT * FROM staging.etl_qualite ORDER BY nb_lignes DESC;

-- Règle : si le % d'anomalies dépasse le seuil (ex: 5%), bloquer le chargement.

59.4 LOAD : CHARGEMENT DANS LE DWH
────────────────────────────────────

-- Chargement incrémental avec UPSERT (INSERT ... ON CONFLICT)

-- Charger les nouvelles lignes de faits dans le DWH
INSERT INTO dwh.fact_lignes_ventes (
    date_sk, client_sk, produit_sk,
    id_commande, id_ligne,
    quantite, prix_unitaire_ht, taux_tva, remise_pct,
    montant_ht, montant_tva, montant_ttc,
    statut_commande
)
SELECT
    TO_CHAR(tv.date_commande, 'YYYYMMDD')::INTEGER     AS date_sk,
    dc.client_sk,
    dp.produit_sk,
    tv.id_commande,
    tv.id_ligne,
    tv.quantite,
    tv.prix_unitaire_ht,
    tv.taux_tva,
    tv.remise_pct,
    tv.montant_ht,
    tv.montant_tva,
    tv.montant_ttc,
    tv.statut_commande
FROM transform.trn_lignes_ventes tv
JOIN dwh.dim_client dc  ON tv.id_client = dc.id_client AND dc.est_actuel = TRUE
JOIN dwh.dim_produit dp ON tv.id_produit = dp.id_produit
-- Exclure ce qui est déjà chargé (idempotent)
ON CONFLICT (id_commande, id_ligne)
DO UPDATE SET
    statut_commande = EXCLUDED.statut_commande,  -- Mettre à jour le statut si changé
    date_chargement = NOW();

59.5 GESTION DES ERREURS ETL
──────────────────────────────

-- Table de log des erreurs ETL
CREATE TABLE IF NOT EXISTS staging.etl_log (
    log_id          SERIAL PRIMARY KEY,
    date_log        TIMESTAMP DEFAULT NOW(),
    batch_id        INTEGER,
    etape           VARCHAR(50),  -- 'EXTRACT', 'TRANSFORM', 'LOAD'
    table_cible     VARCHAR(100),
    nb_lignes_ok    INTEGER DEFAULT 0,
    nb_lignes_erreur INTEGER DEFAULT 0,
    message_erreur  TEXT,
    duree_ms        INTEGER,
    statut          VARCHAR(20) DEFAULT 'SUCCESS'  -- 'SUCCESS', 'WARNING', 'ERROR'
);

-- Pattern d'utilisation : wraper chaque étape ETL dans un bloc transaction
-- avec logging des erreurs

BEGIN;
  -- Sauvegarder le début
  INSERT INTO staging.etl_log (etape, table_cible, statut)
  VALUES ('LOAD', 'fact_lignes_ventes', 'IN_PROGRESS');

  -- Exécuter le chargement
  -- ... (INSERT INTO dwh.fact_lignes_ventes ...)

  -- Logger le succès avec le nombre de lignes
  UPDATE staging.etl_log
  SET statut = 'SUCCESS', nb_lignes_ok = (SELECT COUNT(*) FROM dwh.fact_lignes_ventes)
  WHERE log_id = lastval();

COMMIT;
-- Si erreur : ROLLBACK automatique, logguer l'erreur

59.6 EXERCICES PRATIQUES — ETL
────────────────────────────────

NIVEAU FACILE :

Ex1 : Écrivez la requête d'extraction incrémentale pour la table
      lignes_commande. Utilisez le watermark de staging.etl_watermark.
      Extraire seulement les lignes dont l'id_ligne est supérieur
      au dernier id_ligne extrait.

Ex2 : Créez une transformation qui nettoie la table produits :
      trim du nom, référence en majuscules, vérification du prix_ht > 0.
      Affichez un rapport de qualité avant transformation.

Ex3 : Écrivez la requête de validation qui bloque le chargement si plus
      de 2% des clients ont un email invalide. La requête doit retourner
      TRUE (OK) ou FALSE (BLOQUER).

NIVEAU INTERMÉDIAIRE :

Ex4 : Implémentez une détection de doublons dans la staging :
      quelles commandes dans stg_commandes ont le même (id_client, date_commande,
      montant_ttc) à ±1 heure d'intervalle ? Ces doublons doivent être
      flaggés avant le chargement.

Ex5 : Construisez un rapport de réconciliation ETL :
      comparer le CA total dans l'OLTP (commandes) vs le CA dans le DWH
      (fact_lignes_ventes) pour les données chargées ce jour.
      Les deux nombres doivent correspondre à ±0.01€.

Ex6 : Implémentez une stratégie de "delta load" pour dim_client :
      identifier les clients dont le segment a changé depuis le dernier
      chargement et mettre à jour le DWH avec le SCD Type 1 (écrasement).

NIVEAU AVANCÉ :

Ex7 : Construisez un ETL complet avec gestion des erreurs :
      extraction -> transformation -> validation -> chargement.
      Chaque étape doit logguer dans staging.etl_log. Si la validation
      échoue (>2% d'anomalies), stopper avec un message d'erreur clair.

Ex8 : Implémentez la transformation "commandes multi-devises" :
      supposez que certaines commandes sont en USD ou GBP.
      Créez une table staging.taux_change (date, devise, taux_eur)
      et une transformation qui convertit tous les montants en EUR
      au taux du jour de la commande.

Ex9 : Construisez un DWH "mini" en mémoire pour les tests :
      créez toutes les tables DWH dans un schéma dwh_test, chargez
      10% des données de l'OLTP (TABLESAMPLE), exécutez les tests de
      qualité et de réconciliation, et nettoyez le schéma dwh_test à la fin.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 60 — PIPELINES DE DONNÉES : ORCHESTRATION ET AUTOMATISATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

60.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce qu'un pipeline de données ?
  Un pipeline orchestre l'exécution séquentielle ou parallèle de traitements
  de données : extraction -> nettoyage -> transformation -> chargement -> validation.

Pourquoi ça existe ?
  Un ETL manuel qui s'exécute à la main n'est pas scalable.
  Un pipeline automatisé garantit que les données sont fraîches, cohérentes
  et disponibles aux bonnes heures pour les équipes.

Architecture typique :

  Déclencheur (cron, événement)
         v
  Extraction OLTP -> staging
         v
  Validation qualité -> alertes si anomalies
         v
  Transformation -> zone transform
         v
  Chargement -> DWH
         v
  Rafraîchissement vues matérialisées
         v
  Notification (email, Slack)

60.2 TABLES DE CONTRÔLE DU PIPELINE
──────────────────────────────────────

-- Infrastructure de contrôle d'un pipeline SQL

-- Table des étapes du pipeline
CREATE TABLE IF NOT EXISTS staging.pipeline_definition (
    etape_id        SERIAL PRIMARY KEY,
    nom_etape       VARCHAR(100) UNIQUE NOT NULL,
    ordre_execution SMALLINT NOT NULL,
    sql_a_executer  TEXT NOT NULL,
    dependances     TEXT[],  -- noms des étapes précédentes requises
    actif           BOOLEAN DEFAULT TRUE,
    description     TEXT
);

-- Enregistrement des étapes du pipeline ShopFlow
INSERT INTO staging.pipeline_definition
    (nom_etape, ordre_execution, sql_a_executer, description)
VALUES
(
    'EXTRACT_CLIENTS',
    1,
    'INSERT INTO staging.stg_clients SELECT * FROM clients
     WHERE date_inscription > (SELECT derniere_extraction FROM staging.etl_watermark WHERE table_source = ''clients'')',
    'Extraction incrémentale des nouveaux clients'
),
(
    'EXTRACT_COMMANDES',
    2,
    'INSERT INTO staging.stg_commandes SELECT * FROM commandes WHERE ...',
    'Extraction incrémentale des nouvelles commandes'
),
(
    'TRANSFORM_CLEAN',
    3,
    'REFRESH MATERIALIZED VIEW staging.mv_clients_clean;
     REFRESH MATERIALIZED VIEW staging.mv_commandes_clean;',
    'Nettoyage et conformité des données'
),
(
    'LOAD_DWH',
    4,
    'INSERT INTO dwh.fact_lignes_ventes ... ON CONFLICT DO UPDATE ...',
    'Chargement incrémental dans le Data Warehouse'
),
(
    'REFRESH_MVS',
    5,
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_kpi_quotidien;
     REFRESH MATERIALIZED VIEW CONCURRENTLY mv_rapport_mensuel;',
    'Rafraîchissement des vues matérialisées analytiques'
);

-- Table de log d'exécution du pipeline
CREATE TABLE IF NOT EXISTS staging.pipeline_execution (
    execution_id    SERIAL PRIMARY KEY,
    run_id          UUID DEFAULT gen_random_uuid(),  -- identifiant unique du run
    date_debut      TIMESTAMP DEFAULT NOW(),
    date_fin        TIMESTAMP,
    etape_id        INTEGER REFERENCES staging.pipeline_definition(etape_id),
    nom_etape       VARCHAR(100),
    statut          VARCHAR(20) DEFAULT 'RUNNING',  -- RUNNING, SUCCESS, FAILED, SKIPPED
    nb_lignes_ok    INTEGER DEFAULT 0,
    nb_lignes_ko    INTEGER DEFAULT 0,
    message         TEXT,
    duree_ms        INTEGER
);

60.3 PROCÉDURE STOCKÉE D'ORCHESTRATION
────────────────────────────────────────

-- PostgreSQL supporte les procédures stockées (PL/pgSQL) pour l'orchestration.

CREATE OR REPLACE PROCEDURE staging.run_pipeline(
    p_run_id UUID DEFAULT gen_random_uuid()
)
LANGUAGE plpgsql AS $$
DECLARE
    v_etape     staging.pipeline_definition%ROWTYPE;
    v_exec_id   INTEGER;
    v_debut     TIMESTAMP;
    v_nb_lignes INTEGER;
    v_erreur    TEXT;
BEGIN
    RAISE NOTICE 'Démarrage du pipeline — Run ID: %', p_run_id;

    -- Itérer sur chaque étape dans l'ordre
    FOR v_etape IN
        SELECT * FROM staging.pipeline_definition
        WHERE actif = TRUE
        ORDER BY ordre_execution
    LOOP
        v_debut := NOW();
        RAISE NOTICE 'Étape % : %', v_etape.ordre_execution, v_etape.nom_etape;

        -- Logger le début de l'étape
        INSERT INTO staging.pipeline_execution (run_id, etape_id, nom_etape)
        VALUES (p_run_id, v_etape.etape_id, v_etape.nom_etape)
        RETURNING execution_id INTO v_exec_id;

        BEGIN
            -- Exécuter le SQL de l'étape
            EXECUTE v_etape.sql_a_executer;

            GET DIAGNOSTICS v_nb_lignes = ROW_COUNT;

            -- Logger le succès
            UPDATE staging.pipeline_execution
            SET statut = 'SUCCESS',
                date_fin = NOW(),
                nb_lignes_ok = v_nb_lignes,
                duree_ms = EXTRACT(MILLISECONDS FROM NOW() - v_debut)::INTEGER
            WHERE execution_id = v_exec_id;

            RAISE NOTICE '  [OK] Succès : % lignes en % ms',
                v_nb_lignes,
                EXTRACT(MILLISECONDS FROM NOW() - v_debut)::INTEGER;

        EXCEPTION WHEN OTHERS THEN
            GET STACKED DIAGNOSTICS v_erreur = MESSAGE_TEXT;

            -- Logger l'erreur
            UPDATE staging.pipeline_execution
            SET statut = 'FAILED',
                date_fin = NOW(),
                message = v_erreur,
                duree_ms = EXTRACT(MILLISECONDS FROM NOW() - v_debut)::INTEGER
            WHERE execution_id = v_exec_id;

            RAISE NOTICE '  [X] Erreur : %', v_erreur;

            -- Arrêter le pipeline sur erreur critique
            RAISE EXCEPTION 'Pipeline arrêté à l''étape % : %',
                v_etape.nom_etape, v_erreur;
        END;
    END LOOP;

    RAISE NOTICE '[OK] Pipeline terminé avec succès';
END;
$$;

-- Exécuter le pipeline
CALL staging.run_pipeline();

60.4 AUTOMATISATION AVEC PG_CRON
──────────────────────────────────

-- pg_cron est une extension PostgreSQL qui permet de planifier des tâches SQL.
-- Équivalent de cron (Linux) directement dans PostgreSQL.

-- Installation (nécessite accès super-utilisateur)
CREATE EXTENSION IF NOT EXISTS pg_cron;

-- Planifier le pipeline quotidien à 2h du matin
SELECT cron.schedule(
    'pipeline-shopflow-quotidien',     -- nom du job
    '0 2 * * *',                       -- cron expression : 2h00 tous les jours
    'CALL staging.run_pipeline();'     -- commande SQL à exécuter
);

-- Rafraîchir les vues matérialisées toutes les heures (dashboard temps quasi-réel)
SELECT cron.schedule(
    'refresh-mv-kpi-horaire',
    '0 * * * *',                       -- chaque heure
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_kpi_quotidien;'
);

-- Nettoyer les logs de pipeline plus vieux que 30 jours
SELECT cron.schedule(
    'nettoyage-logs-pipeline',
    '0 3 1 * *',                       -- 1er de chaque mois à 3h
    'DELETE FROM staging.pipeline_execution WHERE date_debut < NOW() - INTERVAL ''30 days'';
     DELETE FROM staging.etl_log WHERE date_log < NOW() - INTERVAL ''30 days'';'
);

-- Voir les jobs planifiés
SELECT * FROM cron.job;

-- Voir l'historique d'exécution
SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 20;

-- Supprimer un job
SELECT cron.unschedule('pipeline-shopflow-quotidien');

60.5 MONITORING DU PIPELINE
─────────────────────────────

-- Dashboard de suivi de la santé du pipeline

WITH derniers_runs AS (
    SELECT
        run_id,
        MAX(date_debut)                                AS debut_run,
        MAX(date_fin)                                  AS fin_run,
        COUNT(*)                                       AS nb_etapes_total,
        COUNT(*) FILTER (WHERE statut = 'SUCCESS')    AS nb_etapes_ok,
        COUNT(*) FILTER (WHERE statut = 'FAILED')     AS nb_etapes_ko,
        BOOL_OR(statut = 'FAILED')                    AS run_en_erreur
    FROM staging.pipeline_execution
    WHERE date_debut >= NOW() - INTERVAL '7 days'
    GROUP BY run_id
)
SELECT
    TO_CHAR(debut_run, 'DD/MM/YYYY HH24:MI')          AS debut,
    TO_CHAR(fin_run, 'DD/MM/YYYY HH24:MI')            AS fin,
    EXTRACT(MINUTES FROM fin_run - debut_run)::INTEGER AS duree_minutes,
    nb_etapes_ok || ' / ' || nb_etapes_total          AS etapes_ok,
    CASE
        WHEN run_en_erreur   THEN '[X] ERREUR'
        WHEN fin_run IS NULL THEN '[HOURGLASS_WITH_FLOWING_SAND] EN COURS'
        ELSE                      '[OK] SUCCÈS'
    END                                               AS statut_run
FROM derniers_runs
ORDER BY debut_run DESC;

-- Alertes : pipeline en retard ou en erreur
SELECT
    'Pipeline en erreur'                               AS alerte,
    MAX(date_debut)                                    AS date_derniere_execution,
    EXTRACT(HOURS FROM NOW() - MAX(date_debut))::INTEGER AS heures_depuis
FROM staging.pipeline_execution
WHERE statut = 'FAILED'
HAVING MAX(date_debut) >= NOW() - INTERVAL '24 hours';

60.6 DOCUMENTATION DE LA LIGNÉE DES DONNÉES (DATA LINEAGE)
────────────────────────────────────────────────────────────

-- Tracer d'où viennent les données dans chaque table du DWH.

CREATE TABLE IF NOT EXISTS staging.data_lineage (
    lineage_id      SERIAL PRIMARY KEY,
    table_cible     VARCHAR(100),
    champ_cible     VARCHAR(100),
    table_source    VARCHAR(100),
    champ_source    VARCHAR(100),
    transformation  TEXT,  -- description de la transformation appliquée
    date_creation   TIMESTAMP DEFAULT NOW()
);

-- Documentation de la transformation du CA
INSERT INTO staging.data_lineage
    (table_cible, champ_cible, table_source, champ_source, transformation)
VALUES
('dwh.fact_lignes_ventes', 'montant_ttc',
 'lignes_commande', 'quantite + prix_unitaire_ht + taux_tva + remise_pct',
 'quantite × prix_ht × (1 - remise%) × (1 + TVA%) — arrondi à 2 décimales'),
('dwh.dim_client', 'nom_complet',
 'clients', 'prenom + nom',
 'INITCAP(TRIM(prenom)) || '' '' || UPPER(TRIM(nom))'),
('dwh.dim_client', 'tranche_age',
 'clients', 'date_naissance',
 'Calculé depuis AGE(NOW(), date_naissance) — tranches : <25, 25-34, 35-44, 45-54, 55+');

-- Consulter la lignée d'un champ
SELECT *
FROM staging.data_lineage
WHERE table_cible = 'dwh.fact_lignes_ventes'
ORDER BY champ_cible;

60.7 EXERCICES PRATIQUES — PIPELINES
──────────────────────────────────────

NIVEAU FACILE :

Ex1 : Créez une table staging.etl_notification qui enregistre les alertes
      à envoyer après le pipeline. Chaque alerte contient : destinataire,
      sujet, corps du message, et statut d'envoi. Écrivez la requête
      qui insère une alerte si le pipeline a mis plus de 30 minutes.

Ex2 : Écrivez une requête qui calcule le "taux de succès" du pipeline
      sur les 30 derniers jours : % de runs sans erreur, durée moyenne,
      durée max.

Ex3 : Identifiez les étapes du pipeline qui prennent le plus de temps
      en moyenne (basé sur staging.pipeline_execution). Proposez des
      optimisations (index, partitionnement, vue matérialisée).

NIVEAU INTERMÉDIAIRE :

Ex4 : Implémentez une reprise sur erreur : si le pipeline échoue à
      l'étape 4 (LOAD_DWH), il doit pouvoir reprendre depuis l'étape 4
      sans réexécuter les étapes 1-3. Modifiez la procédure run_pipeline.

Ex5 : Construisez un test de régression ETL :
      après chaque chargement, vérifier que le CA total dans le DWH
      est cohérent avec l'OLTP (différence < 0.01€).
      Logguer le résultat dans staging.etl_qualite.

Ex6 : Implémentez la gestion des "dépendances entre étapes" :
      l'étape LOAD_DWH ne doit démarrer que si EXTRACT_COMMANDES
      et TRANSFORM_CLEAN ont réussi. Modifiez run_pipeline pour
      vérifier les dépendances avant chaque étape.

NIVEAU AVANCÉ :

Ex7 : Construisez un pipeline parallèle :
      EXTRACT_CLIENTS et EXTRACT_COMMANDES peuvent s'exécuter en parallèle
      (ce sont des sources indépendantes). Modifiez le pipeline pour
      exécuter ces étapes simultanément avec des sessions PostgreSQL séparées
      (via pg_advisory_lock pour la synchronisation).

Ex8 : Implémentez une "idempotency key" pour le pipeline :
      si le même batch est exécuté deux fois (crash + replay), les données
      ne doivent pas être doublées dans le DWH. Montrez le mécanisme complet
      de déduplication.

Ex9 : Construisez un "data quality contract" :
      une table qui définit les règles de qualité attendues pour chaque
      table (ex: "dans fact_lignes_ventes, montant_ttc doit toujours être > 0").
      Écrivez la procédure qui vérifie automatiquement ces contrats à la fin
      de chaque pipeline et génère un rapport de conformité.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 16
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 58 : Architecture Data Warehouse. Schéma en étoile vs flocon.
                   Tables de faits et dimensions. Construction du DWH ShopFlow
                   (dim_date, dim_client, dim_produit, fact_lignes_ventes).
                   Slowly Changing Dimensions (SCD Type 1 et 2).

  [OK] Chapitre 59 : ETL complet avec SQL. Zone staging, watermarks incrémentaux,
                   transformations de nettoyage, validation qualité, chargement
                   idempotent avec ON CONFLICT. Logging des erreurs ETL.

  [OK] Chapitre 60 : Pipelines de données. Tables de contrôle, procédures stockées
                   PL/pgSQL d'orchestration, pg_cron pour l'automatisation,
                   monitoring et data lineage. Gestion des erreurs et reprises.

POINTS CLÉS À RETENIR :
  -> OLTP (normalisé) ≠ OLAP/DWH (dénormalisé en étoile)
  -> dim_date est la dimension la plus importante : génération sur 10 ans dès le départ
  -> Les Surrogate Keys (SERIAL dans le DWH) découplent le DWH du système source
  -> SCD Type 2 = conservation de l'historique des changements de dimension
  -> Idempotency : un pipeline qui peut être rejoué sans corrompre les données
  -> pg_cron + procédures stockées = pipeline SQL natif sans outil externe

PROCHAINE PARTIE :
  Partie 17 — Sécurité SQL : permissions, rôles, sécurité des données.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 16 — ARCHITECTURE DATA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                       PARTIE 17 — SÉCURITÉ SQL                                  ║
║                          Chapitres 61 à 63                                      ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Avancé — Professionnel
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 17
════════════════════════════════
  Chapitre 61 : Permissions SQL — GRANT, REVOKE, contrôle d'accès
  Chapitre 62 : Rôles — Gestion des identités et accès (IAM SQL)
  Chapitre 63 : Sécurité des données — Chiffrement, audit, RGPD, RLS

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 61 — PERMISSIONS SQL : GRANT ET REVOKE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

61.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que les permissions SQL ?
  Les permissions définissent QUI peut faire QUOI sur QUELS objets de la
  base de données. C'est le contrôle d'accès (Access Control) au niveau SQL.

Pourquoi c'est crucial ?
  -> Un développeur backend ne doit pas pouvoir supprimer la table clients.
  -> Un analyste ne doit pas voir les salaires des employés.
  -> Un utilisateur de l'application ne doit accéder qu'à ses propres données.
  Sans permissions correctes, une faille applicative peut compromettre
  toute la base de données.

Modèle de sécurité PostgreSQL :
  -> Authentification : qui vous êtes (mot de passe, certificat, LDAP)
  -> Autorisation : ce que vous avez le droit de faire (permissions)
  -> Principe du moindre privilège : chaque utilisateur n'a que les droits
    strictement nécessaires à sa fonction.

61.2 NIVEAUX DE PERMISSIONS
─────────────────────────────

  Sur une TABLE :
    SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, ALL

  Sur un SCHEMA :
    USAGE (accéder aux objets), CREATE (créer des objets)

  Sur une BASE DE DONNÉES :
    CONNECT, CREATE (créer des schémas), TEMP

61.3 GRANT — OCTROYER DES PERMISSIONS
───────────────────────────────────────

-- Donner le droit de lecture à un utilisateur
GRANT SELECT ON clients TO analyst_user;

-- Plusieurs permissions à la fois
GRANT SELECT, INSERT, UPDATE ON commandes TO app_user;

-- SELECT sur toutes les tables du schéma public
GRANT USAGE ON SCHEMA public TO readonly_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;

-- Accès aux séquences (pour INSERTer avec SERIAL/IDENTITY)
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_user;

-- WITH GRANT OPTION : l'utilisateur peut re-déléguer ce droit
GRANT SELECT ON clients TO manager_user WITH GRANT OPTION;

-- ALTER DEFAULT PRIVILEGES : s'applique aux futurs objets
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO readonly_user;
-- Toute nouvelle table créée dans public sera lisible par readonly_user

61.4 REVOKE — RETIRER DES PERMISSIONS
───────────────────────────────────────

-- Retirer une permission spécifique
REVOKE INSERT ON commandes FROM app_user;

-- Retirer toutes les permissions
REVOKE ALL PRIVILEGES ON clients FROM dangerous_user;

-- CASCADE : retire aussi les permissions redéléguées par cet utilisateur
REVOKE SELECT ON clients FROM manager_user CASCADE;

-- Vérifier les permissions actuelles
SELECT grantee, table_name, privilege_type, is_grantable
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
ORDER BY grantee, table_name;

61.5 SÉCURITÉ AU NIVEAU COLONNES
──────────────────────────────────

-- Limiter l'accès à certaines colonnes seulement
GRANT SELECT (id_client, prenom, nom, email, segment)
    ON clients TO analyst_user;
-- analyst_user peut lire ces colonnes, mais pas telephone, date_naissance, etc.

-- Alternative plus courante : une vue qui n'expose que les colonnes autorisées
CREATE VIEW v_clients_public AS
SELECT id_client, prenom, nom, email, segment, adresse_ville, actif
FROM clients;

GRANT SELECT ON v_clients_public TO analyst_user;
REVOKE SELECT ON clients FROM analyst_user;

61.6 AUDIT DES PERMISSIONS
────────────────────────────

-- Rapport des permissions par utilisateur
SELECT
    grantee,
    table_schema,
    table_name,
    STRING_AGG(privilege_type, ', ' ORDER BY privilege_type) AS privileges
FROM information_schema.role_table_grants
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
  AND grantee != 'postgres'
GROUP BY grantee, table_schema, table_name
ORDER BY grantee, table_schema, table_name;

-- Superusers (critique pour la sécurité !)
SELECT usename AS utilisateur, usesuper, usecreatedb, valuntil AS expiration
FROM pg_user
WHERE usesuper = TRUE;

-- Connexions actives en ce moment
SELECT pid, usename, client_addr, state, LEFT(query, 80) AS requete_courante
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start DESC;

61.7 EXERCICES PRATIQUES — PERMISSIONS
────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Créez un utilisateur shopflow_readonly. Donnez-lui les droits pour
      lire toutes les tables ShopFlow sauf employes. Vérifiez qu'un
      SELECT sur employes échoue et que SELECT sur clients réussit.

Ex2 : Retirez toutes les permissions de shopflow_readonly sur la table
      employes (salaires — données sensibles). Vérifiez par une requête
      information_schema que la permission n'existe plus.

Ex3 : Listez tous les utilisateurs ayant le droit DELETE sur la table
      commandes. Cette liste doit être revue chaque mois.

NIVEAU INTERMÉDIAIRE :

Ex4 : Implémentez le principe du moindre privilège pour une appli web :
      app_read (SELECT sur les tables produit/catalogue),
      app_write (INSERT/UPDATE sur commandes et lignes_commande),
      app_auth (SELECT/UPDATE sur clients pour la gestion sessions).

Ex5 : Créez une vue v_employes_annuaire (id, prénom, nom, poste, dpt,
      sans salaire). Accordez SELECT à tous via un rôle groupe.
      Révoquez l'accès direct à la table employes pour ce groupe.

Ex6 : Écrivez un rapport qui détecte : utilisateurs avec ALL PRIVILEGES
      sur des tables sensibles, utilisateurs avec WITH GRANT OPTION,
      utilisateurs avec TRUNCATE sur des tables de production.

NIVEAU AVANCÉ :

Ex7 : Procédure de rotation de compte de service : crée app_user_v2,
      copie les permissions de app_user (via SQL dynamique depuis
      information_schema), puis révoque app_user de façon atomique.

Ex8 : Système "break glass" : compte admin_emergency désactivé (NOLOGIN).
      Procédure d'activation avec expiration auto dans 4h et log de qui
      a activé, quand, et pourquoi. Procédure de désactivation.

Ex9 : Audit complet ShopFlow : état actuel, excès de permissions,
      proposition d'un schéma minimal, application et documentation.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 62 — RÔLES : GESTION DES IDENTITÉS ET ACCÈS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

62.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce qu'un rôle dans PostgreSQL ?
  Dans PostgreSQL, un rôle est à la fois un utilisateur et un groupe.
  Un rôle peut se connecter (LOGIN) ou non, et peut contenir d'autres rôles.

Pourquoi les rôles ?
  Sans rôles : gérer les permissions de 50 développeurs individuellement
               -> 50 × N opérations GRANT pour chaque changement.
  Avec rôles : 3 rôles (dev, analyst, ops), les utilisateurs appartiennent
               aux rôles -> 3 × N opérations GRANT + assignation.

  Analogie : comme les groupes AD (Active Directory) ou les groupes Linux.

62.2 CRÉER ET GÉRER LES RÔLES
───────────────────────────────

-- Rôle "groupe" (sans LOGIN : ne peut pas se connecter directement)
CREATE ROLE role_readonly;
CREATE ROLE role_analyst;
CREATE ROLE role_developer;
CREATE ROLE role_dba;

-- Utilisateur (rôle avec LOGIN)
CREATE ROLE alice WITH LOGIN PASSWORD 'alice_secure_2024';

-- Avec options complètes
CREATE ROLE svc_application WITH
    LOGIN
    PASSWORD 'app_secret_key'
    NOSUPERUSER
    NOCREATEDB
    NOCREATEROLE
    CONNECTION LIMIT 20
    VALID UNTIL '2025-12-31';

-- Modifier un rôle
ALTER ROLE alice PASSWORD 'nouveau_mdp_secure';
ALTER ROLE svc_application CONNECTION LIMIT 50;
ALTER ROLE alice VALID UNTIL 'infinity';

-- Désactiver un compte sans le supprimer
ALTER ROLE alice NOLOGIN;
ALTER ROLE alice LOGIN;  -- Réactiver

-- Supprimer un rôle
REASSIGN OWNED BY alice TO postgres;
DROP OWNED BY alice;
DROP ROLE alice;

62.3 HIÉRARCHIE DE RÔLES
──────────────────────────

-- Assigner un utilisateur à un rôle (groupe)
GRANT role_readonly TO alice;
GRANT role_analyst  TO bob;

-- Hiérarchie : role_analyst hérite de toutes les permissions de role_readonly
GRANT role_readonly TO role_analyst;

-- Visualiser la hiérarchie
SELECT r.rolname AS role, m.rolname AS membre_de
FROM pg_roles r
JOIN pg_auth_members am ON r.oid = am.member
JOIN pg_roles m ON am.roleid = m.oid
WHERE r.rolname NOT LIKE 'pg_%'
ORDER BY role, membre_de;

62.4 ARCHITECTURE DE RÔLES POUR SHOPFLOW
──────────────────────────────────────────

-- Étape 1 : Rôles fonctionnels

-- Rôle lecture seule (données non-sensibles, sans employes)
CREATE ROLE shopflow_readonly;
GRANT CONNECT ON DATABASE shopflow TO shopflow_readonly;
GRANT USAGE ON SCHEMA public TO shopflow_readonly;
GRANT SELECT ON clients, produits, categories, commandes,
               lignes_commande, fournisseurs, paiements, avis,
               entrepots, stocks_entrepot TO shopflow_readonly;

-- Rôle analyste (hérite readonly + accès DWH + employes)
CREATE ROLE shopflow_analyst;
GRANT shopflow_readonly TO shopflow_analyst;
GRANT SELECT ON employes TO shopflow_analyst;
GRANT USAGE ON SCHEMA dwh TO shopflow_analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA dwh TO shopflow_analyst;

-- Rôle application backend (CRUD contrôlé)
CREATE ROLE shopflow_app;
GRANT CONNECT ON DATABASE shopflow TO shopflow_app;
GRANT USAGE ON SCHEMA public TO shopflow_app;
GRANT SELECT ON clients, produits, categories, commandes,
               lignes_commande, fournisseurs, paiements, avis,
               entrepots, stocks_entrepot TO shopflow_app;
GRANT INSERT, UPDATE ON commandes, lignes_commande, paiements, avis TO shopflow_app;
GRANT INSERT ON clients TO shopflow_app;
GRANT UPDATE (segment, actif) ON clients TO shopflow_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO shopflow_app;

-- Rôle DBA
CREATE ROLE shopflow_dba;
GRANT ALL PRIVILEGES ON DATABASE shopflow TO shopflow_dba;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO shopflow_dba;

-- Étape 2 : Utilisateurs concrets
CREATE ROLE alice_analyst WITH LOGIN PASSWORD 'secure_alice_2024';
GRANT shopflow_analyst TO alice_analyst;

CREATE ROLE svc_backend WITH LOGIN PASSWORD 'service_key_prod' CONNECTION LIMIT 50;
GRANT shopflow_app TO svc_backend;

-- Étape 3 : Test de conformité
SET ROLE alice_analyst;
SELECT COUNT(*) FROM clients;                -- OK [OK]
INSERT INTO commandes DEFAULT VALUES;        -- ERROR: permission denied [OK]
RESET ROLE;

62.5 ROW LEVEL SECURITY (RLS)
───────────────────────────────

-- RLS filtre automatiquement les lignes selon l'utilisateur connecté.
-- Cas d'usage : chaque commercial ne voit que ses propres commandes.

-- Activer RLS
ALTER TABLE commandes ENABLE ROW LEVEL SECURITY;

-- Politique : les commerciaux ne voient que leurs commandes
CREATE POLICY policy_commandes_employe
ON commandes FOR SELECT TO shopflow_app
USING (
    id_employe = (
        SELECT id_employe FROM employes WHERE email = CURRENT_USER
    )
);

-- Politique : les managers voient leur équipe
CREATE POLICY policy_commandes_manager
ON commandes FOR SELECT TO shopflow_app
USING (
    id_employe IN (
        SELECT e.id_employe FROM employes e
        WHERE e.id_manager = (
            SELECT id_employe FROM employes WHERE email = CURRENT_USER
        )
    )
    OR id_employe = (SELECT id_employe FROM employes WHERE email = CURRENT_USER)
);

-- Politique bypass pour les DBA
CREATE POLICY policy_admin_bypass
ON commandes FOR ALL TO shopflow_dba
USING (TRUE);

-- Les DBA ne sont pas bloqués par RLS
ALTER ROLE shopflow_dba BYPASSRLS;

-- Clients : chaque client ne voit que ses propres données
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
CREATE POLICY policy_clients_self
ON clients FOR SELECT TO shopflow_app
USING (email = CURRENT_USER);

-- Voir toutes les politiques RLS
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies WHERE schemaname = 'public';

62.6 EXERCICES PRATIQUES — RÔLES
──────────────────────────────────

NIVEAU FACILE :

Ex1 : Créez shopflow_marketing (SELECT sur clients, commandes, avis,
      produits, categories) et l'utilisateur marie_marketing. Testez.

Ex2 : Créez la hiérarchie role_base -> role_commercial -> role_manager_commercial.
      Chaque niveau hérite du précédent. Listez les membres de chaque rôle.

Ex3 : Désactivez svc_backend pour maintenance. Vérifiez qu'il ne peut plus
      se connecter. Réactivez avec expiration dans 6 mois.

NIVEAU INTERMÉDIAIRE :

Ex4 : Politique RLS sur avis : clients voient leurs avis uniquement,
      analystes voient tout, appli peut INSERT mais pas UPDATE. Testez.

Ex5 : Rapport "Inventaire des accès" : pour chaque utilisateur, ses rôles
      directs, ses rôles hérités, et ses permissions effectives.

Ex6 : Rotation de service account : svc_backend_v1 (actif) et svc_backend_v2
      (prêt). Procédure atomique de bascule qui active v2 et désactive v1.

NIVEAU AVANCÉ :

Ex7 : Politique multi-tenant avec RLS : ShopFlow héberge plusieurs
      entreprises clientes. Ajoutez tenant_id, créez les politiques RLS
      pour que chaque tenant ne voie que ses données.

Ex8 : Audit des rôles : trigger qui enregistre chaque GRANT/REVOKE avec
      l'auteur, la date et le motif (passé en paramètre de session).

Ex9 : ABAC simplifié : permissions dynamiques selon département,
      niveau hiérarchique et ancienneté de l'employé. Vues dynamiques.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 63 — SÉCURITÉ DES DONNÉES : CHIFFREMENT, AUDIT, RGPD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

63.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Qu'est-ce que la sécurité des données ?
  Au-delà des permissions (qui peut lire quoi), la sécurité des données
  protège les données elles-mêmes : chiffrement, pseudonymisation,
  audit des accès, conformité RGPD/PCI-DSS.

Les 3 axes de protection :
  1. PROTECTION EN TRANSIT   : SSL/TLS pour les connexions à la BD
  2. PROTECTION AU REPOS     : chiffrement disque (filesystem / TDE)
  3. PROTECTION APPLICATIVE  : masquage, pseudonymisation, chiffrement colonne

63.2 CHIFFREMENT AU NIVEAU COLONNE
────────────────────────────────────

CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Chiffrement symétrique AES (clé stockée dans un vault, JAMAIS dans la BD)
ALTER TABLE paiements ADD COLUMN IF NOT EXISTS carte_chiffree BYTEA;

-- Chiffrement lors du stockage
UPDATE paiements
SET carte_chiffree = pgp_sym_encrypt(
    '4111111111111111',
    'cle_secrete_aes256_depuis_vault'  -- obtenue du vault au runtime
);

-- Déchiffrement (uniquement par l'application avec la clé)
SELECT pgp_sym_decrypt(carte_chiffree, 'cle_secrete_aes256_depuis_vault') AS carte
FROM paiements WHERE id_paiement = 1;

-- Hachage de mot de passe avec bcrypt (IRRÉVERSIBLE — recommandé)
-- JAMAIS stocker un mot de passe en clair !

CREATE TABLE IF NOT EXISTS auth_clients (
    id_client           INTEGER PRIMARY KEY REFERENCES clients(id_client),
    mot_de_passe_hash   TEXT NOT NULL,
    date_modif          TIMESTAMP DEFAULT NOW()
);

-- Stocker : coût 12 = recommandé (lent intentionnellement contre le brute force)
INSERT INTO auth_clients (id_client, mot_de_passe_hash)
VALUES (1, crypt('mot_de_passe_utilisateur', gen_salt('bf', 12)));

-- Vérifier un mot de passe lors de la connexion
SELECT id_client
FROM auth_clients
WHERE id_client = 1
  AND mot_de_passe_hash = crypt('mot_de_passe_saisi', mot_de_passe_hash);
-- Si une ligne est retournée -> mot de passe correct

63.3 PSEUDONYMISATION ET ANONYMISATION
───────────────────────────────────────

-- RGPD : données pseudonymisées = toujours des données personnelles (réversibles).
--         données anonymisées = hors du champ RGPD (irréversibles).

-- PSEUDONYMISATION : remplacer les identifiants par des pseudonymes réversibles
CREATE TABLE pseudonymisation_map (
    id_original INTEGER,
    pseudonyme  UUID DEFAULT gen_random_uuid(),
    table_source VARCHAR(50),
    PRIMARY KEY (id_original, table_source)
);

-- Jeu de données pseudonymisé pour les tests
CREATE TABLE clients_pseudo AS
SELECT
    pm.pseudonyme::TEXT                               AS id_pseudo,
    'Client_' || pm.pseudonyme::TEXT                 AS nom_pseudo,
    CASE
        WHEN EXTRACT(YEAR FROM AGE(NOW(), cl.date_naissance)) < 25 THEN '18-24'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), cl.date_naissance)) < 35 THEN '25-34'
        WHEN EXTRACT(YEAR FROM AGE(NOW(), cl.date_naissance)) < 45 THEN '35-44'
        ELSE '45+'
    END                                              AS tranche_age,
    NULL::TEXT                                       AS email,
    NULL::TEXT                                       AS telephone,
    cl.segment,
    cl.adresse_ville,
    cl.date_inscription
FROM clients cl
JOIN pseudonymisation_map pm
    ON cl.id_client = pm.id_original AND pm.table_source = 'clients';

-- ANONYMISATION COMPLÈTE (irréversible — environnements hors production UNIQUEMENT)
CREATE OR REPLACE FUNCTION anonymiser_base_de_donnees()
RETURNS TEXT LANGUAGE plpgsql AS $$
BEGIN
    UPDATE clients SET
        prenom         = 'Prénom_' || id_client,
        nom            = 'Nom_' || id_client,
        email          = 'client_' || id_client || '@anonyme.test',
        telephone      = NULL,
        adresse_rue    = NULL,
        date_naissance = NULL;

    UPDATE commandes SET
        note_client  = CASE WHEN note_client IS NOT NULL THEN '[ANONYMISÉ]' END;

    UPDATE avis SET
        commentaire = CASE WHEN commentaire IS NOT NULL
                     THEN 'Avis anonymisé - Note : ' || note END;

    RETURN 'Anonymisation terminée.';
END;
$$;

-- Restreindre l'exécution aux DBA uniquement
REVOKE EXECUTE ON FUNCTION anonymiser_base_de_donnees() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION anonymiser_base_de_donnees() TO shopflow_dba;

63.4 AUDIT TRAIL : TRACER TOUS LES ACCÈS
──────────────────────────────────────────

-- Qui a modifié quoi, quand.

CREATE SCHEMA IF NOT EXISTS audit;

CREATE TABLE IF NOT EXISTS audit.audit_log (
    audit_id        BIGSERIAL PRIMARY KEY,
    date_operation  TIMESTAMP DEFAULT NOW(),
    utilisateur     TEXT DEFAULT CURRENT_USER,
    operation       TEXT NOT NULL,    -- INSERT, UPDATE, DELETE
    table_affectee  TEXT NOT NULL,
    id_ligne        INTEGER,
    donnees_avant   JSONB,
    donnees_apres   JSONB,
    application     TEXT DEFAULT current_setting('application_name', TRUE)
);

-- Trigger d'audit générique
CREATE OR REPLACE FUNCTION audit.audit_trigger_fn()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit.audit_log(operation, table_affectee, donnees_apres)
        VALUES ('INSERT', TG_TABLE_NAME, to_jsonb(NEW));
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        IF NEW IS DISTINCT FROM OLD THEN
            INSERT INTO audit.audit_log(operation, table_affectee, donnees_avant, donnees_apres)
            VALUES ('UPDATE', TG_TABLE_NAME, to_jsonb(OLD), to_jsonb(NEW));
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit.audit_log(operation, table_affectee, donnees_avant)
        VALUES ('DELETE', TG_TABLE_NAME, to_jsonb(OLD));
        RETURN OLD;
    END IF;
END;
$$;

-- Appliquer sur les tables sensibles
CREATE TRIGGER audit_clients
    AFTER INSERT OR UPDATE OR DELETE ON clients
    FOR EACH ROW EXECUTE FUNCTION audit.audit_trigger_fn();

CREATE TRIGGER audit_commandes
    AFTER INSERT OR UPDATE OR DELETE ON commandes
    FOR EACH ROW EXECUTE FUNCTION audit.audit_trigger_fn();

CREATE TRIGGER audit_employes
    AFTER INSERT OR UPDATE OR DELETE ON employes
    FOR EACH ROW EXECUTE FUNCTION audit.audit_trigger_fn();

-- Requêtes d'audit utiles

-- Qui a modifié le client 5 ce mois-ci ?
SELECT date_operation, utilisateur, operation,
       donnees_avant->>'segment' AS segment_avant,
       donnees_apres->>'segment' AS segment_apres
FROM audit.audit_log
WHERE table_affectee = 'clients'
  AND (donnees_avant->>'id_client')::INTEGER = 5
  AND date_operation >= DATE_TRUNC('month', NOW())
ORDER BY date_operation;

-- Activité suspecte : > 100 modifications par heure par utilisateur
SELECT utilisateur,
       DATE_TRUNC('hour', date_operation) AS heure,
       operation,
       COUNT(*) AS nb
FROM audit.audit_log
WHERE date_operation >= NOW() - INTERVAL '24 hours'
GROUP BY utilisateur, DATE_TRUNC('hour', date_operation), operation
HAVING COUNT(*) > 100
ORDER BY nb DESC;

63.5 CONFORMITÉ RGPD EN SQL
─────────────────────────────

-- Le RGPD impose plusieurs obligations techniques implémentables en SQL.

-- DROIT D'ACCÈS (art. 15) : export de toutes les données d'un client
CREATE OR REPLACE FUNCTION rgpd_export_client(p_id_client INTEGER)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE v_result JSON;
BEGIN
    SELECT json_build_object(
        'client',    (SELECT row_to_json(c) FROM clients c WHERE id_client = p_id_client),
        'commandes', (SELECT json_agg(json_build_object(
                         'commande', row_to_json(co),
                         'lignes',   (SELECT json_agg(row_to_json(lc))
                                      FROM lignes_commande lc
                                      WHERE lc.id_commande = co.id_commande)
                     ))
                     FROM commandes co WHERE co.id_client = p_id_client),
        'avis',      (SELECT json_agg(row_to_json(a)) FROM avis a WHERE a.id_client = p_id_client),
        'export_date', NOW()::TEXT
    ) INTO v_result;

    INSERT INTO audit.audit_log(operation, table_affectee, donnees_apres)
    VALUES ('RGPD_EXPORT', 'clients',
            json_build_object('id_client', p_id_client, 'action', 'art.15')::JSONB);

    RETURN v_result;
END;
$$;

-- DROIT À L'OUBLI (art. 17) : suppression ou anonymisation des données personnelles
CREATE OR REPLACE FUNCTION rgpd_droit_oubli(p_id_client INTEGER)
RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
    v_nb_commandes INTEGER;
BEGIN
    SELECT COUNT(*) INTO v_nb_commandes
    FROM commandes WHERE id_client = p_id_client
      AND statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND date_commande >= NOW() - INTERVAL '5 years';

    IF v_nb_commandes > 0 THEN
        -- Obligation légale de conservation des données comptables (5 ans)
        -- -> Pseudonymiser plutôt que supprimer
        UPDATE clients SET
            prenom         = 'Client',
            nom            = 'Supprimé',
            email          = 'supprime_' || id_client || '@rgpd.local',
            telephone      = NULL,
            date_naissance = NULL,
            adresse_rue    = NULL,
            actif          = FALSE
        WHERE id_client = p_id_client;

        INSERT INTO audit.audit_log(operation, table_affectee, donnees_apres)
        VALUES ('RGPD_OUBLI_PSEUDO', 'clients',
                json_build_object('id_client', p_id_client,
                                  'raison', 'commandes actives < 5 ans')::JSONB);

        RETURN 'Client pseudonymisé (commandes actives conservées pour obligations légales).';
    ELSE
        -- Suppression complète si pas de commandes récentes
        DELETE FROM avis           WHERE id_client = p_id_client;
        DELETE FROM paiements      WHERE id_commande IN (
            SELECT id_commande FROM commandes WHERE id_client = p_id_client);
        DELETE FROM lignes_commande WHERE id_commande IN (
            SELECT id_commande FROM commandes WHERE id_client = p_id_client);
        DELETE FROM commandes      WHERE id_client = p_id_client;
        DELETE FROM clients        WHERE id_client = p_id_client;

        INSERT INTO audit.audit_log(operation, table_affectee, donnees_apres)
        VALUES ('RGPD_OUBLI_SUPPRESSION', 'clients',
                json_build_object('id_client', p_id_client)::JSONB);

        RETURN 'Client et toutes ses données supprimés.';
    END IF;
END;
$$;

-- DROIT DE RECTIFICATION (art. 16)
CREATE OR REPLACE FUNCTION rgpd_rectification(
    p_id_client INTEGER,
    p_champ     TEXT,
    p_valeur    TEXT
)
RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
    -- Seuls certains champs peuvent être rectifiés
    IF p_champ NOT IN ('prenom', 'nom', 'email', 'telephone', 'adresse_rue',
                        'adresse_ville', 'adresse_cp') THEN
        RAISE EXCEPTION 'Champ % non autorisé pour la rectification.', p_champ;
    END IF;

    EXECUTE format('UPDATE clients SET %I = $1 WHERE id_client = $2', p_champ)
    USING p_valeur, p_id_client;

    INSERT INTO audit.audit_log(operation, table_affectee, donnees_apres)
    VALUES ('RGPD_RECTIFICATION', 'clients',
            json_build_object('id_client', p_id_client,
                              'champ', p_champ,
                              'nouvelle_valeur', p_valeur)::JSONB);

    RETURN 'Rectification appliquée : ' || p_champ || ' = ' || p_valeur;
END;
$$;

-- REGISTRE DES TRAITEMENTS (art. 30) : documenter les traitements en BD
CREATE TABLE IF NOT EXISTS rgpd_registre_traitements (
    traitement_id   SERIAL PRIMARY KEY,
    nom_traitement  TEXT NOT NULL,
    finalite        TEXT NOT NULL,
    base_legale     TEXT NOT NULL,  -- 'contrat', 'intérêt légitime', 'consentement'...
    categories_données TEXT[],
    responsable     TEXT,
    duree_retention_mois INTEGER,
    transfert_pays_tiers BOOLEAN DEFAULT FALSE,
    date_creation   TIMESTAMP DEFAULT NOW()
);

INSERT INTO rgpd_registre_traitements
    (nom_traitement, finalite, base_legale, categories_données, duree_retention_mois)
VALUES
('Gestion des commandes',
 'Traitement et livraison des commandes clients',
 'exécution du contrat',
 ARRAY['identité', 'coordonnées', 'données de transaction'],
 60),  -- 5 ans (obligation comptable)
('Analyse marketing',
 'Segmentation et campagnes ciblées',
 'intérêt légitime',
 ARRAY['identité', 'historique achats', 'préférences'],
 36),  -- 3 ans
('Service client',
 'Gestion des réclamations et SAV',
 'exécution du contrat',
 ARRAY['identité', 'coordonnées', 'historique achats'],
 24);  -- 2 ans

63.6 PRÉVENTION DES INJECTIONS SQL
────────────────────────────────────

-- L'injection SQL est l'attaque #1 sur les bases de données.
-- Elle consiste à insérer du SQL malveillant dans une requête.

-- MAUVAIS : requête construite par concaténation de chaînes
-- (exemple en pseudocode applicatif)
--   query = "SELECT * FROM clients WHERE email = '" + user_input + "'"
--   Si user_input = "' OR '1'='1" -> lit TOUS les clients !
--   Si user_input = "'; DROP TABLE clients; --" -> désastre !

-- CORRECT : requêtes paramétrées (côté application)
-- En PL/pgSQL, utiliser EXECUTE ... USING pour les requêtes dynamiques :

CREATE OR REPLACE FUNCTION rechercher_client_par_email(p_email TEXT)
RETURNS TABLE(id_client INTEGER, nom TEXT, prenom TEXT)
LANGUAGE plpgsql AS $$
BEGIN
    -- CORRECT : paramètre $1 passé via USING (jamais concaténé)
    RETURN QUERY
    EXECUTE 'SELECT id_client, nom, prenom FROM clients WHERE email = $1'
    USING p_email;
    -- $1 est échappé par PostgreSQL -> injection impossible
END;
$$;

-- Au lieu de format() avec %s (dangereux) :
-- EXECUTE format('SELECT * FROM clients WHERE email = ''%s''', email) -- DANGEREUX

-- Utiliser format() avec %L (quoted literal) ou %I (quoted identifier) :
EXECUTE format('SELECT * FROM %I WHERE email = %L', 'clients', p_email);
-- %I = identifiant quoté (table, colonne) -> protège contre injection dans les noms
-- %L = littéral quoté (valeur) -> protège contre injection dans les valeurs

63.7 EXERCICES PRATIQUES — SÉCURITÉ DES DONNÉES
─────────────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Créez la table auth_clients avec les mots de passe hachés en bcrypt.
      Insérez 3 clients avec des mots de passe différents.
      Écrivez la requête de vérification de mot de passe.

Ex2 : Installez le trigger d'audit sur la table employes.
      Simulez une modification de salaire. Vérifiez que l'audit_log
      contient bien l'ancienne et la nouvelle valeur.

Ex3 : Testez la fonction rgpd_export_client sur un client réel.
      Vérifiez que le JSON retourné contient ses commandes et ses avis.

NIVEAU INTERMÉDIAIRE :

Ex4 : Implémentez une politique de rétention automatique des données :
      une procédure qui s'exécute mensuellement et anonymise automatiquement
      les clients inactifs depuis plus de 3 ans (sans commandes récentes).
      Logguez chaque anonymisation dans audit.audit_log.

Ex5 : Construisez un rapport RGPD mensuel :
      - Nb de demandes d'export reçues ce mois
      - Nb de demandes de suppression traitées
      - Clients actifs avec données en dépassement de rétention
      (basé sur audit.audit_log et rgpd_registre_traitements)

Ex6 : Implémentez le masquage dynamique des données :
      créez une vue v_clients_masked qui affiche les données clients
      avec masquage conditionnel selon le rôle de l'appelant :
      - DBA : données complètes
      - Analyste : email masqué (al***@***.fr), téléphone masqué
      - Application : email complet, pas de téléphone

NIVEAU AVANCÉ :

Ex7 : Implémentez un "data vault" simplifié pour les données très sensibles :
      une table vault_secrets (id, clé_hashée, valeur_chiffrée, date_expiration).
      Les mots de passe de compte de service y sont stockés chiffrés.
      Procédure de rotation automatique des secrets expirés.

Ex8 : Construisez un système de détection d'anomalies :
      une procédure qui détecte et alerte sur :
      - Connexions en dehors des heures ouvrées (20h-7h)
      - SELECT de plus de 10 000 lignes sur clients (exfiltration potentielle)
      - Modifications en masse (> 50 UPDATE en 1 minute par un même user)
      - Accès depuis une IP inconnue

Ex9 : Audit de conformité RGPD complet :
      identifiez toutes les colonnes contenant des données personnelles
      (via information_schema + liste de mots-clés : nom, prenom, email,
      telephone, adresse, date_naissance, etc.), vérifiez qu'elles sont
      couvertes par le registre des traitements, et générez un rapport
      de conformité avec les écarts à corriger.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 17
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 61 : GRANT/REVOKE, permissions par colonne, vues de sécurité,
                   DEFAULT PRIVILEGES pour les futurs objets. Audit des accès.

  [OK] Chapitre 62 : Architecture de rôles (readonly -> analyst -> dba).
                   Hiérarchie des rôles avec héritage. Row Level Security (RLS)
                   pour filtrer automatiquement les lignes par utilisateur.

  [OK] Chapitre 63 : Chiffrement colonne (pgcrypto), bcrypt pour les mots de passe,
                   pseudonymisation RGPD, audit trail complet via trigger,
                   implémentation SQL des droits RGPD (art. 15, 16, 17).
                   Prévention des injections SQL.

POINTS CLÉS À RETENIR :
  -> Principe du moindre privilège : chaque compte n'a que le strict nécessaire
  -> Les rôles simplifient la gestion des permissions à grande échelle
  -> RLS filtre automatiquement les données sans modifier les requêtes applicatives
  -> bcrypt > SHA256 pour les mots de passe (lenteur intentionnelle = protection)
  -> L'audit trail est obligatoire en production pour la traçabilité RGPD
  -> Le droit à l'oubli ≠ suppression systématique : obligations légales comptables

PROCHAINE PARTIE :
  Partie 18 — Debugging SQL : erreurs fréquentes et troubleshooting performance.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 17 — SÉCURITÉ SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                    PARTIE 18 — DEBUGGING SQL                                    ║
║                         Chapitres 64 à 65                                       ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Intermédiaire -> Avancé
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 18
════════════════════════════════
  Chapitre 64 : Erreurs SQL fréquentes — Diagnostic et correction
  Chapitre 65 : Performance troubleshooting — Identifier et résoudre les lenteurs

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 64 — ERREURS SQL FRÉQUENTES : DIAGNOSTIC ET CORRECTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

64.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Pourquoi un chapitre sur les erreurs ?
  Tout développeur SQL fait des erreurs. La différence entre un débutant et un
  expert, c'est la vitesse à laquelle il identifie et corrige les problèmes.
  Ce chapitre est un catalogue des erreurs les plus fréquentes avec leur
  diagnostic et leur solution.

Méthode de debugging SQL :
  1. LIRE le message d'erreur complètement (il contient souvent la solution)
  2. ISOLER la partie problématique (diviser la requête en CTEs)
  3. TESTER avec des données simples (1-2 lignes)
  4. VÉRIFIER les types de données, les NULL, les noms de colonnes
  5. CONSULTER la documentation PostgreSQL si nécessaire

64.2 ERREURS DE SYNTAXE
─────────────────────────

──────────────────────────
Erreur 1 : syntax error at or near "..."
──────────────────────────

Message type : ERROR: syntax error at or near "WHERE"

Causes fréquentes et corrections :

-- MAUVAIS : virgule oubliée entre colonnes SELECT
SELECT prenom nom FROM clients;
-- ERROR: column "nom" does not exist
-- CORRECT :
SELECT prenom, nom FROM clients;

-- MAUVAIS : GROUP BY oublié avec une fonction d'agrégation
SELECT id_client, COUNT(*) FROM commandes;
-- ERROR: column "commandes.id_client" must appear in GROUP BY
-- CORRECT :
SELECT id_client, COUNT(*) FROM commandes GROUP BY id_client;

-- MAUVAIS : alias utilisé dans WHERE (l'alias n'existe pas encore à ce stade)
SELECT montant_ttc * 0.1 AS remise FROM commandes WHERE remise > 50;
-- ERROR: column "remise" does not exist
-- CORRECT : répéter l'expression ou utiliser une sous-requête
SELECT * FROM (
    SELECT montant_ttc * 0.1 AS remise FROM commandes
) sub
WHERE remise > 50;
-- Ou, plus simple :
SELECT montant_ttc * 0.1 AS remise FROM commandes
WHERE montant_ttc * 0.1 > 50;

-- MAUVAIS : ORDER BY après HAVING sans GROUP BY
SELECT statut FROM commandes HAVING COUNT(*) > 5 ORDER BY statut;
-- CORRECT :
SELECT statut FROM commandes GROUP BY statut HAVING COUNT(*) > 5 ORDER BY statut;

-- MAUVAIS : LIMIT avant ORDER BY (logiquement incorrect même si syntaxe OK)
-- Ce n'est PAS une erreur SQL mais un bug logique :
SELECT * FROM commandes LIMIT 10 ORDER BY date_commande DESC;
-- (PostgreSQL accepte mais le résultat est aléatoire)
-- CORRECT :
SELECT * FROM commandes ORDER BY date_commande DESC LIMIT 10;

──────────────────────────
Erreur 2 : column "x" does not exist
──────────────────────────

-- Cause 1 : Faute de frappe dans le nom de colonne
SELECT id_comande FROM commandes;  -- "comande" au lieu de "commande"
-- Solution : \d commandes dans psql pour voir les vraies colonnes

-- Cause 2 : Mauvaise table dans le FROM
SELECT reference FROM commandes;  -- 'reference' est dans produits, pas commandes
-- Solution : identifier la table correcte et joindre

-- Cause 3 : Ambiguïté de colonne dans un JOIN
SELECT id_client FROM commandes JOIN clients USING (...);
-- Si les deux tables ont id_client, PostgreSQL peut être confus
SELECT c.id_client FROM commandes co JOIN clients c ON co.id_client = c.id_client;

-- Cause 4 : Majuscules non quotées (PostgreSQL = case-insensitive SAUF si quoté)
CREATE TABLE MaTable (IdClient INTEGER);  -- Stocké en minuscules : matable, idclient
SELECT IdClient FROM MaTable;  -- Fonctionne (converti en minuscules)
SELECT "IdClient" FROM "MaTable";  -- Fonctionne si créé avec guillemets
-- -> NE JAMAIS utiliser de majuscules dans les noms SQL PostgreSQL

──────────────────────────
Erreur 3 : relation "x" does not exist
──────────────────────────

-- Cause 1 : Faute de frappe dans le nom de table
SELECT * FROM commande;  -- "commande" au lieu de "commandes"

-- Cause 2 : Mauvais schéma
SELECT * FROM clients;           -- cherche dans le search_path courant
SELECT * FROM public.clients;    -- cherche dans le schéma public
SELECT * FROM dwh.dim_client;    -- cherche dans le schéma dwh

-- Voir le search_path courant
SHOW search_path;
-- Modifier pour inclure dwh
SET search_path = public, dwh;

-- Cause 3 : Table dans une autre base de données
-- PostgreSQL ne supporte pas les requêtes cross-database nativement
-- Solution : utiliser postgres_fdw (Foreign Data Wrapper)

64.3 ERREURS DE TYPES
──────────────────────

──────────────────────────
Erreur 4 : operator does not exist : integer = text
──────────────────────────

-- Cause : comparaison de types incompatibles
SELECT * FROM commandes WHERE id_commande = '123';
-- Peut fonctionner (cast implicite int->text) ou pas selon la version

-- Solution : cast explicite
SELECT * FROM commandes WHERE id_commande = 123;           -- Pas de cast nécessaire
SELECT * FROM commandes WHERE id_commande = '123'::INTEGER; -- Cast explicite
SELECT * FROM commandes WHERE id_commande = CAST('123' AS INTEGER);

-- Cas fréquent : statut VARCHAR vs enum ou TEXT
SELECT * FROM commandes WHERE statut = 1;  -- ERREUR si statut est VARCHAR
SELECT * FROM commandes WHERE statut = 'LIVREE';  -- CORRECT

──────────────────────────
Erreur 5 : invalid input syntax for type date
──────────────────────────

-- Cause : format de date non reconnu
SELECT * FROM commandes WHERE date_commande = '15/03/2024';
-- ERROR: invalid input syntax for type timestamp: "15/03/2024"

-- Solution : utiliser le format ISO (YYYY-MM-DD)
SELECT * FROM commandes WHERE date_commande >= '2024-03-15';
-- Ou : TO_DATE() avec le format explicite
SELECT * FROM commandes
WHERE date_commande >= TO_DATE('15/03/2024', 'DD/MM/YYYY');

-- Dates courantes et leurs formats PostgreSQL :
-- '2024-03-15'         -> DATE littéral standard
-- '2024-03-15 14:30:00' -> TIMESTAMP littéral
-- NOW()                -> timestamp courant
-- CURRENT_DATE         -> date du jour
-- CURRENT_TIMESTAMP    -> timestamp courant (avec fuseau horaire)

64.4 ERREURS DE LOGIQUE (PAS D'ERREUR SQL, MAIS RÉSULTAT FAUX)
────────────────────────────────────────────────────────────────

Ce sont les erreurs les plus dangereuses : la requête s'exécute sans erreur
mais produit des résultats incorrects.

──────────────────────────
Bug 1 : Double comptage avec les JOINs
──────────────────────────

-- Problème : un client avec 3 commandes est compté 3 fois
SELECT COUNT(*) AS nb_clients FROM clients
JOIN commandes ON clients.id_client = commandes.id_client;
-- Retourne le nombre de commandes, PAS de clients !

-- Solution : COUNT(DISTINCT)
SELECT COUNT(DISTINCT clients.id_client) AS nb_clients_ayant_commande
FROM clients
JOIN commandes ON clients.id_client = commandes.id_client;

-- Ou utiliser EXISTS (souvent plus clair)
SELECT COUNT(*) AS nb_clients_ayant_commande
FROM clients
WHERE EXISTS (SELECT 1 FROM commandes c WHERE c.id_client = clients.id_client);

──────────────────────────
Bug 2 : NULL dans les conditions WHERE
──────────────────────────

-- NULL n'est pas égal à NULL !
SELECT * FROM clients WHERE telephone = NULL;   -- Retourne 0 lignes !
-- CORRECT :
SELECT * FROM clients WHERE telephone IS NULL;
SELECT * FROM clients WHERE telephone IS NOT NULL;

-- NULL dans les agrégations
SELECT AVG(note) FROM avis;          -- Ignore automatiquement les NULL (correct)
SELECT COUNT(note) FROM avis;        -- Ne compte PAS les NULL
SELECT COUNT(*) FROM avis;           -- Compte toutes les lignes, même si note IS NULL

-- NULL dans les expressions arithmétiques
SELECT 100 + NULL;  -- Retourne NULL !
-- Solution : COALESCE pour remplacer NULL par une valeur par défaut
SELECT 100 + COALESCE(valeur_nullable, 0);

-- NULL dans les conditions IN/NOT IN
SELECT * FROM clients WHERE id_client NOT IN (1, 2, NULL);
-- Retourne 0 lignes ! NULL dans IN/NOT IN = comportement inattendu
-- Solution : filtrer les NULL explicitement
SELECT * FROM clients WHERE id_client NOT IN (1, 2)
  AND id_client IS NOT NULL;

──────────────────────────
Bug 3 : Division par zéro
──────────────────────────

SELECT 100 / 0;  -- ERROR: division by zero

-- Protection avec NULLIF
SELECT 100 / NULLIF(0, 0);  -- Retourne NULL (pas d'erreur)

-- Protection dans les calculs de taux
SELECT
    ROUND(nb_annulees::DECIMAL / NULLIF(total_commandes, 0) * 100, 1) AS taux
FROM stats;
-- Si total_commandes = 0, NULLIF retourne NULL -> pas d'erreur

──────────────────────────
Bug 4 : BETWEEN avec des timestamps
──────────────────────────

-- MAUVAIS : BETWEEN inclut les deux bornes, '2024-03-31' = '2024-03-31 00:00:00'
-- Les commandes du 31 mars après minuit ne sont PAS incluses !
SELECT * FROM commandes
WHERE date_commande BETWEEN '2024-03-01' AND '2024-03-31';
-- Commandes du 31/03 à 14h30 : NON incluses !

-- CORRECT : utiliser >= et < avec la borne exclusive du lendemain
SELECT * FROM commandes
WHERE date_commande >= '2024-03-01'
  AND date_commande < '2024-04-01';
-- Toutes les commandes de mars 2024 incluses

-- Ou avec DATE_TRUNC pour être encore plus précis
SELECT * FROM commandes
WHERE DATE_TRUNC('month', date_commande) = '2024-03-01';

──────────────────────────
Bug 5 : ORDER BY sans LIMIT dans une sous-requête
──────────────────────────

-- MAUVAIS : ORDER BY dans une sous-requête sans LIMIT est ignoré par PostgreSQL
-- (comportement légal selon le standard SQL)
SELECT * FROM (
    SELECT * FROM commandes ORDER BY date_commande DESC  -- ORDER BY ignoré !
) sub
LIMIT 10;

-- CORRECT : mettre le ORDER BY dans la requête externe
SELECT * FROM commandes ORDER BY date_commande DESC LIMIT 10;

-- Ou si vous voulez vraiment une sous-requête ordonnée : ORDER BY + LIMIT
SELECT * FROM (
    SELECT * FROM commandes ORDER BY date_commande DESC LIMIT 100
) sub
WHERE statut = 'LIVREE';

──────────────────────────
Bug 6 : Agrégation et ordre d'exécution SQL
──────────────────────────

-- L'ordre d'exécution logique SQL :
-- 1. FROM / JOIN   -> quelles tables
-- 2. WHERE         -> filtrer les lignes
-- 3. GROUP BY      -> grouper
-- 4. HAVING        -> filtrer les groupes
-- 5. SELECT        -> calculer les colonnes/agrégations
-- 6. ORDER BY      -> trier
-- 7. LIMIT/OFFSET  -> paginer

-- Erreur fréquente : filtrer après agrégation dans WHERE
SELECT id_client, COUNT(*) AS nb_cmd
FROM commandes
WHERE nb_cmd > 3;  -- ERREUR : nb_cmd n'existe pas encore à l'étape WHERE
-- CORRECT : utiliser HAVING qui s'exécute APRÈS GROUP BY
SELECT id_client, COUNT(*) AS nb_cmd
FROM commandes
GROUP BY id_client
HAVING COUNT(*) > 3;

64.5 ERREURS DE CONTRAINTES
─────────────────────────────

──────────────────────────
Erreur 6 : violates foreign key constraint
──────────────────────────

-- Cause : insérer une ligne référençant une clé étrangère qui n'existe pas
INSERT INTO commandes (id_client, statut, montant_ttc)
VALUES (9999, 'EN_ATTENTE', 100);
-- ERROR: insert or update on table "commandes" violates foreign key constraint
-- Key (id_client)=(9999) is not present in table "clients"

-- Solution : vérifier l'existence de la clé avant d'insérer
SELECT id_client FROM clients WHERE id_client = 9999;  -- doit retourner 1 ligne

-- Ou créer le client d'abord
INSERT INTO clients (id_client, prenom, nom, email) VALUES (9999, 'Test', 'Client', 'test@test.fr');
INSERT INTO commandes (id_client, ...) VALUES (9999, ...);

──────────────────────────
Erreur 7 : duplicate key value violates unique constraint
──────────────────────────

-- Cause : insérer une valeur qui existe déjà dans une colonne UNIQUE
INSERT INTO clients (email) VALUES ('existing@email.com');
-- ERROR: duplicate key value violates unique constraint "clients_email_key"

-- Solution 1 : vérifier avant d'insérer
SELECT COUNT(*) FROM clients WHERE email = 'existing@email.com';

-- Solution 2 : UPSERT (INSERT ... ON CONFLICT)
INSERT INTO clients (email, prenom, nom)
VALUES ('existing@email.com', 'Jean', 'Dupont')
ON CONFLICT (email)
DO UPDATE SET prenom = EXCLUDED.prenom, nom = EXCLUDED.nom;

-- Solution 3 : ON CONFLICT DO NOTHING (ignorer silencieusement)
INSERT INTO clients (email, prenom, nom)
VALUES ('existing@email.com', 'Jean', 'Dupont')
ON CONFLICT (email) DO NOTHING;

──────────────────────────
Erreur 8 : not-null constraint
──────────────────────────

-- Cause : insérer NULL dans une colonne NOT NULL
INSERT INTO commandes (id_client, statut) VALUES (1, NULL);
-- ERROR: null value in column "statut" violates not-null constraint

-- Solution : fournir une valeur par défaut ou utiliser DEFAULT
INSERT INTO commandes (id_client, statut) VALUES (1, 'EN_ATTENTE');
-- Ou si la colonne a une valeur DEFAULT définie :
INSERT INTO commandes (id_client) VALUES (1);  -- statut prend sa valeur DEFAULT

64.6 DEBUGGING EN PRATIQUE : LA MÉTHODE
─────────────────────────────────────────

-- Technique 1 : Simplifier progressivement la requête

-- Requête complexe qui échoue :
WITH ca AS (
    SELECT c.id_client, SUM(lc.prix_unitaire_ht * lc.quantite) AS ca
    FROM commandes c
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    WHERE c.statut = 'LIVREE'
    GROUP BY c.id_client
    HAVING SUM(lc.prix_unitaire_ht * lc.quantite) > 500
)
SELECT cl.nom, ca.ca FROM clients cl JOIN ca USING (id_client);

-- Étape 1 : Tester la CTE seule
SELECT c.id_client, SUM(lc.prix_unitaire_ht * lc.quantite) AS ca
FROM commandes c
JOIN lignes_commande lc ON c.id_commande = lc.id_commande
WHERE c.statut = 'LIVREE'
GROUP BY c.id_client
LIMIT 5;  -- OK ?

-- Étape 2 : Tester avec HAVING
... HAVING SUM(lc.prix_unitaire_ht * lc.quantite) > 500;  -- OK ?

-- Étape 3 : Tester le JOIN final
...  -- etc.

-- Technique 2 : Afficher les données intermédiaires
-- Remplacer la requête finale par SELECT * FROM la_cte
WITH ca AS (
    SELECT c.id_client, SUM(lc.prix_unitaire_ht * lc.quantite) AS ca
    FROM commandes c
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    GROUP BY c.id_client
)
SELECT * FROM ca LIMIT 10;  -- Inspecter le résultat intermédiaire

-- Technique 3 : Compter les lignes à chaque étape
-- Vérifier que les JOINs ne multiplient pas les lignes (double comptage)
SELECT COUNT(*) FROM commandes;  -- 200
SELECT COUNT(*) FROM commandes JOIN lignes_commande USING (id_commande);
-- Si 850 -> chaque commande a ~4.25 lignes en moyenne (normal)
-- Si 200 -> chaque commande a exactement 1 ligne (vérifier)
-- Si 200000 -> produit cartésien accidentel (JOIN sans ON !)

-- Technique 4 : RAISE NOTICE dans les procédures PL/pgSQL
CREATE OR REPLACE FUNCTION debug_exemple()
RETURNS TEXT LANGUAGE plpgsql AS $$
DECLARE
    v_count INTEGER;
BEGIN
    SELECT COUNT(*) INTO v_count FROM commandes WHERE statut = 'LIVREE';
    RAISE NOTICE 'Commandes livrées : %', v_count;  -- Affiche dans les logs

    IF v_count = 0 THEN
        RAISE EXCEPTION 'Aucune commande livrée trouvée !';
    END IF;

    RETURN 'OK : ' || v_count || ' commandes livrées';
END;
$$;

64.7 CATALOGUE DES CODES D'ERREUR POSTGRESQL
──────────────────────────────────────────────

  23505  -> unique_violation (clé dupliquée)
  23503  -> foreign_key_violation (FK violée)
  23502  -> not_null_violation (NULL interdit)
  23514  -> check_violation (contrainte CHECK violée)
  42601  -> syntax_error (erreur de syntaxe)
  42703  -> undefined_column (colonne inexistante)
  42P01  -> undefined_table (table inexistante)
  22012  -> division_by_zero
  22001  -> string_data_right_truncation (valeur trop longue pour le champ)
  40001  -> serialization_failure (conflit de transaction)
  40P01  -> deadlock_detected (deadlock)
  08006  -> connection_failure (connexion perdue)
  53300  -> too_many_connections (connexions saturées)

-- Voir le code d'erreur PostgreSQL dans psql avec \errverbose après une erreur
-- Ou dans le log : SQLSTATE: XXXXX

64.8 EXERCICES PRATIQUES — DEBUGGING
──────────────────────────────────────

NIVEAU FACILE :

Ex1 : Identifiez et corrigez les 5 bugs dans ces requêtes :
  a) SELECT nom prenom FROM clients WHERE actif = 1;
  b) SELECT id_client, MAX(montant_ttc) FROM commandes;
  c) SELECT * FROM commandes WHERE date_commande = NULL;
  d) SELECT COUNT(montant_ttc) / COUNT(*) FROM commandes;
  e) SELECT * FROM commandes WHERE montant_ttc BETWEEN 100 AND 99;

Ex2 : Expliquez pourquoi cette requête peut retourner un résultat incorrect
      et proposez la correction :
      SELECT AVG(montant_ttc) FROM commandes JOIN clients USING(id_client)
      WHERE segment = 'VIP';

Ex3 : Cette requête génère-t-elle une erreur ou un résultat incorrect ?
      SELECT id_client, SUM(montant_ttc) AS total FROM commandes
      WHERE total > 1000 GROUP BY id_client;

NIVEAU INTERMÉDIAIRE :

Ex4 : Debuggez cette requête qui est censée trouver les clients VIP
      ayant commandé plus de 3 fois, mais retourne trop peu de résultats :
      SELECT cl.nom, COUNT(*) FROM clients cl, commandes c
      WHERE cl.segment = 'VIP' GROUP BY cl.nom HAVING COUNT(*) > 3;

Ex5 : Cette requête de statistiques retourne des nombres qui ne collent pas :
      SELECT cat.nom, SUM(c.montant_ttc) AS ca
      FROM categories cat
      JOIN produits p ON cat.id_categorie = p.id_categorie
      JOIN lignes_commande lc ON p.id_produit = lc.id_produit
      JOIN commandes c ON lc.id_commande = c.id_commande
      GROUP BY cat.nom;
      Identifiez le problème potentiel et proposez une correction.

Ex6 : Construisez un "test harness" SQL : une série de 5 requêtes de
      validation qui vérifient l'intégrité des données ShopFlow
      (ex: commandes sans lignes, clients avec email dupliqué, etc.)
      et retournent "PASS" ou "FAIL" avec le nombre d'anomalies.

NIVEAU AVANCÉ :

Ex7 : Analysez cette requête de rétention client et identifiez
      tous les bugs (il y en a au moins 3) :
      SELECT DATE_TRUNC('month', date_inscription) AS cohorte,
             COUNT(*) AS total,
             (SELECT COUNT(*) FROM commandes c
              WHERE c.id_client = cl.id_client
              AND date_commande BETWEEN date_inscription
              AND date_inscription + 30) AS achats_30j
      FROM clients cl
      WHERE actif = TRUE;

Ex8 : Vous recevez ce rapport d'anomalie : "Le CA du mois de mars 2024 calculé
      par notre requête SQL (47 823€) diffère du chiffre comptable (51 200€)".
      Construisez une investigation méthodique en SQL pour trouver la source
      de l'écart : requêtes de validation étape par étape.

Ex9 : Implémentez un framework de tests unitaires SQL :
      une table test_assertions avec les tests attendus, une procédure
      run_tests() qui exécute chaque test et compare le résultat attendu
      vs le résultat réel. Écrivez 10 tests pour les KPIs de ShopFlow.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 65 — PERFORMANCE TROUBLESHOOTING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

65.1 INTRODUCTION PÉDAGOGIQUE
───────────────────────────────

Pourquoi une requête est-elle lente ?
  Plusieurs causes possibles, par ordre de fréquence :
  1. Absence d'index sur les colonnes filtrées/jointures
  2. Statistiques obsolètes (l'optimiseur fait de mauvais choix)
  3. Requête mal écrite (produit cartésien, SELECT *, N+1 queries)
  4. Volume de données trop important sans partitionnement
  5. Ressources insuffisantes (RAM, CPU, I/O disque)

Méthode d'investigation :
  IDENTIFIER la requête lente -> COMPRENDRE le plan d'exécution -> MESURER
  -> OPTIMISER -> VALIDER -> MONITORER

65.2 IDENTIFIER LES REQUÊTES LENTES
──────────────────────────────────────

-- Méthode 1 : Activer le log des requêtes lentes dans postgresql.conf
-- log_min_duration_statement = 1000  (ms) -> logge toute requête > 1 seconde
-- log_min_duration_statement = 0     -> logge TOUTES les requêtes (dev seulement)

-- Méthode 2 : Extension pg_stat_statements (recommandée en production)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top 10 des requêtes les plus lentes (temps total)
SELECT
    LEFT(query, 80)                                   AS requete,
    calls                                             AS nb_appels,
    ROUND(total_exec_time::DECIMAL, 0)                AS temps_total_ms,
    ROUND(mean_exec_time::DECIMAL, 0)                 AS temps_moyen_ms,
    ROUND(max_exec_time::DECIMAL, 0)                  AS temps_max_ms,
    rows                                              AS lignes_retournees,
    ROUND(total_exec_time / calls, 0)                 AS ms_par_appel
FROM pg_stat_statements
WHERE calls > 5  -- ignorer les requêtes rarement appelées
ORDER BY total_exec_time DESC
LIMIT 10;

-- Requêtes avec le plus mauvais ratio temps/données (candidates à l'optimisation)
SELECT
    LEFT(query, 80) AS requete,
    calls,
    ROUND(mean_exec_time, 0) AS ms_moyen,
    rows / NULLIF(calls, 0) AS lignes_par_appel,
    ROUND(mean_exec_time / NULLIF(rows / NULLIF(calls, 0), 0), 2) AS ms_par_ligne
FROM pg_stat_statements
WHERE calls > 10 AND rows > 0
ORDER BY ms_par_ligne DESC
LIMIT 10;

-- Remettre à zéro les statistiques (après une optimisation pour mesurer l'impact)
SELECT pg_stat_statements_reset();

65.3 LIRE UN PLAN D'EXÉCUTION : RAPPEL ET APPROFONDISSEMENT
─────────────────────────────────────────────────────────────

-- EXPLAIN ANALYZE exécute réellement la requête et mesure les durées
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT cl.nom, COUNT(c.id_commande) AS nb_cmd
FROM clients cl
LEFT JOIN commandes c ON cl.id_client = c.id_client
GROUP BY cl.id_client, cl.nom;

-- Nœuds du plan d'exécution à connaître :

-- Seq Scan    : parcours séquentiel (toute la table) — lent sur grandes tables
-- Index Scan  : utilise un index B-tree — rapide si sélectif
-- Index Only Scan : lit uniquement l'index (pas la table) — très rapide
-- Bitmap Index Scan -> Bitmap Heap Scan : combinaison pour plusieurs conditions
-- Nested Loop : pour les JOINs, boucle sur chaque ligne de la table externe
-- Hash Join   : charge une table en mémoire, hash, puis probe — bon pour grands volumes
-- Merge Join  : tri les deux tables puis les fusionne — efficace si déjà triées
-- Sort        : tri explicite (ORDER BY, DISTINCT, certains GROUP BY)
-- Hash Aggregate : GROUP BY en mémoire
-- Gather      : parallélisme (plusieurs workers)

-- Signaux d'alarme dans EXPLAIN ANALYZE :

-- 1. "rows=1" estimé mais "rows=10000" réel -> statistiques obsolètes -> ANALYZE
-- 2. "Seq Scan" sur une grande table avec filtre -> index manquant
-- 3. "Sort" avec "Sort Method: external merge Disk" -> pas assez de work_mem
-- 4. Coût très élevé "cost=50000.00..80000.00" -> probablement à optimiser
-- 5. Nested Loop avec beaucoup de lignes outer -> considérer Hash Join

65.4 CAS PRATIQUES D'OPTIMISATION
────────────────────────────────────

──────────────────────────
Cas 1 : Seq Scan sur grande table -> Index manquant
──────────────────────────

-- Symptôme : Seq Scan sur commandes (200 000 lignes) pour filtrer par date
EXPLAIN ANALYZE
SELECT * FROM commandes WHERE date_commande >= '2024-01-01';

-- Plan : Seq Scan on commandes (cost=0.00..12000.00 rows=50000)
-- Problème : scan séquentiel de 200 000 lignes pour en retourner 50 000

-- Solution : créer un index sur la colonne filtrée
CREATE INDEX CONCURRENTLY idx_commandes_date ON commandes (date_commande);

-- Relancer EXPLAIN ANALYZE :
-- Index Scan on commandes (cost=0.44..4200.00 rows=50000)
-- -> 3× plus rapide grâce à l'index

──────────────────────────
Cas 2 : Statistiques obsolètes -> mauvais plan
──────────────────────────

-- Symptôme : l'optimiseur estime 1 ligne mais en trouve 50 000

-- Solution : mettre à jour les statistiques
ANALYZE commandes;
ANALYZE;  -- Analyser toutes les tables
-- postgresql.conf : autovacuum_analyze_scale_factor = 0.02 (défaut 0.2)
-- -> ANALYZE déclenché après 2% de changements (au lieu de 20%)

-- Voir les statistiques de table
SELECT relname, last_analyze, last_autoanalyze, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
ORDER BY last_autoanalyze NULLS FIRST;

──────────────────────────
Cas 3 : Fonction dans WHERE -> l'index n'est pas utilisé
──────────────────────────

-- Symptôme : Seq Scan même avec un index sur date_commande
-- MAUVAIS :
SELECT * FROM commandes WHERE EXTRACT(YEAR FROM date_commande) = 2024;
-- L'index sur date_commande ne peut pas être utilisé avec EXTRACT()

-- CORRECT : reformuler sans fonction sur la colonne indexée
SELECT * FROM commandes
WHERE date_commande >= '2024-01-01'
  AND date_commande < '2025-01-01';
-- L'index sur date_commande est maintenant utilisable

-- Alternative : index fonctionnel (si la reformulation est impossible)
CREATE INDEX idx_commandes_annee ON commandes (EXTRACT(YEAR FROM date_commande));
-- Mais préférer la reformulation de la requête

──────────────────────────
Cas 4 : N+1 queries -> une requête par ligne
──────────────────────────

-- Problème classique des ORM : pour chaque client, faire une requête séparée
-- pour ses commandes.

-- MAUVAIS (N+1) :
-- Pour chaque client (N clients) : 1 requête
-- Pour les commandes de chaque client : 1 autre requête
-- Total : N+1 requêtes -> très lent

-- CORRECT (1 requête avec JOIN)
SELECT
    cl.id_client,
    cl.nom,
    COUNT(c.id_commande)  AS nb_commandes,
    SUM(c.montant_ttc)    AS ca_total
FROM clients cl
LEFT JOIN commandes c ON cl.id_client = c.id_client
    AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY cl.id_client, cl.nom;
-- 1 seule requête au lieu de N+1 -> 100× plus rapide

──────────────────────────
Cas 5 : Sous-requête corrélée -> recalculée pour chaque ligne
──────────────────────────

-- MAUVAIS : la sous-requête est recalculée pour chaque ligne de clients
SELECT
    cl.nom,
    (SELECT COUNT(*) FROM commandes c WHERE c.id_client = cl.id_client) AS nb_cmd
FROM clients cl;
-- -> O(N) sous-requêtes où N = nombre de clients

-- CORRECT : LEFT JOIN + agrégation
SELECT
    cl.nom,
    COUNT(c.id_commande) AS nb_cmd
FROM clients cl
LEFT JOIN commandes c ON cl.id_client = c.id_client
GROUP BY cl.id_client, cl.nom;
-- -> 1 seule passe sur les données

-- Ou avec une CTE (souvent plus lisible)
WITH nb_par_client AS (
    SELECT id_client, COUNT(*) AS nb_cmd FROM commandes GROUP BY id_client
)
SELECT cl.nom, COALESCE(n.nb_cmd, 0) AS nb_cmd
FROM clients cl
LEFT JOIN nb_par_client n ON cl.id_client = n.id_client;

──────────────────────────
Cas 6 : work_mem insuffisant -> tri sur disque
──────────────────────────

-- Symptôme dans EXPLAIN ANALYZE :
-- Sort (cost=...) ... Sort Method: external merge  Disk: 14328kB
-- Le tri déborde sur le disque -> très lent

-- Solution temporaire (pour la session courante)
SET work_mem = '256MB';  -- Augmenter la mémoire de tri pour cette session
-- Puis relancer la requête

-- Solution permanente (attention : work_mem × nb_connexions = RAM totale utilisée)
-- Dans postgresql.conf :
-- work_mem = 64MB (défaut : 4MB, trop faible pour les analyses)

65.5 CHECKLIST D'OPTIMISATION SQL
───────────────────────────────────

  [ ] 1. Index sur les colonnes de JOIN (FK -> table parente)
  [ ] 2. Index sur les colonnes de WHERE les plus sélectives
  [ ] 3. Index composite pour les requêtes multi-colonnes fréquentes
  [ ] 4. Pas de fonctions sur les colonnes indexées dans WHERE
  [ ] 5. Pas de conversions de type implicites dans WHERE
  [ ] 6. SELECT uniquement les colonnes nécessaires (pas SELECT *)
  [ ] 7. Filtrer tôt (dans le JOIN ou WHERE, pas en post-traitement)
  [ ] 8. LIMIT sur les requêtes exploratoires
  [ ] 9. EXISTS plutôt que COUNT(*) > 0 pour les tests d'existence
  [ ] 10. Éviter les sous-requêtes corrélées -> remplacer par JOIN ou CTE
  [ ] 11. ANALYZE régulier pour maintenir les statistiques fraîches
  [ ] 12. VACUUM régulier pour récupérer l'espace des lignes mortes
  [ ] 13. Vues matérialisées pour les agrégations coûteuses
  [ ] 14. Partitionnement pour les tables > 100 millions de lignes
  [ ] 15. Mesurer avant et après chaque optimisation

65.6 MONITORING CONTINU DES PERFORMANCES
──────────────────────────────────────────

-- Santé des tables (nécessitent VACUUM ?)
SELECT
    schemaname,
    relname AS table,
    n_live_tup AS lignes_vivantes,
    n_dead_tup AS lignes_mortes,
    ROUND(n_dead_tup::DECIMAL / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS pct_mortes,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

-- Tables sans index sur leurs FK (risque de Seq Scan lors des JOINs)
SELECT
    tc.table_name AS table_enfant,
    kcu.column_name AS colonne_fk,
    ccu.table_name AS table_parent
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
    ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
    ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND NOT EXISTS (
      SELECT 1 FROM pg_indexes
      WHERE tablename = tc.table_name
        AND indexdef LIKE '%' || kcu.column_name || '%'
  );

-- Index inutilisés (candidats à la suppression)
SELECT
    schemaname,
    relname AS table,
    indexrelname AS index,
    idx_scan AS nb_utilisations,
    pg_size_pretty(pg_relation_size(indexrelid)) AS taille
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

-- Tables les plus volumineuses
SELECT
    schemaname,
    relname AS table,
    pg_size_pretty(pg_total_relation_size(relid)) AS taille_totale,
    pg_size_pretty(pg_relation_size(relid)) AS taille_table,
    pg_size_pretty(pg_indexes_size(relid)) AS taille_index
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;

-- Connexions actives et requêtes en cours
SELECT
    pid,
    usename,
    state,
    ROUND(EXTRACT(SECONDS FROM NOW() - query_start)::DECIMAL, 1) AS duree_s,
    LEFT(query, 100) AS requete
FROM pg_stat_activity
WHERE state = 'active'
  AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY duree_s DESC NULLS LAST;

-- Locks (verrous) — détecter les blocages
SELECT
    blocking.pid AS pid_bloquant,
    blocked.pid  AS pid_bloque,
    blocking.usename AS user_bloquant,
    blocked.usename  AS user_bloque,
    LEFT(blocking.query, 60) AS requete_bloquante,
    LEFT(blocked.query, 60)  AS requete_bloquee,
    ROUND(EXTRACT(SECONDS FROM NOW() - blocked.query_start)::DECIMAL, 0) AS attente_s
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
    ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';

65.7 EXERCICES PRATIQUES — PERFORMANCE
────────────────────────────────────────

NIVEAU FACILE :

Ex1 : Exécutez EXPLAIN ANALYZE sur la requête :
      SELECT * FROM commandes WHERE statut = 'LIVREE' AND id_client = 5;
      Analysez le plan. Y a-t-il un Seq Scan ? Créez les index appropriés.
      Mesurez le gain de performance.

Ex2 : Identifiez les 3 requêtes les plus lentes de ShopFlow
      via pg_stat_statements. Pour chacune, proposez une optimisation.

Ex3 : Exécutez le rapport "Tables sans index sur FK" et créez les index
      manquants pour ShopFlow. Justifiez chaque index créé.

NIVEAU INTERMÉDIAIRE :

Ex4 : Transformez cette requête corrélée en JOIN efficace et mesurez
      le gain avec EXPLAIN ANALYZE :
      SELECT cl.nom,
             (SELECT SUM(montant_ttc) FROM commandes c WHERE c.id_client = cl.id_client
              AND c.statut = 'LIVREE') AS ca_total
      FROM clients cl WHERE cl.actif = TRUE;

Ex5 : Trouvez la requête dans votre codebase ShopFlow qui génère le plus
      de lectures de blocs (buffers hits + reads dans EXPLAIN ANALYZE BUFFERS).
      Proposez un index couvrant pour réduire les lectures disque.

Ex6 : Configurez un script de maintenance nocturne complet :
      ANALYZE toutes les tables, VACUUM les tables avec > 5% de lignes mortes,
      REINDEX les index gonflés (bloat > 30%). Planifiez avec pg_cron.

NIVEAU AVANCÉ :

Ex7 : Construisez un benchmark automatisé :
      pour les 5 requêtes KPI principales du chapitre 52, mesurez
      les temps d'exécution avant et après optimisation (index, vues matérialisées).
      Produisez un rapport comparatif.

Ex8 : Analysez le phénomène de "bloat" d'index dans ShopFlow :
      identifiez les index dont la taille réelle est 2× supérieure à la taille
      théorique (car des versions mortes s'accumulent). Planifiez leur REINDEX.

Ex9 : Implémentez un système d'alertes de performance :
      une procédure stockée vérifiée toutes les heures qui alerte si :
      - Une requête dure > 30 secondes (pg_stat_activity)
      - Un index n'a pas été utilisé depuis 7 jours mais prend > 100MB
      - Le ratio dead_tuples / live_tuples dépasse 10% sur une table principale
      - pg_stat_statements révèle une nouvelle requête > 2 secondes de moyenne

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 18
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 64 : Catalogue des erreurs SQL fréquentes (syntaxe, types, logique,
                   contraintes). Méthode de debugging par simplification progressive.
                   Les codes d'erreur PostgreSQL essentiels.

  [OK] Chapitre 65 : Identifier les requêtes lentes (pg_stat_statements).
                   Lire et interpréter EXPLAIN ANALYZE. 6 cas pratiques
                   d'optimisation (index manquants, statistiques, fonctions dans
                   WHERE, N+1, sous-requêtes corrélées, work_mem).
                   Monitoring continu de la performance.

POINTS CLÉS À RETENIR :
  -> Lire TOUJOURS le message d'erreur en entier — il contient souvent la solution
  -> NULL ≠ NULL en SQL — toujours IS NULL / IS NOT NULL
  -> BETWEEN avec des timestamps est piégeux -> préférer >= ET <
  -> L'ordre d'exécution SQL : FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY
  -> Un index sur une colonne avec une fonction dans WHERE ne sera pas utilisé
  -> pg_stat_statements est l'outil #1 pour identifier les requêtes à optimiser
  -> VACUUM + ANALYZE = maintenance régulière obligatoire en production

PROCHAINE PARTIE :
  Partie 19 — Projets pratiques : e-commerce, finance, marketing.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 18 — DEBUGGING SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                    PARTIE 19 — PROJETS PRATIQUES                                ║
║                         Chapitres 66 à 68                                       ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Avancé — Projet complet
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 19
════════════════════════════════
  Chapitre 66 : Projet E-commerce — Dashboard complet ShopFlow
  Chapitre 67 : Projet Finance — Reporting financier et comptable
  Chapitre 68 : Projet Marketing — Analyse de campagnes et ROI

  APPROCHE : Chaque chapitre est un mini-projet complet avec brief,
  livrables attendus, solution commentée et variantes.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 66 — PROJET E-COMMERCE : DASHBOARD COMPLET SHOPFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

66.1 BRIEF DU PROJET
──────────────────────

Contexte :
  Le CEO de ShopFlow veut un rapport hebdomadaire automatique envoyé chaque
  lundi matin couvrant la semaine précédente. Le rapport doit couvrir :
  les ventes, les produits, les clients, les stocks, et les alertes critiques.

Livrables attendus :
  1. Vue matérialisée mv_rapport_hebdo stockant les KPIs de la semaine
  2. Rapport texte structuré généré en SQL
  3. Alertes automatiques (stock critique, taux d'annulation élevé)
  4. Top performers et sous-performants de la semaine
  5. Comparaison avec la semaine précédente et la même semaine N-1

66.2 SOLUTION COMMENTÉE
─────────────────────────

────────────────────────────────────────
PARTIE 1 : Infrastructure de base
────────────────────────────────────────

-- Paramètre : semaine analysée (par défaut : semaine dernière)
-- En production, ces dates sont passées par le système d'orchestration

DO $$
DECLARE
    v_debut_semaine DATE := DATE_TRUNC('week', NOW() - INTERVAL '7 days')::DATE;
    v_fin_semaine   DATE := v_debut_semaine + 6;
BEGIN
    RAISE NOTICE 'Rapport semaine du % au %',
        TO_CHAR(v_debut_semaine, 'DD/MM/YYYY'),
        TO_CHAR(v_fin_semaine, 'DD/MM/YYYY');
END $$;

-- Définir les variables de période comme constantes dans les CTEs
WITH params AS (
    SELECT
        DATE_TRUNC('week', NOW() - INTERVAL '7 days')::DATE AS debut_s,
        (DATE_TRUNC('week', NOW() - INTERVAL '7 days') + INTERVAL '6 days')::DATE AS fin_s,
        DATE_TRUNC('week', NOW() - INTERVAL '14 days')::DATE AS debut_s_prec,
        (DATE_TRUNC('week', NOW() - INTERVAL '14 days') + INTERVAL '6 days')::DATE AS fin_s_prec,
        DATE_TRUNC('week', NOW() - INTERVAL '371 days')::DATE AS debut_s_n1  -- même semaine N-1
)

────────────────────────────────────────
PARTIE 2 : KPIs Hebdomadaires
────────────────────────────────────────

, kpis_semaine AS (
    SELECT
        'semaine_courante'                              AS periode,
        COUNT(DISTINCT c.id_commande)                  AS nb_commandes,
        COUNT(DISTINCT c.id_client)                    AS nb_clients_actifs,
        COUNT(DISTINCT c.id_client) FILTER (
            WHERE NOT EXISTS (
                SELECT 1 FROM commandes c2
                WHERE c2.id_client = c.id_client
                  AND c2.date_commande < (SELECT debut_s FROM params)
                  AND c2.statut NOT IN ('ANNULEE', 'REMBOURSEE')
            )
        )                                              AS nb_nouveaux_clients,
        ROUND(SUM(c.montant_ttc), 2)                  AS ca_ttc,
        ROUND(AVG(c.montant_ttc), 2)                  AS panier_moyen,
        COUNT(*) FILTER (WHERE c.statut = 'ANNULEE')   AS nb_annulees,
        ROUND(
            COUNT(*) FILTER (WHERE c.statut = 'ANNULEE')::DECIMAL
            / NULLIF(COUNT(*), 0) * 100, 1
        )                                             AS taux_annulation_pct
    FROM commandes c, params p
    WHERE c.date_commande >= p.debut_s
      AND c.date_commande <= p.fin_s
)

────────────────────────────────────────
PARTIE 3 : Top 5 produits de la semaine
────────────────────────────────────────

, top_produits AS (
    SELECT
        p.nom                                         AS produit,
        cat.nom                                       AS categorie,
        SUM(lc.quantite)                             AS unites,
        ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
                  * (1 + lc.taux_tva / 100)), 2)    AS ca,
        RANK() OVER (ORDER BY SUM(lc.quantite) DESC) AS rang
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    JOIN produits p ON lc.id_produit = p.id_produit
    JOIN categories cat ON p.id_categorie = cat.id_categorie
    JOIN params pr ON c.date_commande >= pr.debut_s AND c.date_commande <= pr.fin_s
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY p.nom, cat.nom
)

────────────────────────────────────────
PARTIE 4 : Alertes critiques
────────────────────────────────────────

, alertes AS (
    -- Stock critique
    SELECT
        '[ATTENTION]  STOCK CRITIQUE' AS type_alerte,
        p.reference || ' — ' || p.nom AS detail,
        p.stock::TEXT || ' unités restantes (min: ' || p.stock_min::TEXT || ')' AS info
    FROM produits p
    WHERE p.actif = TRUE AND p.stock < p.stock_min

    UNION ALL

    -- Taux d'annulation élevé
    SELECT
        '[ALERTE] TAUX ANNULATION ÉLEVÉ',
        'Taux d''annulation > 10%',
        taux_annulation_pct::TEXT || '%'
    FROM kpis_semaine
    WHERE taux_annulation_pct > 10

    UNION ALL

    -- Aucune vente aujourd'hui (si lundi matin, pas de vente vendredi soir)
    SELECT
        '[ATTENTION]  ACTIVITÉ FAIBLE',
        'Moins de 3 commandes hier',
        nb::TEXT || ' commandes'
    FROM (
        SELECT COUNT(*) AS nb FROM commandes
        WHERE date_commande >= CURRENT_DATE - 1
          AND date_commande < CURRENT_DATE
          AND statut NOT IN ('ANNULEE')
    ) sub
    WHERE nb < 3
)

────────────────────────────────────────
PARTIE 5 : Assemblage du rapport
────────────────────────────────────────

-- Rapport final multi-section
SELECT '═══════════════════════════════════════'    AS rapport
UNION ALL SELECT '  RAPPORT HEBDOMADAIRE SHOPFLOW'
UNION ALL SELECT '  Semaine du ' || TO_CHAR(debut_s, 'DD/MM') || ' au ' || TO_CHAR(fin_s, 'DD/MM/YYYY')
FROM params
UNION ALL SELECT '═══════════════════════════════════════'
UNION ALL SELECT '[GRAPHIQUE] KPIs DE LA SEMAINE'
UNION ALL SELECT '  Commandes     : ' || nb_commandes        FROM kpis_semaine
UNION ALL SELECT '  CA TTC        : ' || ca_ttc || ' €'      FROM kpis_semaine
UNION ALL SELECT '  Panier moyen  : ' || panier_moyen || ' €' FROM kpis_semaine
UNION ALL SELECT '  Clients actifs: ' || nb_clients_actifs   FROM kpis_semaine
UNION ALL SELECT '  Nouveaux      : ' || nb_nouveaux_clients FROM kpis_semaine
UNION ALL SELECT '  Taux annul.   : ' || taux_annulation_pct || '%' FROM kpis_semaine
UNION ALL SELECT '───────────────────────────────────────'
UNION ALL SELECT '[TROPHEE] TOP 5 PRODUITS'
UNION ALL SELECT '  ' || rang || '. ' || produit || ' (' || unites || ' u.) — ' || ca || '€'
    FROM top_produits WHERE rang <= 5
UNION ALL SELECT '───────────────────────────────────────'
UNION ALL SELECT '[ALERTE] ALERTES'
UNION ALL SELECT '  ' || type_alerte || ' : ' || detail FROM alertes
UNION ALL SELECT '  (Aucune alerte)' WHERE NOT EXISTS (SELECT 1 FROM alertes)
UNION ALL SELECT '═══════════════════════════════════════';

────────────────────────────────────────
PARTIE 6 : Vue matérialisée pour archivage
────────────────────────────────────────

CREATE MATERIALIZED VIEW IF NOT EXISTS mv_kpi_hebdomadaire AS
SELECT
    DATE_TRUNC('week', date_commande)::DATE              AS semaine,
    COUNT(DISTINCT id_commande) FILTER (
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE'))    AS nb_commandes,
    COUNT(DISTINCT id_client)   FILTER (
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE'))    AS nb_clients_actifs,
    ROUND(SUM(montant_ttc)      FILTER (
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')), 2) AS ca,
    ROUND(AVG(montant_ttc)      FILTER (
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')), 2) AS aov,
    COUNT(*) FILTER (WHERE statut = 'ANNULEE')            AS nb_annulees
FROM commandes
GROUP BY DATE_TRUNC('week', date_commande)
WITH DATA;

CREATE UNIQUE INDEX ON mv_kpi_hebdomadaire (semaine);

-- Rafraîchissement (chaque lundi à 1h)
-- SELECT cron.schedule('refresh-kpi-hebdo', '0 1 * * 1',
--     'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_kpi_hebdomadaire;');

66.3 VARIANTES ET EXTENSIONS
──────────────────────────────

Extension 1 : Rapport par employé
  Ajouter une section "Performance commerciale" avec le CA par employé,
  leur rang, et leur évolution vs la semaine précédente.

Extension 2 : Rapport par zone géographique
  Ajouter une section "Géographie" avec le CA par ville et département.

Extension 3 : Prévisions
  Sur la base des 4 dernières semaines, projeter le CA probable
  de la semaine en cours (tendance linéaire simple).

66.4 EXERCICES — PROJET E-COMMERCE
────────────────────────────────────

Ex1 : Ajoutez au rapport une section "Comparaison semaine précédente" :
      CA S vs S-1, commandes S vs S-1, AOV S vs S-1, avec flèche
      (^ si hausse, v si baisse, -> si < 2% de variation).

Ex2 : Créez un rapport "Inventaire hebdomadaire" :
      pour chaque catégorie, calculez le stock total, les unités vendues
      cette semaine, et la couverture stock en semaines.

Ex3 : Construisez un rapport "Clients VIP à risque" :
      listez les clients VIP qui n'ont pas commandé depuis > 30 jours,
      avec leur LTV et leur catégorie préférée pour une campagne ciblée.

Ex4 : Créez la vue matérialisée mv_classement_produits rafraîchie chaque
      dimanche soir contenant le top 20 produits de la semaine avec leur
      variation de rang vs la semaine précédente.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 67 — PROJET FINANCE : REPORTING FINANCIER ET COMPTABLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

67.1 BRIEF DU PROJET
──────────────────────

Contexte :
  L'équipe financière de ShopFlow a besoin de rapports comptables mensuels :
  compte de résultat simplifié, suivi des encaissements, analyse des TVA,
  et prévisions de trésorerie.

Livrables attendus :
  1. Compte de résultat mensuel (CA, coûts, marge)
  2. Balance des paiements (encaissés, en attente, impayés)
  3. Ventilation TVA par taux
  4. Suivi des remboursements et avoirs
  5. Prévision de trésorerie à 30 jours

67.2 SOLUTION COMMENTÉE
─────────────────────────

────────────────────────────────────────
RAPPORT 1 : Compte de résultat mensuel
────────────────────────────────────────

WITH params AS (
    SELECT
        DATE_TRUNC('month', NOW() - INTERVAL '1 month') AS debut_mois,
        DATE_TRUNC('month', NOW())                       AS fin_mois
),
ventes AS (
    SELECT
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 - COALESCE(lc.remise_pct, 0) / 100))   AS ca_ht,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 - COALESCE(lc.remise_pct, 0) / 100)
            * (1 + lc.taux_tva / 100))                  AS ca_ttc,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 - COALESCE(lc.remise_pct, 0) / 100)
            * lc.taux_tva / 100)                        AS tva_collectee,
        SUM(lc.quantite * lc.prix_unitaire_ht
            * (1 - COALESCE(lc.remise_pct, 0) / 100)
            * 0.65)                                     AS cout_revient_estime
    FROM lignes_commande lc
    JOIN commandes c ON lc.id_commande = c.id_commande
    JOIN params p ON c.date_commande >= p.debut_mois
                 AND c.date_commande < p.fin_mois
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
),
remboursements AS (
    SELECT
        COALESCE(SUM(c.montant_ttc), 0)                AS total_rembourse,
        COUNT(*)                                       AS nb_rembourses
    FROM commandes c
    JOIN params p ON c.date_commande >= p.debut_mois
                 AND c.date_commande < p.fin_mois
    WHERE c.statut = 'REMBOURSEE'
)
SELECT
    '════ COMPTE DE RÉSULTAT MENSUEL ════' AS ligne

UNION ALL SELECT TO_CHAR(debut_mois, 'Month YYYY') FROM params

UNION ALL SELECT '──── CHIFFRE D''AFFAIRES ────'
UNION ALL SELECT 'CA brut HT         : ' || ROUND(v.ca_ht, 2) || ' €' FROM ventes v
UNION ALL SELECT 'CA brut TTC        : ' || ROUND(v.ca_ttc, 2) || ' €' FROM ventes v
UNION ALL SELECT 'Remboursements     : -' || ROUND(r.total_rembourse, 2) || ' €' FROM remboursements r
UNION ALL SELECT 'CA net TTC         : ' || ROUND(v.ca_ttc - r.total_rembourse, 2) || ' €' FROM ventes v, remboursements r

UNION ALL SELECT '──── COÛTS ESTIMÉS ────'
UNION ALL SELECT 'Coût de revient    : ' || ROUND(v.cout_revient_estime, 2) || ' €' FROM ventes v

UNION ALL SELECT '──── MARGE BRUTE ────'
UNION ALL SELECT 'Marge brute        : ' || ROUND(v.ca_ht - v.cout_revient_estime, 2) || ' €' FROM ventes v
UNION ALL SELECT 'Taux de marge      : ' || ROUND((v.ca_ht - v.cout_revient_estime) / NULLIF(v.ca_ht, 0) * 100, 1) || '%' FROM ventes v

UNION ALL SELECT '──── TVA ────'
UNION ALL SELECT 'TVA collectée      : ' || ROUND(v.tva_collectee, 2) || ' €' FROM ventes v;

────────────────────────────────────────
RAPPORT 2 : Balance des paiements
────────────────────────────────────────

WITH params AS (
    SELECT
        DATE_TRUNC('month', NOW() - INTERVAL '1 month') AS debut_mois,
        DATE_TRUNC('month', NOW())                       AS fin_mois
)
SELECT
    p.mode_paiement,
    COUNT(*)                                            AS nb_transactions,
    -- Encaissés
    COUNT(*) FILTER (WHERE p.statut = 'VALIDE')        AS nb_encaisses,
    ROUND(SUM(p.montant) FILTER (WHERE p.statut = 'VALIDE'), 2) AS montant_encaisse,
    -- En attente
    COUNT(*) FILTER (WHERE p.statut = 'EN_ATTENTE')    AS nb_en_attente,
    ROUND(SUM(p.montant) FILTER (WHERE p.statut = 'EN_ATTENTE'), 2) AS montant_en_attente,
    -- Refusés
    COUNT(*) FILTER (WHERE p.statut = 'REFUSE')        AS nb_refuses,
    ROUND(SUM(p.montant) FILTER (WHERE p.statut = 'REFUSE'), 2) AS montant_refuse,
    -- Remboursés
    COUNT(*) FILTER (WHERE p.statut = 'REMBOURSE')     AS nb_rembourses,
    ROUND(SUM(p.montant) FILTER (WHERE p.statut = 'REMBOURSE'), 2) AS montant_rembourse,
    -- Taux d'échec
    ROUND(
        COUNT(*) FILTER (WHERE p.statut = 'REFUSE')::DECIMAL / COUNT(*) * 100, 1
    )                                                  AS taux_echec_pct
FROM paiements p
JOIN commandes c ON p.id_commande = c.id_commande
CROSS JOIN params pr
WHERE c.date_commande >= pr.debut_mois AND c.date_commande < pr.fin_mois
GROUP BY p.mode_paiement
ORDER BY montant_encaisse DESC NULLS LAST;

────────────────────────────────────────
RAPPORT 3 : Ventilation TVA
────────────────────────────────────────

-- En France : TVA à 20% (standard), 10% (restauration, transport),
-- 5.5% (alimentation, livres), 2.1% (médicaments)

WITH params AS (
    SELECT DATE_TRUNC('month', NOW() - INTERVAL '1 month') AS debut,
           DATE_TRUNC('month', NOW()) AS fin
)
SELECT
    lc.taux_tva                                        AS taux_tva_pct,
    COUNT(DISTINCT lc.id_commande)                     AS nb_commandes,
    ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
              * (1 - COALESCE(lc.remise_pct, 0) / 100)), 2) AS base_ht,
    ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
              * (1 - COALESCE(lc.remise_pct, 0) / 100)
              * lc.taux_tva / 100), 2)                 AS montant_tva,
    ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
              * (1 - COALESCE(lc.remise_pct, 0) / 100)
              * (1 + lc.taux_tva / 100)), 2)           AS total_ttc
FROM lignes_commande lc
JOIN commandes c ON lc.id_commande = c.id_commande
CROSS JOIN params p
WHERE c.date_commande >= p.debut AND c.date_commande < p.fin
  AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY lc.taux_tva
ORDER BY lc.taux_tva DESC;

────────────────────────────────────────
RAPPORT 4 : Prévision de trésorerie à 30 jours
────────────────────────────────────────

-- Projeter les encaissements attendus sur les 30 prochains jours
-- basé sur les commandes en cours + tendance historique

WITH commandes_en_cours AS (
    -- Commandes confirmées/en préparation/expédiées non encore payées
    SELECT
        c.id_commande,
        c.date_commande,
        c.montant_ttc,
        c.statut,
        -- Date d'encaissement estimée selon statut
        CASE
            WHEN c.statut = 'CONFIRMEE'    THEN CURRENT_DATE + 3
            WHEN c.statut = 'EN_PREPARATION' THEN CURRENT_DATE + 5
            WHEN c.statut = 'EXPEDIEE'     THEN CURRENT_DATE + 2
            ELSE CURRENT_DATE + 7
        END AS date_encaissement_estimee
    FROM commandes c
    WHERE c.statut IN ('CONFIRMEE', 'EN_PREPARATION', 'EXPEDIEE')
      AND NOT EXISTS (
          SELECT 1 FROM paiements p
          WHERE p.id_commande = c.id_commande AND p.statut = 'VALIDE'
      )
),
prevision_30j AS (
    SELECT
        date_encaissement_estimee AS date_prevue,
        COUNT(*) AS nb_commandes,
        SUM(montant_ttc) AS encaissement_prevu
    FROM commandes_en_cours
    WHERE date_encaissement_estimee <= CURRENT_DATE + 30
    GROUP BY date_encaissement_estimee
),
tendance AS (
    -- CA moyen quotidien des 30 derniers jours
    SELECT AVG(ca_jour) AS ca_moyen_jour
    FROM (
        SELECT DATE_TRUNC('day', date_commande) AS jour,
               SUM(montant_ttc) AS ca_jour
        FROM commandes
        WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
          AND date_commande >= CURRENT_DATE - 30
        GROUP BY DATE_TRUNC('day', date_commande)
    ) daily
)
SELECT
    generate_series::DATE AS date_prevue,
    COALESCE(p.nb_commandes, 0) AS commandes_confirmees,
    COALESCE(p.encaissement_prevu, 0) AS encaissements_confirmes,
    ROUND((SELECT ca_moyen_jour FROM tendance), 2) AS tendance_base,
    ROUND(COALESCE(p.encaissement_prevu, 0) +
          (SELECT ca_moyen_jour FROM tendance), 2) AS encaissement_total_estime
FROM generate_series(CURRENT_DATE::TIMESTAMP, CURRENT_DATE + 30, '1 day') gs
LEFT JOIN prevision_30j p ON p.date_prevue = gs::DATE
ORDER BY gs;

67.3 EXERCICES — PROJET FINANCE
─────────────────────────────────

Ex1 : Construisez le rapport "Délai moyen de paiement" :
      calculez le nombre de jours entre la date_commande et la date
      de validation du paiement, par mode de paiement.
      Identifiez les modes de paiement les plus lents.

Ex2 : Créez le rapport "Top clients par CA vs par marge" :
      est-ce que les clients qui génèrent le plus de CA sont aussi
      ceux qui génèrent le plus de marge ? Calculez le rang des 10
      premiers clients selon chaque critère et comparez.

Ex3 : Calculez l'impact financier des remises accordées :
      quelle est la remise totale accordée ce mois ? Par catégorie ?
      Par commercial ? Les remises sont-elles corrélées avec un panier
      plus élevé (vaut-il la peine d'accorder des remises) ?

Ex4 : Construisez un tableau de bord de trésorerie :
      encaissements par semaine sur les 3 derniers mois, avec une
      colonne "prévu" (basé sur les commandes en cours) vs "réalisé".

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 68 — PROJET MARKETING : ANALYSE DE CAMPAGNES ET ROI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

68.1 BRIEF DU PROJET
──────────────────────

Contexte :
  L'équipe marketing de ShopFlow a lancé plusieurs campagnes :
  - "Soldes d'été" : réductions 20% sur l'électronique (juillet)
  - "Rentrée" : -15% sur tous les produits pour les clients inactifs (septembre)
  - "Black Friday" : -25% général (novembre)
  - "Fidélité VIP" : bon cadeau 50€ pour les clients VIP (décembre)

  La direction veut savoir : quelles campagnes ont le meilleur ROI ?

Livrables attendus :
  1. Mesure de l'impact de chaque campagne (CA incrémental)
  2. Coût de chaque campagne (coût des remises accordées)
  3. Calcul du ROI par campagne
  4. Analyse des clients acquis vs réactivés par campagne
  5. Recommandations pour les prochaines campagnes

68.2 SOLUTION COMMENTÉE
─────────────────────────

-- Note : on simule des campagnes en définissant les périodes et les conditions.
-- En production, un champ code_promo ou id_campagne serait dans la table commandes.

────────────────────────────────────────
STRUCTURE DE DONNÉES CAMPAGNES
────────────────────────────────────────

-- Table des campagnes marketing
CREATE TABLE IF NOT EXISTS campagnes_marketing (
    id_campagne         SERIAL PRIMARY KEY,
    nom                 VARCHAR(100) NOT NULL,
    date_debut          DATE NOT NULL,
    date_fin            DATE NOT NULL,
    type_campagne       VARCHAR(50),    -- 'REMISE', 'BON_CADEAU', 'LIVRAISON_GRATUITE'
    cible               VARCHAR(100),   -- 'TOUS', 'VIP', 'INACTIFS', 'CATEGORIE:X'
    remise_pct          DECIMAL(5,2),   -- % de remise accordée
    cout_fixe           DECIMAL(10,2) DEFAULT 0,  -- coût créatif, diffusion email
    description         TEXT
);

INSERT INTO campagnes_marketing (nom, date_debut, date_fin, type_campagne,
    cible, remise_pct, cout_fixe, description)
VALUES
('Soldes Été 2024',   '2024-07-01', '2024-07-31', 'REMISE', 'CATEGORIE:Electronique',  20, 500, 'Soldes estivaux électronique'),
('Rentrée 2024',      '2024-09-01', '2024-09-15', 'REMISE', 'INACTIFS_90J',            15, 800, 'Réactivation clients inactifs'),
('Black Friday 2024', '2024-11-29', '2024-12-01', 'REMISE', 'TOUS',                    25, 1200,'Black Friday général'),
('Fidélité VIP Noël', '2024-12-01', '2024-12-31', 'BON_CADEAU', 'VIP',                  0, 3000,'Bon cadeau 50€ pour les VIP');

────────────────────────────────────────
ANALYSE 1 : Impact des campagnes sur les ventes
────────────────────────────────────────

-- Pour chaque campagne, comparer le CA pendant la campagne vs la période équivalente
-- de l'année précédente (pour isoler l'effet saisonnier)

WITH impact_campagne AS (
    SELECT
        cm.id_campagne,
        cm.nom AS campagne,
        cm.date_debut,
        cm.date_fin,
        -- CA pendant la campagne
        COALESCE(SUM(c.montant_ttc) FILTER (
            WHERE c.date_commande >= cm.date_debut
              AND c.date_commande <= cm.date_fin
              AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
        ), 0) AS ca_pendant_campagne,
        -- Nombre de commandes pendant la campagne
        COUNT(DISTINCT c.id_commande) FILTER (
            WHERE c.date_commande >= cm.date_debut
              AND c.date_commande <= cm.date_fin
              AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
        ) AS nb_commandes_campagne,
        -- CA même période N-1 (baseline)
        COALESCE(SUM(c.montant_ttc) FILTER (
            WHERE c.date_commande >= cm.date_debut - INTERVAL '1 year'
              AND c.date_commande <= cm.date_fin - INTERVAL '1 year'
              AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
        ), 0) AS ca_baseline_n1
    FROM campagnes_marketing cm
    CROSS JOIN commandes c
    GROUP BY cm.id_campagne, cm.nom, cm.date_debut, cm.date_fin
)
SELECT
    campagne,
    TO_CHAR(date_debut, 'DD/MM') || '-' || TO_CHAR(date_fin, 'DD/MM/YYYY') AS periode,
    ROUND(ca_pendant_campagne, 2) AS ca_campagne,
    ROUND(ca_baseline_n1, 2) AS ca_baseline,
    ROUND(ca_pendant_campagne - ca_baseline_n1, 2) AS ca_incremental,
    CASE
        WHEN ca_baseline_n1 > 0
        THEN ROUND((ca_pendant_campagne - ca_baseline_n1) / ca_baseline_n1 * 100, 1)
        ELSE NULL
    END AS uplift_pct,
    nb_commandes_campagne
FROM impact_campagne
ORDER BY ca_incremental DESC;

────────────────────────────────────────
ANALYSE 2 : Coût des remises et ROI
────────────────────────────────────────

WITH cout_remises AS (
    SELECT
        cm.id_campagne,
        cm.nom,
        cm.cout_fixe,
        cm.remise_pct,
        -- Coût des remises accordées (CA qu'on aurait eu sans remise)
        SUM(
            lc.quantite * lc.prix_unitaire_ht
            * (1 + lc.taux_tva / 100)
            * cm.remise_pct / 100
        )                                              AS cout_remises_accordees,
        -- Nombre de clients touchés
        COUNT(DISTINCT c.id_client)                   AS nb_clients_touches,
        -- Nouveaux clients acquis pendant la campagne
        COUNT(DISTINCT c.id_client) FILTER (
            WHERE NOT EXISTS (
                SELECT 1 FROM commandes c2
                WHERE c2.id_client = c.id_client
                  AND c2.date_commande < cm.date_debut
                  AND c2.statut NOT IN ('ANNULEE', 'REMBOURSEE')
            )
        )                                              AS nb_nouveaux_clients
    FROM campagnes_marketing cm
    JOIN commandes c ON c.date_commande >= cm.date_debut
                    AND c.date_commande <= cm.date_fin
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cm.id_campagne, cm.nom, cm.cout_fixe, cm.remise_pct
),
ca_campagnes AS (
    SELECT cm.id_campagne,
           SUM(c.montant_ttc) AS ca_total
    FROM campagnes_marketing cm
    JOIN commandes c ON c.date_commande >= cm.date_debut
                    AND c.date_commande <= cm.date_fin
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cm.id_campagne
)
SELECT
    cr.nom AS campagne,
    ROUND(ca.ca_total, 2) AS ca_genere,
    ROUND(cr.cout_remises_accordees, 2) AS cout_remises,
    ROUND(cr.cout_fixe, 2) AS cout_fixe,
    ROUND(cr.cout_remises_accordees + cr.cout_fixe, 2) AS cout_total,
    -- CA net (après déduction du coût des remises)
    ROUND(ca.ca_total - cr.cout_remises_accordees - cr.cout_fixe, 2) AS ca_net,
    -- ROI = (CA net - Coût total) / Coût total × 100
    ROUND(
        (ca.ca_total - cr.cout_remises_accordees - cr.cout_fixe)
        / NULLIF(cr.cout_remises_accordees + cr.cout_fixe, 0) * 100,
        1
    ) AS roi_pct,
    cr.nb_clients_touches,
    cr.nb_nouveaux_clients,
    -- Coût d'acquisition par nouveau client
    ROUND(
        (cr.cout_remises_accordees + cr.cout_fixe)
        / NULLIF(cr.nb_nouveaux_clients, 0),
        2
    ) AS cout_acquisition_client
FROM cout_remises cr
JOIN ca_campagnes ca ON cr.id_campagne = ca.id_campagne
ORDER BY roi_pct DESC NULLS LAST;

────────────────────────────────────────
ANALYSE 3 : Comportement post-campagne (halo effect)
────────────────────────────────────────

-- Les clients acquis pendant la campagne continuent-ils à acheter après ?
-- Mesurer la LTV à 90 jours des clients acquis par campagne.

WITH clients_par_campagne AS (
    SELECT
        cm.id_campagne,
        cm.nom AS campagne,
        c.id_client
    FROM campagnes_marketing cm
    JOIN commandes c ON c.date_commande >= cm.date_debut
                    AND c.date_commande <= cm.date_fin
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND NOT EXISTS (
          SELECT 1 FROM commandes c2
          WHERE c2.id_client = c.id_client
            AND c2.date_commande < cm.date_debut
      )  -- Uniquement les nouveaux clients pendant la campagne
),
ltv_post_campagne AS (
    SELECT
        cpc.id_campagne,
        cpc.campagne,
        COUNT(DISTINCT cpc.id_client)                  AS nb_clients_acquis,
        -- CA pendant les 90 jours suivant la fin de la campagne
        SUM(c.montant_ttc)                             AS ca_90j_post,
        COUNT(DISTINCT c.id_commande)                  AS nb_commandes_post
    FROM clients_par_campagne cpc
    JOIN campagnes_marketing cm ON cpc.id_campagne = cm.id_campagne
    LEFT JOIN commandes c ON cpc.id_client = c.id_client
        AND c.date_commande > cm.date_fin
        AND c.date_commande <= cm.date_fin + INTERVAL '90 days'
        AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cpc.id_campagne, cpc.campagne
)
SELECT
    campagne,
    nb_clients_acquis,
    ROUND(ca_90j_post, 2) AS ca_total_90j_post,
    ROUND(ca_90j_post / NULLIF(nb_clients_acquis, 0), 2) AS ltv_90j_moyen,
    ROUND(nb_commandes_post::DECIMAL / NULLIF(nb_clients_acquis, 0), 1) AS cmds_post_par_client,
    -- Taux de réachat (clients ayant recacheté après la campagne)
    ROUND(
        COUNT(DISTINCT CASE WHEN nb_commandes_post > 0 THEN cpc.id_client END)::DECIMAL
        / NULLIF(nb_clients_acquis, 0) * 100, 1
    ) AS taux_reachat_pct
FROM ltv_post_campagne
ORDER BY ltv_90j_moyen DESC;

────────────────────────────────────────
ANALYSE 4 : Recommandations automatiques
────────────────────────────────────────

-- Générer des recommandations textuelles basées sur les données

WITH roi_data AS (
    SELECT
        cm.nom,
        cm.type_campagne,
        cm.cible,
        -- ROI simplifié
        ROUND(SUM(c.montant_ttc) / NULLIF(cm.cout_fixe + SUM(
            lc.quantite * lc.prix_unitaire_ht * cm.remise_pct / 100
        ), 0) * 100 - 100, 0) AS roi_pct
    FROM campagnes_marketing cm
    JOIN commandes c ON c.date_commande >= cm.date_debut
                    AND c.date_commande <= cm.date_fin
                    AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    JOIN lignes_commande lc ON c.id_commande = lc.id_commande
    GROUP BY cm.id_campagne, cm.nom, cm.type_campagne, cm.cible, cm.cout_fixe, cm.remise_pct
)
SELECT
    nom AS campagne,
    roi_pct,
    CASE
        WHEN roi_pct > 200
            THEN '[OK] EXCELLENT — À répéter impérativement. ROI de ' || roi_pct || '%. Augmenter le budget.'
        WHEN roi_pct > 100
            THEN '[BIEN] BON — Campagne rentable. Reconduire avec ajustements mineurs.'
        WHEN roi_pct > 50
            THEN '[ATTENTION]  MOYEN — Rentable mais optimisable. Tester une remise plus faible.'
        WHEN roi_pct > 0
            THEN '[JAUNE] FAIBLE — Juste rentable. Revoir le ciblage ou la mécanique.'
        ELSE
            '[X] NÉGATIF — Campagne non rentable (ROI: ' || roi_pct || '%). Ne pas renouveler.'
    END AS recommandation
FROM roi_data
ORDER BY roi_pct DESC;

68.3 EXERCICES — PROJET MARKETING
────────────────────────────────────

Ex1 : Ajoutez une campagne "Soldes d'hiver" dans campagnes_marketing.
      Définissez ses dates, sa cible et ses paramètres. Calculez
      son ROI prévisionnel basé sur les données historiques.

Ex2 : Analysez la "fatigue marketing" :
      les clients qui ont reçu les 3 dernières campagnes ont-ils
      un taux d'ouverture/conversion décroissant ? Calculez le
      nombre de campagnes reçues par client et son panier moyen.

Ex3 : Créez une segmentation "Next Best Campaign" :
      pour chaque segment de clients (RFM), proposez la campagne
      la plus adaptée basée sur l'historique des performances.
      Ex: Champions -> Fidélité VIP, À risque -> Réactivation agressive.

Ex4 : Calculez le "Customer Acquisition Cost" (CAC) par canal :
      si vous avez les colonnes source_acquisition dans clients
      (SEO, Paid, Email, Direct, Referral), calculez le coût
      d'acquisition moyen par canal et comparez à la LTV générée.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 19
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans cette partie, vous avez appris :

  [OK] Chapitre 66 : Projet E-commerce — Rapport hebdomadaire complet avec KPIs,
                   alertes automatiques, top produits et vue matérialisée d'archivage.

  [OK] Chapitre 67 : Projet Finance — Compte de résultat mensuel, balance des paiements,
                   ventilation TVA, prévision de trésorerie à 30 jours.

  [OK] Chapitre 68 : Projet Marketing — Mesure d'impact de campagnes, calcul du ROI,
                   analyse post-campagne (halo effect), recommandations automatiques.

POINTS CLÉS À RETENIR :
  -> Les projets réels combinent toutes les techniques vues : CTEs, window functions,
    FILTER, GROUP BY, sous-requêtes, vues matérialisées, procédures stockées
  -> Toujours comparer à une baseline (période N-1 ou contrôle) pour isoler l'effet
  -> Le ROI d'une campagne inclut le coût des remises ET les coûts fixes
  -> Les rapports automatisés = vues matérialisées + pg_cron + procédures stockées
  -> Documenter les hypothèses est aussi important que le SQL lui-même

PROCHAINE PARTIE :
  Partie 20 — Projet final : base de données complète, requêtes avancées, rapport final.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 19 — PROJETS PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════════════════════════════╗
║          GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS          ║
║                      PARTIE 20 — PROJET FINAL                                   ║
║                          Chapitres 69 à 71                                      ║
╚══════════════════════════════════════════════════════════════════════════════════╝

Base de données : ShopFlow (e-commerce)
Niveau : Expert — Synthèse totale
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TABLE DES MATIÈRES — PARTIE 20
════════════════════════════════
  Chapitre 69 : Base de données complète — Consolidation et schéma final ShopFlow
  Chapitre 70 : Requêtes avancées — 30 requêtes de niveau expert
  Chapitre 71 : Rapport final — Synthèse des compétences et feuille de route

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 69 — BASE DE DONNÉES COMPLÈTE : SCHÉMA FINAL SHOPFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

69.1 INTRODUCTION
──────────────────

Ce chapitre présente le schéma complet et définitif de ShopFlow, consolidant
tous les éléments introduits au fil des 20 parties du guide. C'est votre
référence de travail pour le projet final.

69.2 SCHÉMA COMPLET DE LA BASE DE DONNÉES SHOPFLOW
────────────────────────────────────────────────────

-- ═══════════════════════════════════════════════════════
-- SCRIPT DE CRÉATION COMPLET — SHOPFLOW v2.0
-- Ordre : types -> tables indépendantes -> tables dépendantes -> contraintes -> index
-- ═══════════════════════════════════════════════════════

-- Schémas
CREATE SCHEMA IF NOT EXISTS public;
CREATE SCHEMA IF NOT EXISTS dwh;
CREATE SCHEMA IF NOT EXISTS staging;
CREATE SCHEMA IF NOT EXISTS audit;

-- ────────────────────────────────────────────────────────
-- 1. TABLES INDÉPENDANTES (sans FK vers d'autres tables)
-- ────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS clients (
    id_client           SERIAL PRIMARY KEY,
    code_client         VARCHAR(20) UNIQUE NOT NULL,
    prenom              VARCHAR(100) NOT NULL,
    nom                 VARCHAR(100) NOT NULL,
    email               VARCHAR(200) UNIQUE NOT NULL,
    telephone           VARCHAR(20),
    date_naissance      DATE,
    adresse_rue         VARCHAR(200),
    adresse_ville       VARCHAR(100),
    adresse_cp          VARCHAR(10),
    adresse_pays        VARCHAR(100) DEFAULT 'France',
    segment             VARCHAR(50) DEFAULT 'NOUVEAU'
                            CHECK (segment IN ('VIP','PREMIUM','STANDARD','NOUVEAU')),
    newsletter          BOOLEAN DEFAULT FALSE,
    date_inscription    TIMESTAMP DEFAULT NOW(),
    derniere_connexion  TIMESTAMP,
    actif               BOOLEAN DEFAULT TRUE,
    CONSTRAINT chk_email_valide CHECK (email LIKE '%@%.%')
);

CREATE TABLE IF NOT EXISTS categories (
    id_categorie        SERIAL PRIMARY KEY,
    nom                 VARCHAR(100) UNIQUE NOT NULL,
    description         TEXT,
    id_parent           INTEGER REFERENCES categories(id_categorie) ON DELETE SET NULL,
    actif               BOOLEAN DEFAULT TRUE
);

CREATE TABLE IF NOT EXISTS fournisseurs (
    id_fournisseur      SERIAL PRIMARY KEY,
    code_fournisseur    VARCHAR(20) UNIQUE NOT NULL,
    raison_sociale      VARCHAR(200) NOT NULL,
    email               VARCHAR(200),
    telephone           VARCHAR(20),
    adresse             VARCHAR(300),
    pays                VARCHAR(100) DEFAULT 'France',
    delai_livraison_j   SMALLINT DEFAULT 5,
    note_qualite        DECIMAL(3,1) CHECK (note_qualite BETWEEN 0 AND 5),
    actif               BOOLEAN DEFAULT TRUE,
    date_creation       TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS employes (
    id_employe          SERIAL PRIMARY KEY,
    code_employe        VARCHAR(20) UNIQUE NOT NULL,
    prenom              VARCHAR(100) NOT NULL,
    nom                 VARCHAR(100) NOT NULL,
    email               VARCHAR(200) UNIQUE NOT NULL,
    poste               VARCHAR(100),
    departement         VARCHAR(100),
    salaire             DECIMAL(10,2),
    date_embauche       DATE NOT NULL,
    id_manager          INTEGER REFERENCES employes(id_employe) ON DELETE SET NULL,
    actif               BOOLEAN DEFAULT TRUE
);

CREATE TABLE IF NOT EXISTS entrepots (
    id_entrepot         SERIAL PRIMARY KEY,
    code_entrepot       VARCHAR(20) UNIQUE NOT NULL,
    nom                 VARCHAR(100) NOT NULL,
    adresse             VARCHAR(300),
    ville               VARCHAR(100),
    pays                VARCHAR(100) DEFAULT 'France',
    capacite_m2         INTEGER,
    actif               BOOLEAN DEFAULT TRUE
);

-- ────────────────────────────────────────────────────────
-- 2. TABLES DE SECOND NIVEAU (dépendent des indépendantes)
-- ────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS produits (
    id_produit          SERIAL PRIMARY KEY,
    reference           VARCHAR(50) UNIQUE NOT NULL,
    nom                 VARCHAR(200) NOT NULL,
    description         TEXT,
    id_categorie        INTEGER NOT NULL REFERENCES categories(id_categorie) ON DELETE RESTRICT,
    id_fournisseur      INTEGER NOT NULL REFERENCES fournisseurs(id_fournisseur) ON DELETE RESTRICT,
    prix_ht             DECIMAL(10,2) NOT NULL CHECK (prix_ht > 0),
    taux_tva            DECIMAL(4,2) DEFAULT 20.00 CHECK (taux_tva >= 0),
    poids_kg            DECIMAL(6,3),
    stock               INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    stock_min           INTEGER NOT NULL DEFAULT 5 CHECK (stock_min >= 0),
    actif               BOOLEAN DEFAULT TRUE,
    date_creation       TIMESTAMP DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS commandes (
    id_commande         SERIAL PRIMARY KEY,
    numero_commande     VARCHAR(30) UNIQUE NOT NULL,
    id_client           INTEGER NOT NULL REFERENCES clients(id_client) ON DELETE RESTRICT,
    id_employe          INTEGER REFERENCES employes(id_employe) ON DELETE SET NULL,
    date_commande       TIMESTAMP DEFAULT NOW(),
    date_livraison      TIMESTAMP,
    statut              VARCHAR(30) DEFAULT 'EN_ATTENTE'
                            CHECK (statut IN (
                                'EN_ATTENTE','CONFIRMEE','EN_PREPARATION',
                                'EXPEDIEE','LIVREE','ANNULEE','REMBOURSEE'
                            )),
    montant_ht          DECIMAL(12,2) DEFAULT 0,
    montant_tva         DECIMAL(12,2) DEFAULT 0,
    montant_ttc         DECIMAL(12,2) DEFAULT 0,
    frais_livraison     DECIMAL(8,2) DEFAULT 0,
    note_client         TEXT,
    note_interne        TEXT
);

-- ────────────────────────────────────────────────────────
-- 3. TABLES DE TROISIÈME NIVEAU
-- ────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS lignes_commande (
    id_ligne            SERIAL PRIMARY KEY,
    id_commande         INTEGER NOT NULL REFERENCES commandes(id_commande) ON DELETE CASCADE,
    id_produit          INTEGER NOT NULL REFERENCES produits(id_produit) ON DELETE RESTRICT,
    quantite            INTEGER NOT NULL CHECK (quantite > 0),
    prix_unitaire_ht    DECIMAL(10,2) NOT NULL CHECK (prix_unitaire_ht >= 0),
    taux_tva            DECIMAL(4,2) NOT NULL DEFAULT 20.00,
    remise_pct          DECIMAL(5,2) DEFAULT 0 CHECK (remise_pct BETWEEN 0 AND 100),
    montant_ligne_ht    DECIMAL(12,2) GENERATED ALWAYS AS
                            (ROUND(quantite * prix_unitaire_ht * (1 - remise_pct / 100), 2))
                            STORED,
    UNIQUE (id_commande, id_produit)
);

CREATE TABLE IF NOT EXISTS paiements (
    id_paiement         SERIAL PRIMARY KEY,
    id_commande         INTEGER NOT NULL REFERENCES commandes(id_commande) ON DELETE CASCADE,
    mode_paiement       VARCHAR(50) NOT NULL
                            CHECK (mode_paiement IN (
                                'CARTE','VIREMENT','PAYPAL','CHEQUE','ESPECES','BON_CADEAU'
                            )),
    montant             DECIMAL(12,2) NOT NULL CHECK (montant > 0),
    date_paiement       TIMESTAMP DEFAULT NOW(),
    reference_paiement  VARCHAR(100),
    statut              VARCHAR(20) DEFAULT 'EN_ATTENTE'
                            CHECK (statut IN ('EN_ATTENTE','VALIDE','REFUSE','REMBOURSE')),
    date_validation     TIMESTAMP,
    commentaire         TEXT
);

CREATE TABLE IF NOT EXISTS avis (
    id_avis             SERIAL PRIMARY KEY,
    id_produit          INTEGER NOT NULL REFERENCES produits(id_produit) ON DELETE CASCADE,
    id_client           INTEGER NOT NULL REFERENCES clients(id_client) ON DELETE CASCADE,
    id_commande         INTEGER REFERENCES commandes(id_commande) ON DELETE SET NULL,
    note                SMALLINT NOT NULL CHECK (note BETWEEN 1 AND 5),
    titre               VARCHAR(200),
    commentaire         TEXT,
    date_avis           TIMESTAMP DEFAULT NOW(),
    verifie             BOOLEAN DEFAULT FALSE,
    UNIQUE (id_produit, id_client)  -- Un seul avis par client par produit
);

CREATE TABLE IF NOT EXISTS stocks_entrepot (
    id_entrepot         INTEGER NOT NULL REFERENCES entrepots(id_entrepot) ON DELETE CASCADE,
    id_produit          INTEGER NOT NULL REFERENCES produits(id_produit) ON DELETE CASCADE,
    quantite            INTEGER NOT NULL DEFAULT 0 CHECK (quantite >= 0),
    emplacement         VARCHAR(50),
    date_maj            TIMESTAMP DEFAULT NOW(),
    PRIMARY KEY (id_entrepot, id_produit)
);

-- ────────────────────────────────────────────────────────
-- 4. INDEX DE PERFORMANCE
-- ────────────────────────────────────────────────────────

-- Index sur les FK (jointures fréquentes)
CREATE INDEX IF NOT EXISTS idx_produits_categorie    ON produits (id_categorie);
CREATE INDEX IF NOT EXISTS idx_produits_fournisseur  ON produits (id_fournisseur);
CREATE INDEX IF NOT EXISTS idx_commandes_client      ON commandes (id_client);
CREATE INDEX IF NOT EXISTS idx_commandes_employe     ON commandes (id_employe);
CREATE INDEX IF NOT EXISTS idx_lignes_commande_cmd   ON lignes_commande (id_commande);
CREATE INDEX IF NOT EXISTS idx_lignes_commande_prod  ON lignes_commande (id_produit);
CREATE INDEX IF NOT EXISTS idx_paiements_commande    ON paiements (id_commande);
CREATE INDEX IF NOT EXISTS idx_avis_produit          ON avis (id_produit);
CREATE INDEX IF NOT EXISTS idx_avis_client           ON avis (id_client);

-- Index sur les colonnes fréquemment filtrées
CREATE INDEX IF NOT EXISTS idx_commandes_date        ON commandes (date_commande);
CREATE INDEX IF NOT EXISTS idx_commandes_statut      ON commandes (statut);
CREATE INDEX IF NOT EXISTS idx_clients_email         ON clients (email);
CREATE INDEX IF NOT EXISTS idx_clients_segment       ON clients (segment);
CREATE INDEX IF NOT EXISTS idx_produits_actif        ON produits (actif) WHERE actif = TRUE;
CREATE INDEX IF NOT EXISTS idx_commandes_date_statut ON commandes (date_commande, statut);

-- Index couvrant pour les requêtes de CA fréquentes
CREATE INDEX IF NOT EXISTS idx_commandes_covering
    ON commandes (date_commande, statut, id_client, montant_ttc);

-- ────────────────────────────────────────────────────────
-- 5. TRIGGERS D'INTÉGRITÉ
-- ────────────────────────────────────────────────────────

-- Trigger : mettre à jour montant_ttc de la commande après chaque ligne
CREATE OR REPLACE FUNCTION recalculer_montant_commande()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
    UPDATE commandes
    SET
        montant_ht  = (
            SELECT COALESCE(SUM(quantite * prix_unitaire_ht
                               * (1 - remise_pct / 100)), 0)
            FROM lignes_commande WHERE id_commande = COALESCE(NEW.id_commande, OLD.id_commande)
        ),
        montant_tva = (
            SELECT COALESCE(SUM(quantite * prix_unitaire_ht
                               * (1 - remise_pct / 100) * taux_tva / 100), 0)
            FROM lignes_commande WHERE id_commande = COALESCE(NEW.id_commande, OLD.id_commande)
        ),
        montant_ttc = (
            SELECT COALESCE(SUM(quantite * prix_unitaire_ht
                               * (1 - remise_pct / 100) * (1 + taux_tva / 100)), 0)
            FROM lignes_commande WHERE id_commande = COALESCE(NEW.id_commande, OLD.id_commande)
        )
    WHERE id_commande = COALESCE(NEW.id_commande, OLD.id_commande);
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_recalculer_montant
    AFTER INSERT OR UPDATE OR DELETE ON lignes_commande
    FOR EACH ROW EXECUTE FUNCTION recalculer_montant_commande();

-- Trigger : décrémenter le stock lors de la confirmation d'une commande
CREATE OR REPLACE FUNCTION gerer_stock_commande()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
    -- Quand une commande passe de non-confirmée à confirmée : déduire le stock
    IF OLD.statut IN ('EN_ATTENTE') AND NEW.statut = 'CONFIRMEE' THEN
        UPDATE produits p
        SET stock = stock - lc.quantite
        FROM lignes_commande lc
        WHERE lc.id_commande = NEW.id_commande
          AND p.id_produit = lc.id_produit;

        -- Vérifier qu'aucun stock n'est passé en négatif
        IF EXISTS (
            SELECT 1 FROM produits p
            JOIN lignes_commande lc ON p.id_produit = lc.id_produit
            WHERE lc.id_commande = NEW.id_commande AND p.stock < 0
        ) THEN
            RAISE EXCEPTION 'Stock insuffisant pour un ou plusieurs produits de la commande %',
                NEW.id_commande;
        END IF;

    -- Quand une commande est annulée : remettre en stock
    ELSIF OLD.statut = 'CONFIRMEE' AND NEW.statut = 'ANNULEE' THEN
        UPDATE produits p
        SET stock = stock + lc.quantite
        FROM lignes_commande lc
        WHERE lc.id_commande = NEW.id_commande
          AND p.id_produit = lc.id_produit;
    END IF;

    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_gestion_stock
    AFTER UPDATE OF statut ON commandes
    FOR EACH ROW EXECUTE FUNCTION gerer_stock_commande();

69.3 DONNÉES DE TEST SYNTHÉTIQUES
────────────────────────────────────

-- Script de génération de données de test réalistes
-- (pour les exercices et les projets)

-- Insérer des catégories
INSERT INTO categories (nom, description) VALUES
('Informatique', 'Ordinateurs, composants, périphériques'),
('Audio', 'Casques, enceintes, équipement audio'),
('Téléphonie', 'Smartphones, tablettes, accessoires'),
('Photographie', 'Appareils photo, objectifs, accessoires'),
('Gaming', 'Jeux vidéo, consoles, accessoires gaming')
ON CONFLICT (nom) DO NOTHING;

-- Sous-catégories
INSERT INTO categories (nom, id_parent, description)
SELECT 'Ordinateurs portables', id_categorie, 'Laptops et notebooks'
FROM categories WHERE nom = 'Informatique'
ON CONFLICT (nom) DO NOTHING;

-- Fonctions utilitaires pour les tests
CREATE OR REPLACE FUNCTION generer_reference_commande()
RETURNS TEXT LANGUAGE sql AS $$
    SELECT 'CMD-' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' ||
           LPAD((EXTRACT(EPOCH FROM NOW())::BIGINT % 10000)::TEXT, 4, '0');
$$;

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 70 — 30 REQUÊTES DE NIVEAU EXPERT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

70.1 INTRODUCTION
──────────────────

Ce chapitre est votre examen final. Les 30 requêtes suivantes couvrent
l'ensemble des concepts du guide. Elles sont classées par difficulté croissante.
Essayez de les écrire vous-même avant de lire la solution.

70.2 REQUÊTES 1-10 : NIVEAU INTERMÉDIAIRE
──────────────────────────────────────────

────────────────────────────────────────
Requête 1 : Clients actifs sans commande depuis 60 jours
────────────────────────────────────────

SELECT
    cl.id_client,
    cl.prenom || ' ' || cl.nom         AS client,
    cl.email,
    cl.segment,
    MAX(c.date_commande)               AS derniere_commande,
    EXTRACT(DAYS FROM NOW() - MAX(c.date_commande))::INTEGER AS jours_inactif
FROM clients cl
JOIN commandes c ON cl.id_client = c.id_client
WHERE cl.actif = TRUE
  AND c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY cl.id_client, cl.prenom, cl.nom, cl.email, cl.segment
HAVING EXTRACT(DAYS FROM NOW() - MAX(c.date_commande)) > 60
ORDER BY jours_inactif DESC;

────────────────────────────────────────
Requête 2 : Produits jamais commandés mais en stock
────────────────────────────────────────

SELECT
    p.reference,
    p.nom,
    cat.nom AS categorie,
    p.stock,
    p.prix_ht,
    ROUND(p.stock * p.prix_ht * 0.65, 2) AS valeur_stock_immobilise
FROM produits p
JOIN categories cat ON p.id_categorie = cat.id_categorie
WHERE p.actif = TRUE
  AND NOT EXISTS (
      SELECT 1 FROM lignes_commande lc
      JOIN commandes c ON lc.id_commande = c.id_commande
      WHERE lc.id_produit = p.id_produit
        AND c.statut NOT IN ('ANNULEE')
  )
ORDER BY valeur_stock_immobilise DESC;

────────────────────────────────────────
Requête 3 : Évolution mensuelle du panier moyen avec tendance
────────────────────────────────────────

WITH aov_mensuel AS (
    SELECT
        DATE_TRUNC('month', date_commande) AS mois,
        ROUND(AVG(montant_ttc), 2) AS aov
    FROM commandes
    WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY DATE_TRUNC('month', date_commande)
)
SELECT
    TO_CHAR(mois, 'YYYY-MM') AS periode,
    aov AS panier_moyen,
    LAG(aov) OVER (ORDER BY mois) AS panier_mois_prec,
    ROUND(aov - LAG(aov) OVER (ORDER BY mois), 2) AS variation_abs,
    ROUND((aov - LAG(aov) OVER (ORDER BY mois))
          / NULLIF(LAG(aov) OVER (ORDER BY mois), 0) * 100, 1) AS variation_pct,
    ROUND(AVG(aov) OVER (ORDER BY mois ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS mma_3mois
FROM aov_mensuel
ORDER BY mois;

────────────────────────────────────────
Requête 4 : Fournisseurs et leurs produits les plus vendus
────────────────────────────────────────

WITH ventes_produit AS (
    SELECT
        p.id_fournisseur,
        p.id_produit,
        p.nom AS produit,
        SUM(lc.quantite) AS unites_vendues,
        RANK() OVER (PARTITION BY p.id_fournisseur ORDER BY SUM(lc.quantite) DESC) AS rang
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY p.id_fournisseur, p.id_produit, p.nom
)
SELECT
    f.raison_sociale AS fournisseur,
    f.pays,
    COUNT(DISTINCT p.id_produit) AS nb_produits_catalogue,
    ROUND(AVG(p.prix_ht), 2) AS prix_moyen,
    vp.produit AS meilleur_produit,
    vp.unites_vendues AS unites_best_seller
FROM fournisseurs f
JOIN produits p ON f.id_fournisseur = p.id_fournisseur
JOIN ventes_produit vp ON f.id_fournisseur = vp.id_fournisseur AND vp.rang = 1
WHERE f.actif = TRUE
GROUP BY f.id_fournisseur, f.raison_sociale, f.pays, vp.produit, vp.unites_vendues
ORDER BY unites_best_seller DESC NULLS LAST;

────────────────────────────────────────
Requête 5 : Distribution des commandes par heure de la journée
────────────────────────────────────────

SELECT
    EXTRACT(HOUR FROM date_commande)::INTEGER AS heure,
    COUNT(*) AS nb_commandes,
    ROUND(AVG(montant_ttc), 2) AS panier_moyen,
    ROUND(COUNT(*)::DECIMAL / SUM(COUNT(*)) OVER () * 100, 1) AS pct_total,
    -- Barre de visualisation ASCII
    REPEAT('█', (COUNT(*) / 5)::INTEGER) AS visualisation
FROM commandes
WHERE statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY EXTRACT(HOUR FROM date_commande)
ORDER BY heure;

────────────────────────────────────────
Requête 6 : Clients dont la note moyenne donnée est < note moyenne globale
────────────────────────────────────────

WITH note_globale AS (
    SELECT AVG(note) AS note_moy_globale FROM avis
)
SELECT
    cl.prenom || ' ' || cl.nom AS client,
    cl.segment,
    COUNT(a.id_avis) AS nb_avis_donnes,
    ROUND(AVG(a.note), 2) AS note_moyenne_donnee,
    ROUND((SELECT note_moy_globale FROM note_globale), 2) AS note_globale,
    ROUND(AVG(a.note) - (SELECT note_moy_globale FROM note_globale), 2) AS ecart
FROM clients cl
JOIN avis a ON cl.id_client = a.id_client
GROUP BY cl.id_client, cl.prenom, cl.nom, cl.segment
HAVING AVG(a.note) < (SELECT note_moy_globale FROM note_globale)
   AND COUNT(a.id_avis) >= 3
ORDER BY note_moyenne_donnee ASC;

────────────────────────────────────────
Requête 7 : Commandes avec délai de livraison et SLA
────────────────────────────────────────

SELECT
    c.numero_commande,
    cl.nom AS client,
    c.date_commande::DATE AS commande_le,
    c.date_livraison::DATE AS livre_le,
    EXTRACT(DAYS FROM c.date_livraison - c.date_commande)::INTEGER AS jours_livraison,
    CASE
        WHEN EXTRACT(DAYS FROM c.date_livraison - c.date_commande) <= 3 THEN '[OK] Dans le SLA'
        WHEN EXTRACT(DAYS FROM c.date_livraison - c.date_commande) <= 7 THEN '[ATTENTION]  Acceptable'
        ELSE '[X] Hors SLA'
    END AS statut_sla,
    e.prenom || ' ' || e.nom AS commercial
FROM commandes c
JOIN clients cl ON c.id_client = cl.id_client
LEFT JOIN employes e ON c.id_employe = e.id_employe
WHERE c.statut = 'LIVREE'
  AND c.date_livraison IS NOT NULL
ORDER BY jours_livraison DESC;

────────────────────────────────────────
Requête 8 : Produits en cross-sell (achetés dans la même commande)
────────────────────────────────────────

SELECT
    p1.nom AS produit_1,
    p2.nom AS produit_2,
    COUNT(DISTINCT lc1.id_commande) AS nb_co_achats,
    ROUND(COUNT(DISTINCT lc1.id_commande)::DECIMAL
          / (SELECT COUNT(DISTINCT id_commande) FROM commandes
             WHERE statut NOT IN ('ANNULEE')) * 100, 2) AS pct_commandes
FROM lignes_commande lc1
JOIN lignes_commande lc2 ON lc1.id_commande = lc2.id_commande
                         AND lc1.id_produit < lc2.id_produit
JOIN produits p1 ON lc1.id_produit = p1.id_produit
JOIN produits p2 ON lc2.id_produit = p2.id_produit
JOIN commandes c ON lc1.id_commande = c.id_commande
WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
GROUP BY p1.nom, p2.nom
HAVING COUNT(DISTINCT lc1.id_commande) >= 2
ORDER BY nb_co_achats DESC
LIMIT 15;

────────────────────────────────────────
Requête 9 : Employés et leurs commissions simulées
────────────────────────────────────────

WITH performance_employe AS (
    SELECT
        e.id_employe,
        e.prenom || ' ' || e.nom AS employe,
        e.poste,
        COUNT(DISTINCT c.id_commande) AS nb_commandes,
        ROUND(SUM(c.montant_ttc), 2) AS ca_genere,
        ROUND(AVG(c.montant_ttc), 2) AS panier_moyen
    FROM employes e
    JOIN commandes c ON e.id_employe = c.id_employe
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
      AND e.actif = TRUE
    GROUP BY e.id_employe, e.prenom, e.nom, e.poste
)
SELECT
    employe,
    poste,
    nb_commandes,
    ca_genere,
    panier_moyen,
    -- Commission : 2% du CA si > objectif 5000€, sinon 1%
    ROUND(CASE
        WHEN ca_genere > 5000 THEN ca_genere * 0.02
        ELSE ca_genere * 0.01
    END, 2) AS commission_simulee,
    RANK() OVER (ORDER BY ca_genere DESC) AS rang_ca,
    NTILE(3) OVER (ORDER BY ca_genere DESC) AS tercile
    -- Tercile 1 = top performers
FROM performance_employe
ORDER BY ca_genere DESC;

────────────────────────────────────────
Requête 10 : Stock en temps réel avec alerte multi-entrepôts
────────────────────────────────────────

SELECT
    p.reference,
    p.nom AS produit,
    cat.nom AS categorie,
    -- Stock total (somme de tous les entrepôts)
    SUM(se.quantite) AS stock_total,
    p.stock_min,
    -- Distribution par entrepôt
    STRING_AGG(
        ent.nom || ': ' || se.quantite::TEXT,
        ' | ' ORDER BY ent.nom
    ) AS repartition_entrepots,
    -- Nb d'entrepôts en rupture locale
    COUNT(*) FILTER (WHERE se.quantite = 0) AS entrepots_rupture,
    CASE
        WHEN SUM(se.quantite) = 0               THEN '[ROUGE] RUPTURE TOTALE'
        WHEN SUM(se.quantite) < p.stock_min     THEN '[ORANGE] STOCK CRITIQUE'
        WHEN COUNT(*) FILTER (WHERE se.quantite = 0) > 0 THEN '[JAUNE] RUPTURE PARTIELLE'
        ELSE                                         '[VERT] OK'
    END AS statut_stock
FROM produits p
JOIN categories cat ON p.id_categorie = cat.id_categorie
JOIN stocks_entrepot se ON p.id_produit = se.id_produit
JOIN entrepots ent ON se.id_entrepot = ent.id_entrepot
WHERE p.actif = TRUE AND ent.actif = TRUE
GROUP BY p.id_produit, p.reference, p.nom, cat.nom, p.stock_min
ORDER BY
    CASE WHEN SUM(se.quantite) = 0 THEN 1
         WHEN SUM(se.quantite) < p.stock_min THEN 2
         ELSE 3 END,
    p.nom;

70.3 REQUÊTES 11-20 : NIVEAU AVANCÉ
──────────────────────────────────────

────────────────────────────────────────
Requête 11 : Analyse de cohortes — Matrice de rétention pivotée
────────────────────────────────────────

WITH cohorte_base AS (
    SELECT id_client, DATE_TRUNC('month', MIN(date_commande)) AS cohorte
    FROM commandes WHERE statut NOT IN ('ANNULEE') GROUP BY id_client
),
activite AS (
    SELECT
        cb.id_client, cb.cohorte,
        (EXTRACT(YEAR FROM AGE(DATE_TRUNC('month', c.date_commande), cb.cohorte)) * 12
         + EXTRACT(MONTH FROM AGE(DATE_TRUNC('month', c.date_commande), cb.cohorte)))
         ::INTEGER AS mois_offset
    FROM cohorte_base cb
    JOIN commandes c ON cb.id_client = c.id_client
    WHERE c.statut NOT IN ('ANNULEE')
),
taille AS (SELECT cohorte, COUNT(DISTINCT id_client) AS n FROM cohorte_base GROUP BY cohorte)
SELECT
    TO_CHAR(a.cohorte, 'YYYY-MM') AS cohorte,
    t.n AS taille,
    MAX(CASE WHEN mois_offset=0  THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M0",
    MAX(CASE WHEN mois_offset=1  THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M1",
    MAX(CASE WHEN mois_offset=2  THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M2",
    MAX(CASE WHEN mois_offset=3  THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M3",
    MAX(CASE WHEN mois_offset=6  THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M6",
    MAX(CASE WHEN mois_offset=12 THEN ROUND(COUNT(DISTINCT id_client)::DECIMAL/t.n*100) END) || '%' AS "M12"
FROM activite a
JOIN taille t ON a.cohorte = t.cohorte
GROUP BY a.cohorte, t.n
ORDER BY a.cohorte;

────────────────────────────────────────
Requête 12 : Arbre des catégories récursif avec CA cumulé
────────────────────────────────────────

WITH RECURSIVE arbre_categories AS (
    SELECT id_categorie, nom, id_parent, 0 AS niveau,
           nom::TEXT AS chemin
    FROM categories WHERE id_parent IS NULL

    UNION ALL

    SELECT c.id_categorie, c.nom, c.id_parent, ac.niveau + 1,
           ac.chemin || ' > ' || c.nom
    FROM categories c
    JOIN arbre_categories ac ON c.id_parent = ac.id_categorie
),
ca_par_categorie AS (
    SELECT p.id_categorie, ROUND(SUM(lc.quantite * lc.prix_unitaire_ht
                                    * (1 + lc.taux_tva/100)), 2) AS ca
    FROM produits p
    JOIN lignes_commande lc ON p.id_produit = lc.id_produit
    JOIN commandes c ON lc.id_commande = c.id_commande
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY p.id_categorie
)
SELECT
    REPEAT('  ', ac.niveau) || ac.nom AS categorie_indentee,
    ac.chemin,
    ac.niveau,
    COALESCE(cc.ca, 0) AS ca_direct,
    -- CA cumulé incluant les sous-catégories (nécessiterait une 2e récursion)
    COALESCE(cc.ca, 0) AS ca_inclus_sous_cat  -- simplifié ici
FROM arbre_categories ac
LEFT JOIN ca_par_categorie cc ON ac.id_categorie = cc.id_categorie
ORDER BY ac.chemin;

────────────────────────────────────────
Requête 13 : Analyse RFM complète avec segmentation
────────────────────────────────────────

WITH rfm AS (
    SELECT
        cl.id_client,
        cl.prenom || ' ' || cl.nom AS client,
        cl.email,
        EXTRACT(DAYS FROM NOW() - MAX(c.date_commande))::INTEGER AS recence_j,
        COUNT(DISTINCT c.id_commande) AS frequence,
        ROUND(SUM(c.montant_ttc), 2) AS valeur
    FROM clients cl
    JOIN commandes c ON cl.id_client = c.id_client
    WHERE c.statut NOT IN ('ANNULEE', 'REMBOURSEE')
    GROUP BY cl.id_client, cl.prenom, cl.nom, cl.email
),
scores AS (
    SELECT *,
        NTILE(5) OVER (ORDER BY recence_j DESC) AS r,
        NTILE(5) OVER (ORDER BY frequence)       AS f,
        NTILE(5) OVER (ORDER BY valeur)           AS m
    FROM rfm
)
SELECT
    client, email,
    recence_j, frequence, valeur,
    r, f, m,
    CONCAT(r, f, m) AS rfm_code,
    ROUND((r * 0.35 + f * 0.35 + m * 0.30), 1) AS score_global,
    CASE
        WHEN r >= 4 AND f >= 4 AND m >= 4 THEN '[TROPHEE] Champion'
        WHEN r >= 3 AND f >= 3             THEN '* Fidèle'
        WHEN r >= 4 AND f <= 2             THEN '[NOUVEAU] Prometteur'
        WHEN r <= 2 AND f >= 3             THEN '[SLEEPING_FACE] Inactif fidèle'
        WHEN r <= 2 AND m >= 4             THEN '[ATTENTION]  À risque (haute valeur)'
        WHEN r <= 1                        THEN '[ATTENTE] Perdu'
        ELSE                                    '[NEUTRAL_FACE] Moyen'
    END AS segment_rfm
FROM scores
ORDER BY score_global DESC;

────────────────────────────────────────
Requêtes 14-20 (énoncés — solutions à développer par l'étudiant)
────────────────────────────────────────

-- Requête 14 : Calculez la contribution marginale de chaque commercial
--   (si cet employé n'avait pas pris ces commandes, quel aurait été le CA total ?)

-- Requête 15 : Construisez un "Product Affinity Matrix" :
--   pour chaque paire de catégories, calculez le % de clients ayant acheté
--   dans les DEUX catégories (intersection / union).

-- Requête 16 : Analyse de la saisonnalité avec décomposition :
--   CA mensuel = tendance + saisonnalité + résidu.
--   Calculez l'indice saisonnier de chaque mois sur 2 années complètes.

-- Requête 17 : Score de santé fournisseur agrégé :
--   Pour chaque fournisseur : score basé sur délai de livraison des produits,
--   note qualité, taux d'annulation des commandes de ses produits, stock disponible.

-- Requête 18 : Simulateur de promotions :
--   Pour une remise de X% sur la catégorie Y, estimez le CA supplémentaire
--   basé sur l'élasticité historique (variation prix/volume passée).

-- Requête 19 : Détection de fraude potentielle :
--   Commandes avec paiement refusé puis re-tentative réussie le même jour,
--   clients avec plusieurs adresses de livraison différentes en 30 jours,
--   commandes très élevées (> 3× le panier moyen du client) de nouveaux clients.

-- Requête 20 : ETL delta quotidien complet :
--   Procédure qui extrait les nouvelles commandes, les transforme,
--   les charge dans le DWH, rafraîchit les vues matérialisées,
--   et génère le rapport de qualité — tout en moins de 5 minutes.

70.4 REQUÊTES 21-30 : NIVEAU EXPERT
──────────────────────────────────────

-- (Énoncés — À résoudre comme projet personnel)

-- Requête 21 : Construisez un algorithme de pricing dynamique en SQL :
--   basé sur le stock (faible stock = prix plus élevé), la demande récente
--   et le comportement concurrentiel simulé. Générez un prix suggéré par produit.

-- Requête 22 : Analyse de la "valeur client à long terme" avec actualisation :
--   Calculez le CLV actualisé (discount rate 10%/an) sur 3 ans
--   pour chaque segment de client, en modélisant le taux d'attrition historique.

-- Requête 23 : Système de recommandations basé sur le filtrage collaboratif :
--   Pour un client donné, trouvez les clients les plus similaires
--   (même historique d'achat), puis recommandez les produits qu'ils ont achetés
--   mais pas le client cible.

-- Requête 24 : Construisez un "compte de résultat prévisionnel" pour le mois suivant :
--   Basé sur la saisonnalité, la tendance, et les campagnes planifiées.
--   Comparez avec l'objectif budgétaire (si une table budget existe).

-- Requête 25 : Analyse de survie (survival analysis) simplifiée :
--   Pour chaque cohorte de clients, calculez la "courbe de survie" :
--   probabilité qu'un client soit encore actif après N mois.
--   Comparez les courbes entre segments.

-- Requête 26 : Optimisation de l'assortiment :
--   Identifiez les produits qui maximisent le CA/m² de stockage
--   et proposez une réorganisation des entrepôts.

-- Requête 27 : Network analysis simplifié :
--   Clients qui ont parrainé d'autres clients (même ville, inscriptions proches).
--   Construisez le graphe de parrainage et identifiez les "hubs".

-- Requête 28 : Audit de performance complet :
--   Script automatisé qui génère un rapport EXPLAIN ANALYZE sur les 10 requêtes
--   les plus lentes, compare les plans actuels aux plans optimisés théoriques.

-- Requête 29 : Pipeline ETL complet en SQL pur :
--   Extraction, transformation, validation, chargement, rafraîchissement,
--   notification — tout dans une seule procédure stockée avec gestion d'erreurs.

-- Requête 30 : Rapport de conformité RGPD complet :
--   Vérification de toutes les obligations : registre des traitements,
--   durées de rétention respectées, données orphelines, consentements valides.
--   Générez le rapport réglementaire mensuel.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 71 — RAPPORT FINAL : SYNTHÈSE ET FEUILLE DE ROUTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

71.1 CE QUE VOUS AVEZ MAÎTRISÉ
────────────────────────────────

En complétant ce guide, vous avez acquis les compétences suivantes :

FONDATIONS SQL (Parties 1-5)
  [OK] Modélisation relationnelle, normalisation jusqu'à la 3NF
  [OK] SELECT, WHERE, ORDER BY, LIMIT, opérateurs de filtrage
  [OK] Fonctions d'agrégation : COUNT, SUM, AVG, MIN, MAX
  [OK] GROUP BY, HAVING, ROLLUP, CUBE, GROUPING SETS

REQUÊTES AVANCÉES (Parties 6-8)
  [OK] Tous les types de JOINs : INNER, LEFT, RIGHT, FULL, SELF, CROSS
  [OK] Sous-requêtes : scalaires, de table, corrélées, EXISTS, IN
  [OK] DML complet : INSERT avec UPSERT, UPDATE, DELETE, TRUNCATE, RETURNING

FONCTIONS ET OPTIMISATION (Parties 9-10)
  [OK] Fonctions numériques, texte, date/heure
  [OK] Vues (simples et matérialisées), index (B-tree, GIN, partial, expression)
  [OK] EXPLAIN ANALYZE et tuning des requêtes

SQL AVANCÉ (Parties 11-13)
  [OK] Transactions ACID, isolation, SAVEPOINT, SELECT FOR UPDATE
  [OK] Optimisation avancée : pg_stat_statements, partitionnement, Full Text Search
  [OK] Window functions : ROW_NUMBER, RANK, LAG/LEAD, SUM/AVG glissant
  [OK] CTEs : simples, matérialisées, writable, récursives

DATA ANALYTICS (Parties 14-16)
  [OK] KPIs e-commerce : CA, AOV, CLV, taux de conversion, churn
  [OK] Dashboards SQL et vues matérialisées pour le BI
  [OK] Analyse des ventes : ABC, saisonnalité, cohortes, market basket
  [OK] Segmentation RFM, analyse churn, score de risque
  [OK] Data Warehouse : schéma en étoile, dimensions, faits, SCD
  [OK] ETL complet : staging, transformation, validation, chargement, pg_cron

SÉCURITÉ & PRODUCTION (Parties 17-18)
  [OK] GRANT/REVOKE, permissions colonnes, DEFAULT PRIVILEGES
  [OK] Architecture de rôles, hiérarchie, Row Level Security
  [OK] Chiffrement (pgcrypto, bcrypt), audit trail, RGPD
  [OK] Debugging méthodique et performance troubleshooting

PROJETS COMPLETS (Parties 19-20)
  [OK] Rapports hebdomadaires automatisés
  [OK] Reporting financier et comptable
  [OK] Analyse de campagnes marketing et ROI
  [OK] Schéma de base de données complet avec triggers

71.2 LES PATTERNS SQL LES PLUS IMPORTANTS À RETENIR
─────────────────────────────────────────────────────

PATTERN 1 : Le filtre précoce
  -> Filtrer dans WHERE avant d'agréger, pas après
  -> Toujours exclure les statuts invalides (ANNULEE, REMBOURSEE) dans WHERE

PATTERN 2 : Le CTE comme outil de pensée
  -> Décomposer les problèmes complexes en étapes nommées
  -> Tester chaque CTE indépendamment avant d'assembler

PATTERN 3 : La window function pour les comparaisons
  -> LAG/LEAD pour les variations période-sur-période
  -> RANK/NTILE pour les classements et la segmentation
  -> SUM OVER pour les cumuls et les pourcentages du total

PATTERN 4 : L'UPSERT pour l'idempotence
  -> INSERT ... ON CONFLICT DO UPDATE = chargement sûr et répétable
  -> Indispensable dans les pipelines ETL

PATTERN 5 : La vue matérialisée pour la performance
  -> Précalculer les agrégats coûteux une fois par nuit
  -> REFRESH CONCURRENTLY pour éviter le blocage en production

PATTERN 6 : EXISTS vs IN pour les sous-requêtes
  -> EXISTS s'arrête à la première ligne trouvée (plus rapide)
  -> IN avec NULL dans la liste peut produire des résultats inattendus

PATTERN 7 : NULLIF pour les divisions sécurisées
  -> ROUND(numerateur / NULLIF(denominateur, 0) * 100, 1)
  -> Ne jamais diviser sans protéger contre la division par zéro

PATTERN 8 : La récursion pour les hiérarchies
  -> WITH RECURSIVE pour les arbres (catégories, organigrammes)
  -> Toujours inclure une condition de terminaison pour éviter les boucles infinies

71.3 LES 10 COMMANDEMENTS DU SQL PROFESSIONNEL
────────────────────────────────────────────────

  1. Tu MESURERAS avant d'optimiser (EXPLAIN ANALYZE, pg_stat_statements)
  2. Tu FILTRERAS tôt et tu AGGREGERAS tard
  3. Tu NOMMERAS tes CTEs et tes colonnes de façon expressive
  4. Tu DOCUMENTERAS tes hypothèses dans les commentaires
  5. Tu PROTÉGERAS contre les NULL et les divisions par zéro
  6. Tu TESTERAS avec des données connues avant de déployer
  7. Tu VERSIONNERAS tes scripts de migration (Flyway, Liquibase)
  8. Tu AUDITERAS les accès aux données sensibles
  9. Tu AUTOMATISERAS les tâches répétitives (pg_cron, procédures)
  10. Tu RÉCONCILIERAS les chiffres SQL avec les sources de référence

71.4 FEUILLE DE ROUTE — APRÈS CE GUIDE
────────────────────────────────────────

PROCHAINES ÉTAPES RECOMMANDÉES :

Niveau 1 — Consolider (1-3 mois) :
  -> Résoudre les 30 requêtes expert du chapitre 70
  -> Construire votre propre base de données ShopFlow from scratch
  -> Pratiquer sur des datasets réels (Kaggle, data.gouv.fr)

Niveau 2 — Spécialiser (3-6 mois) :
  -> Approfondir dbt (Data Build Tool) pour les transformations SQL
  -> Apprendre un outil de BI : Metabase, Tableau, Superset
  -> Explorer les bases NoSQL : MongoDB, Redis (pour comparer)

Niveau 3 — Professionnaliser (6-12 mois) :
  -> Certification PostgreSQL ou AWS/GCP/Azure Data Engineer
  -> Contribuer à un projet open-source utilisant SQL
  -> Mettre en production un pipeline ETL complet

TECHNOLOGIES COMPLÉMENTAIRES :
  -> Python + SQLAlchemy (ORM pour les requêtes programmatiques)
  -> dbt (transformation SQL industrialisée)
  -> Apache Airflow (orchestration de pipelines)
  -> Apache Spark SQL (SQL distribué pour le Big Data)
  -> Snowflake/BigQuery/Redshift (Data Warehouses cloud)

RESSOURCES RECOMMANDÉES :
  -> Documentation officielle PostgreSQL : https://www.postgresql.org/docs/
  -> Mode Analytics SQL Tutorial : https://mode.com/sql-tutorial/
  -> Practical SQL de Anthony DeBarros
  -> The Art of PostgreSQL de Dimitri Fontaine
  -> SQL Performance Explained de Markus Winand

71.5 BILAN FINAL
─────────────────

Vous venez de parcourir un guide de plus de 1 000 exemples SQL couvrant
70 chapitres, organisés en 20 parties progressives. De la première SELECT
aux pipelines ETL et à la conformité RGPD, vous avez découvert que SQL
n'est pas seulement un langage de requêtes — c'est un outil de pensée.

Chaque requête bien conçue raconte une histoire :
"Dans notre base de données, voici ce qui s'est passé,
voici pourquoi c'est important, voici ce que nous devrions faire."

Le SQL est le langage universel de la donnée. Quelle que soit
la technologie — PostgreSQL, MySQL, SQLite, BigQuery, Snowflake —
les fondamentaux que vous avez appris ici s'appliquent partout.

Continuez à pratiquer, à questionner les données, et à construire
des requêtes qui génèrent de la valeur. C'est là que réside
la vraie compétence de l'ingénieur data.

Bonne continuation ! [RAPIDE]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉSUMÉ DE LA PARTIE 20 ET DU GUIDE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Chapitre 69 : Schéma complet ShopFlow v2.0 — toutes les tables, contraintes,
                   index de performance et triggers d'intégrité documentés.

  [OK] Chapitre 70 : 30 requêtes expert couvrant l'ensemble du programme :
                   cohortes, RFM, window functions, CTEs récursives, ETL,
                   sécurité, optimisation et analyse marketing avancée.

  [OK] Chapitre 71 : Synthèse des compétences acquises, 8 patterns fondamentaux,
                   10 commandements du SQL professionnel, feuille de route
                   pour la progression post-guide.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÉCAPITULATIF COMPLET DU GUIDE SQL MASTER
═══════════════════════════════════════════

  Partie  1 (Ch  1-5)  : Fondations — bases de données, SQL, PostgreSQL
  Partie  2 (Ch  6-12) : Tables — modélisation, types, clés, normalisation
  Partie  3 (Ch 13-16) : SELECT de base — filtres, tri, pagination
  Partie  4 (Ch 17-20) : Opérateurs — AND/OR, BETWEEN, IN, LIKE
  Partie  5 (Ch 21-25) : Agrégations — COUNT, SUM, AVG, GROUP BY, HAVING *
  Partie  6 (Ch 26-30) : JOINs — INNER, LEFT, RIGHT, FULL, SELF
  Partie  7 (Ch 31-33) : Sous-requêtes — scalaires, EXISTS, corrélées
  Partie  8 (Ch 34-36) : DML — INSERT/UPSERT, UPDATE, DELETE/TRUNCATE
  Partie  9 (Ch 37-39) : Fonctions — numériques, texte, date/heure
  Partie 10 (Ch 40-42) : Vues & Index — vues matérialisées, EXPLAIN ANALYZE
  Partie 11 (Ch 43-45) : Transactions — ACID, isolation, SAVEPOINT *
  Partie 12 (Ch 46-48) : Optimisation — statistiques, plans, partitionnement *
  Partie 13 (Ch 49-51) : SQL Avancé — Window functions, CTEs, récursion *
  Partie 14 (Ch 52-54) : Data Analysis — KPIs, dashboards, ventes *
  Partie 15 (Ch 55-57) : Business réel — clients, produits, churn *
  Partie 16 (Ch 58-60) : Architecture — DWH, ETL, pipelines *
  Partie 17 (Ch 61-63) : Sécurité — permissions, rôles, RGPD *
  Partie 18 (Ch 64-65) : Debugging — erreurs, performance *
  Partie 19 (Ch 66-68) : Projets — e-commerce, finance, marketing *
  Partie 20 (Ch 69-71) : Projet final — schéma complet, 30 requêtes *

  * = Partie générée lors de cette session

  TOTAL : 71 chapitres | ~1 000 exemples SQL | 270+ exercices corrigés

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 20 — PROJET FINAL
FIN DU GUIDE COMPLET SQL — MAÎTRISE TOTALE POUR INGÉNIEURS LOGICIELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━