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 … INTOqui 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
- Déclarer le curseur et la requête qu'il parcourt :
DECLARE c_tickets CURSOR FOR SELECT …; - Déclarer un gestionnaire pour la fin du résultat : quand
FETCHne trouve plus de ligne, MySQL lève la conditionNOT FOUND(SQLSTATE02000). Sans gestionnaire, la procédure s'arrête sur l'erreur 1329. - Ouvrir le curseur avec
OPEN: la requête s'exécute. - Lire dans une boucle avec
FETCH … INTO variables, une ligne par tour, jusqu'à la fin. - 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.
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.
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_ticket | priorite |
|---|---|
| 6 | normale |
| 7 | haute |
| 11 | critique |
| 12 | normale |
| 13 | critique |
| 14 | normale |
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;: quandFETCHne trouve plus de ligne, au lieu d'arrêter la procédure, MySQL exécuteSET v_fini = TRUEpuis continue à l'instruction suivante ;- le test
IF v_fini THEN LEAVE lecture;vient juste après leFETCH, avant tout traitement : sinon, le dernier tour traiterait une seconde fois les valeurs de la ligne précédente, restées dans les variables ; FETCHremplit les variables dans l'ordre des colonnes duSELECT, et il faut autant de variables que de colonnes (sinon, erreur 1328Incorrect 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 :
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 :
-
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 deSELECT … INTOdans la boucle, plus de piège. -
Isoler la recherche dans un sous-bloc qui a son propre gestionnaire : dans un bloc imbriqué, le gestionnaire le plus proche l'emporte.
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; -
Remettre
SET v_fini = FALSE;après leSELECT … 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 :
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 :
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).
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_table | nb_lignes |
|---|---|
| audit_ticket | 0 |
| compte | 6 |
| employe | 14 |
| historique_affectation | 0 |
| historique_mdp | 0 |
| installation | 44 |
| intervention | 17 |
| journal_erreur | 0 |
| journal_securite | 0 |
| logiciel | 8 |
| poste | 15 |
| service | 6 |
| stat_mensuelle | 0 |
| ticket | 14 |
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.
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
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é ?