Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 520 minAvancé

Curseur SQL : parcourir un résultat ligne par ligne

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • parcourir un résultat ligne par ligne avec un curseur ;
  • détecter la fin du parcours avec un gestionnaire NOT FOUND ;
  • éviter le piège du SELECT … INTO qui arrête la boucle ;
  • remplacer un curseur par une requête quand c'est possible.

3 exercices corrigés1 schéma

Un curseur est un pointeur sur le résultat d'une requête SELECT, qui permet à une procédure de lire ce résultat une ligne à la fois et de faire un traitement pour chacune. On le déclare avec DECLARE … CURSOR FOR, on l'ouvre avec OPEN, on lit chaque ligne avec FETCH et on le ferme avec CLOSE. Ce chapitre montre la boucle complète, le piège qui l'arrête trop tôt, et pourquoi on préfère presque toujours une requête qui traite toutes les lignes d'un coup.

Les cinq étapes d'un curseur

  1. Déclarer le curseur et la requête qu'il parcourt : DECLARE c_tickets CURSOR FOR SELECT …;
  2. Déclarer un gestionnaire pour la fin du résultat : quand FETCH ne trouve plus de ligne, MySQL lève la condition NOT FOUND (SQLSTATE 02000). Sans gestionnaire, la procédure s'arrête sur l'erreur 1329.
  3. Ouvrir le curseur avec OPEN : la requête s'exécute.
  4. Lire dans une boucle avec FETCH … INTO variables, une ligne par tour, jusqu'à la fin.
  5. Fermer avec CLOSE, ce qui libère le résultat. MySQL ferme de toute façon les curseurs ouverts à la fin du bloc, mais ferme-les toi-même : c'est plus lisible.
ligne suivanteDECLARE cCURSOR FOR …OPEN cexécuteFETCH cINTO v_…v_fini ?nonTraiterla ligneplus de ligne : NOT FOUNDSET v_fini = TRUEouiLEAVECLOSE c
Un curseur : DECLARE, OPEN, une boucle de FETCH jusqu'à NOT FOUND, puis CLOSE

Un curseur complet

Le responsable veut relancer les tickets oubliés : chaque ticket non résolu ouvert depuis plus de p_jours jours monte d'un cran de priorité (basse devient normale, normale devient haute, haute devient critique). La date de référence est un paramètre, pour que la procédure soit testable avec des résultats stables.

sql
DELIMITER //
CREATE PROCEDURE relancer_tickets(IN p_date_ref DATETIME, IN p_jours INT, OUT p_nb INT)
BEGIN
  -- 1. Les variables
  DECLARE v_id_ticket INT;
  DECLARE v_priorite  VARCHAR(10);
  DECLARE v_fini      BOOLEAN DEFAULT FALSE;

  -- 2. Le curseur, après les variables
  DECLARE c_anciens CURSOR FOR
    SELECT id_ticket, priorite
    FROM ticket
    WHERE statut <> 'resolu'
      AND date_ouverture < p_date_ref - INTERVAL p_jours DAY;

  -- 3. Le gestionnaire de fin, après le curseur
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fini = TRUE;

  SET p_nb = 0;
  OPEN c_anciens;

  lecture: LOOP
    FETCH c_anciens INTO v_id_ticket, v_priorite;
    IF v_fini THEN
      LEAVE lecture;
    END IF;

    IF v_priorite <> 'critique' THEN
      UPDATE ticket
      SET priorite = CASE v_priorite WHEN 'basse'   THEN 'normale'
                                     WHEN 'normale' THEN 'haute'
                                     ELSE 'critique' END
      WHERE id_ticket = v_id_ticket;
      SET p_nb = p_nb + 1;
    END IF;
  END LOOP lecture;

  CLOSE c_anciens;
END //
DELIMITER ;

CALL relancer_tickets('2026-11-08 09:00:00', 14, @nb);
SELECT @nb;   -- 4
SELECT id_ticket, priorite FROM ticket WHERE statut <> 'resolu' ORDER BY id_ticket;
id_ticketpriorite
6normale
7haute
11critique
12normale
13critique
14normale

Les tickets 6, 7, 11 et 12, ouverts avant le 25 octobre, sont relancés. Le 13 et le 14, plus récents, ne bougent pas.

Lis attentivement la boucle, elle se retrouve dans tous les curseurs :

  • DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fini = TRUE; : quand FETCH ne trouve plus de ligne, au lieu d'arrêter la procédure, MySQL exécute SET v_fini = TRUE puis continue à l'instruction suivante ;
  • le test IF v_fini THEN LEAVE lecture; vient juste après le FETCH, avant tout traitement : sinon, le dernier tour traiterait une seconde fois les valeurs de la ligne précédente, restées dans les variables ;
  • FETCH remplit les variables dans l'ordre des colonnes du SELECT, et il faut autant de variables que de colonnes (sinon, erreur 1328 Incorrect number of FETCH variables).

Les curseurs de MySQL ont trois propriétés à connaître : ils sont en lecture seule (on ne modifie pas une ligne « à travers » le curseur : on fait un UPDATE … WHERE id = v_id), non défilables (on lit dans l'ordre, sans revenir en arrière ni sauter) et asensibles : le serveur peut parcourir une copie du résultat ou la table elle-même, si bien qu'une modification faite pendant le parcours peut être vue ou non par le curseur. N'écris pas de code qui en dépend.

Le piège du SELECT … INTO dans la boucle

Le gestionnaire NOT FOUND ne se déclenche pas seulement à la fin du curseur : n'importe quelle instruction du bloc qui ne trouve aucune ligne le déclenche, y compris un SELECT … INTO. Cette procédure veut lister les tickets en attente avec le numéro d'inventaire de leur poste :

sql
DELIMITER //
CREATE PROCEDURE liste_attente()
BEGIN
  DECLARE v_id_ticket INT;
  DECLARE v_id_poste  INT;
  DECLARE v_titre     VARCHAR(100);
  DECLARE v_num       CHAR(7);
  DECLARE v_liste     TEXT DEFAULT '';
  DECLARE v_fini      BOOLEAN DEFAULT FALSE;

  DECLARE c_attente CURSOR FOR
    SELECT id_ticket, id_poste, titre FROM ticket
    WHERE statut <> 'resolu' ORDER BY id_ticket;
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fini = TRUE;

  OPEN c_attente;
  lecture: LOOP
    FETCH c_attente INTO v_id_ticket, v_id_poste, v_titre;
    IF v_fini THEN
      LEAVE lecture;
    END IF;
    SET v_num = NULL;
    SELECT num_inventaire INTO v_num FROM poste WHERE id_poste = v_id_poste;
    SET v_liste = CONCAT(v_liste, v_id_ticket, ' ', COALESCE(v_num, 'sans poste'), ' ; ');
  END LOOP lecture;
  CLOSE c_attente;

  SELECT v_liste AS tickets;
END //
DELIMITER ;

CALL liste_attente();
tickets
6 PC-0008 ; 7 sans poste ;

Il y a six tickets en attente, la liste n'en contient que deux. Le ticket 7 n'a pas de poste (id_poste à NULL) : le SELECT … INTO ne trouve aucune ligne, le gestionnaire passe v_fini à TRUE, et le FETCH suivant n'est jamais testé : la boucle s'arrête au tour d'après. Aucune erreur ne s'affiche ; le résultat est simplement faux.

Trois corrections possibles, de la meilleure à la moins bonne :

  1. Mettre la jointure dans la requête du curseur : SELECT t.id_ticket, p.num_inventaire … FROM ticket t LEFT JOIN poste p ON p.id_poste = t.id_poste. Plus de SELECT … INTO dans la boucle, plus de piège.

  2. Isoler la recherche dans un sous-bloc qui a son propre gestionnaire : dans un bloc imbriqué, le gestionnaire le plus proche l'emporte.

    sql
    BEGIN
      DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_num = NULL;
      SET v_num = NULL;
      SELECT num_inventaire INTO v_num FROM poste WHERE id_poste = v_id_poste;
    END;
    
  3. Remettre SET v_fini = FALSE; après le SELECT … INTO. Ça marche, mais c'est fragile : le prochain qui ajoutera une requête dans la boucle retombera dans le piège.

Avec l'une de ces corrections, la procédure renvoie bien les six tickets : 6 PC-0008 ; 7 sans poste ; 11 PC-0004 ; 12 PC-0009 ; 13 sans poste ; 14 PC-0002 ;.

Pourquoi éviter les curseurs

Les deux procédures de ce chapitre s'écrivent en une seule requête. La relance des tickets :

sql
UPDATE ticket
SET priorite = CASE priorite WHEN 'basse'   THEN 'normale'
                             WHEN 'normale' THEN 'haute'
                             ELSE 'critique' END
WHERE statut <> 'resolu'
  AND priorite <> 'critique'
  AND date_ouverture < '2026-11-08 09:00:00' - INTERVAL 14 DAY;

Elle modifie les mêmes 4 lignes (SELECT ROW_COUNT(); renvoie 4 juste après). La liste des tickets en attente :

sql
SELECT GROUP_CONCAT(CONCAT(t.id_ticket, ' ', COALESCE(p.num_inventaire, 'sans poste'))
                    ORDER BY t.id_ticket SEPARATOR ' ; ') AS tickets
FROM ticket t
LEFT JOIN poste p ON p.id_poste = t.id_poste
WHERE t.statut <> 'resolu';

La version ensembliste est plus courte, plus lisible, sans piège, et beaucoup plus rapide sur un gros volume : le moteur traite l'ensemble en une passe, au lieu d'exécuter une requête par ligne. Elle est aussi atomique : si elle échoue, aucune ligne n'est modifiée, alors qu'un curseur interrompu au milieu laisse la moitié des tickets relancés (sauf transaction, voir le chapitre 7).

Un usage légitime : le SQL dynamique table par table

Un curseur se justifie quand chaque ligne demande quelque chose qu'une requête ne sait pas faire : appeler une procédure, envoyer une requête construite dynamiquement, enchaîner des étapes qui dépendent du résultat précédent. Exemple : compter les lignes de chaque table de la base. Le nom d'une table ne peut pas être un paramètre de requête ; il faut construire la requête pour chaque table, avec PREPARE (chapitre 11).

sql
DELIMITER //
CREATE PROCEDURE compter_lignes()
BEGIN
  DECLARE v_table VARCHAR(64);
  DECLARE v_fini  BOOLEAN DEFAULT FALSE;

  DECLARE c_tables CURSOR FOR
    SELECT table_name
    FROM information_schema.tables
    WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
    ORDER BY table_name;
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fini = TRUE;

  DROP TEMPORARY TABLE IF EXISTS tmp_comptage;
  CREATE TEMPORARY TABLE tmp_comptage (nom_table VARCHAR(64), nb_lignes INT);

  OPEN c_tables;
  lecture: LOOP
    FETCH c_tables INTO v_table;
    IF v_fini THEN
      LEAVE lecture;
    END IF;
    -- Le nom vient d'information_schema, pas d'une saisie ; on l'entoure quand même de ` `
    SET @sql = CONCAT('INSERT INTO tmp_comptage SELECT ', QUOTE(v_table), ', COUNT(*) FROM ',
                      CONCAT('`', REPLACE(v_table, '`', '``'), '`'));
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
  END LOOP lecture;
  CLOSE c_tables;

  SELECT nom_table, nb_lignes FROM tmp_comptage ORDER BY nom_table;
END //
DELIMITER ;

CALL compter_lignes();
nom_tablenb_lignes
audit_ticket0
compte6
employe14
historique_affectation0
historique_mdp0
installation44
intervention17
journal_erreur0
journal_securite0
logiciel8
poste15
service6
stat_mensuelle0
ticket14

DATABASE() renvoie la base courante. QUOTE() entoure une chaîne d'apostrophes en doublant celles qu'elle contient ; la double apostrophe inversée protège un nom de table. MySQL fournit aussi sys.quote_identifier() pour ce dernier usage. Une table temporaire n'existe que pour la session qui l'a créée et disparaît à la déconnexion. La colonne table_rows d'information_schema.tables donne une estimation sans rien parcourir, mais seulement une estimation pour InnoDB.

Exercices

Exercice 1 sur 3 Compléter le curseur Réussi À revoir

Complète les cinq trous de cette procédure, qui ajoute dans historique_affectation une ligne « retour en stock » pour chaque poste affecté à un employé du service dont le code est passé en paramètre, puis remet ces postes en stock.

sql
DELIMITER //
CREATE PROCEDURE rendre_postes_service(IN p_code VARCHAR(8))
BEGIN
  DECLARE v_id_poste   INT;
  DECLARE v_id_employe INT;
  DECLARE v_fini       BOOLEAN DEFAULT FALSE;
  DECLARE c_postes ________ FOR
    SELECT p.id_poste, p.id_employe
    FROM poste p
    JOIN employe e ON e.id_employe = p.id_employe
    JOIN service s ON s.id_service = e.id_service
    WHERE s.code = p_code;
  DECLARE ________ HANDLER FOR ________ SET v_fini = TRUE;

  OPEN c_postes;
  lecture: LOOP
    FETCH c_postes INTO v_id_poste, v_id_employe;
    IF v_fini THEN
      ________ lecture;
    END IF;
    INSERT INTO historique_affectation (id_poste, ancien_employe, nouvel_employe, modifie_par)
    VALUES (v_id_poste, v_id_employe, NULL, USER());
    UPDATE poste SET id_employe = NULL WHERE id_poste = v_id_poste;
  END LOOP lecture;
  ________ c_postes;
END //
DELIMITER ;
Voir la réponseMasquer la réponse de l'exercice 1

CURSOR, CONTINUE, NOT FOUND, LEAVE, CLOSE.

Pour le service juridique ('JURI'), qui n'a aucun employé, le premier FETCH déclenche NOT FOUND et la boucle s'arrête aussitôt : la procédure ne fait rien, sans erreur. Si un trigger d'historique existe déjà sur poste (chapitre 9), l'INSERT de la procédure ferait doublon avec lui : une même information ne doit être écrite qu'à un seul endroit.

Tu avais trouvé ?

Exercice 2 sur 3 Le curseur qui s'arrête trop tôt Réussi À revoir

Un collègue a écrit une procédure qui parcourt les interventions de Moussa Diallo (12) dans l'ordre de leur identifiant. Dans la boucle, après le test de fin, elle lit le titre du ticket avec un SELECT … INTO, puis le numéro d'inventaire du poste concerné avec un second SELECT … INTO. Le curseur a le gestionnaire habituel CONTINUE HANDLER FOR NOT FOUND SET v_fini = TRUE. Sur la base parc, la procédure ne traite que la première intervention, sans aucune erreur. Explique pourquoi, et donne deux façons de corriger.

Voir la réponseMasquer la réponse de l'exercice 2

La première intervention de Moussa Diallo (la n° 3) porte sur le ticket 2, « Imprimante du 2e étage hors ligne », qui n'a pas de poste (id_poste à NULL). Le SELECT … INTO qui cherche le poste ne trouve aucune ligne et déclenche le gestionnaire NOT FOUND du curseur : v_fini passe à vrai. Le traitement de cette intervention se termine (c'est un gestionnaire CONTINUE), puis le FETCH suivant lit bien l'intervention n° 4, mais le test qui le suit trouve v_fini à vrai et quitte la boucle.

Corrections : faire la jointure dans la requête du curseur (LEFT JOIN ticket … LEFT JOIN poste …), ou isoler chaque SELECT … INTO dans un sous-bloc BEGIN … END qui déclare son propre gestionnaire NOT FOUND.

Tu avais trouvé ?

Exercice 3 sur 3 Sans curseur Réussi À revoir

Réécris en une seule requête ce que fait la procédure rendre_postes_service pour le service 'LOGI' : l'historique d'abord, puis la remise en stock. Combien de lignes chaque requête concerne-t-elle ? Pourquoi faut-il les mettre dans une transaction ?

Voir la réponseMasquer la réponse de l'exercice 3
sql
START TRANSACTION;

INSERT INTO historique_affectation (id_poste, ancien_employe, nouvel_employe, modifie_par)
SELECT p.id_poste, p.id_employe, NULL, USER()
FROM poste p
JOIN employe e ON e.id_employe = p.id_employe
JOIN service s ON s.id_service = e.id_service
WHERE s.code = 'LOGI';

UPDATE poste p
JOIN employe e ON e.id_employe = p.id_employe
JOIN service s ON s.id_service = e.id_service
SET p.id_employe = NULL
WHERE s.code = 'LOGI';

COMMIT;

Trois lignes chacune : les postes 7 (Nadia Faure), 8 (Lucas Girard) et 9 (Léa Rousseau). Inès Bonnet, également à la logistique, n'a pas de poste. La transaction garantit que l'historique et la remise en stock sont validés ensemble : si l'UPDATE échoue, l'historique ne raconte pas un retour en stock qui n'a pas eu lieu. L'ordre compte : après l'UPDATE, la jointure ne trouverait plus les postes, devenus sans employé.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Gérer les erreurs : DECLARE HANDLER, SIGNAL et GET DIAGNOSTICSRevoir le sommaire du cours