Chapitre 420 minAvancé
Fonction stockée MySQL : CREATE FUNCTION et RETURNS
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire une fonction stockée et l'utiliser dans une requête ;
- déclarer ses caractéristiques (
DETERMINISTIC,NO SQL,READS SQL DATA) ; - choisir entre une fonction et une procédure ;
- connaître ce qu'une fonction n'a pas le droit de faire.
4 exercices corrigés
Une fonction stockée est une routine qui reçoit des paramètres et renvoie une seule valeur avec RETURN. Elle se crée avec CREATE FUNCTION nom(paramètres) RETURNS type et s'utilise directement dans une requête, comme une fonction native de MySQL (UPPER(), NOW(), DATEDIFF()). Ce chapitre montre comment l'écrire, comment déclarer ses caractéristiques (DETERMINISTIC, READS SQL DATA) et ce qu'elle n'a pas le droit de faire.
Ta première fonction
Le temps passé sur un ticket, c'est la somme des durées de ses interventions. On en a besoin dans plusieurs requêtes : on l'écrit une fois.
DELIMITER //
CREATE FUNCTION duree_totale_ticket(p_id_ticket INT) RETURNS INT
READS SQL DATA
BEGIN
DECLARE v_total INT;
SELECT COALESCE(SUM(duree_min), 0) INTO v_total
FROM intervention
WHERE id_ticket = p_id_ticket;
RETURN v_total;
END //
DELIMITER ;
Elle s'utilise partout où une expression est permise : dans la liste du SELECT, dans un WHERE, un ORDER BY, un SET.
SELECT id_ticket, titre, duree_totale_ticket(id_ticket) AS minutes
FROM ticket
WHERE statut = 'resolu' AND duree_totale_ticket(id_ticket) >= 120
ORDER BY minutes DESC;
| id_ticket | titre | minutes |
|---|---|---|
| 9 | Rançongiciel suspecté | 570 |
| 5 | Odoo très lent | 210 |
La syntaxe complète :
CREATE FUNCTION [IF NOT EXISTS] nom_fonction (parametre TYPE, …)
RETURNS type_retour
[DETERMINISTIC | NOT DETERMINISTIC]
[NO SQL | READS SQL DATA | CONTAINS SQL | MODIFIES SQL DATA]
[SQL SECURITY { DEFINER | INVOKER }]
[COMMENT 'texte']
BEGIN
…
RETURN expression;
END
Trois différences avec une procédure : les paramètres n'ont pas de mode (ils sont toujours en entrée), la clause RETURNS (avec un S) donne le type renvoyé, et le corps doit exécuter une instruction RETURN (sans S) sur chaque chemin. Une fonction qui atteint son END sans RETURN échoue à l'exécution avec l'erreur 1321, FUNCTION … ended without RETURN.
Procédure ou fonction ?
| Critère | Procédure | Fonction |
|---|---|---|
| Création | CREATE PROCEDURE | CREATE FUNCTION … RETURNS type |
| Appel | CALL nom(…), seule | Dans une expression : SELECT nom(…), WHERE nom(…) > 10 |
| Ce qu'elle renvoie | Des paramètres OUT, et des jeux de résultats | Une seule valeur, par RETURN |
| Paramètres | IN, OUT, INOUT | Toujours en entrée |
Transactions (COMMIT, ROLLBACK) | Autorisées | Interdites |
SQL dynamique (PREPARE) | Autorisé | Interdit |
| Usage typique | Agir : clôturer, installer, archiver | Calculer : une durée, un libellé, un taux |
Règle simple : si tu as besoin de la valeur dans une requête, c'est une fonction ; si tu agis sur les données ou renvoies plusieurs choses, c'est une procédure.
DETERMINISTIC, NO SQL, READS SQL DATA : les caractéristiques
Une fonction déclare deux informations sur elle-même.
Est-elle déterministe ? DETERMINISTIC signifie : « avec les mêmes paramètres, je renvoie toujours le même résultat ». Une fonction qui ne fait qu'un calcul sur ses paramètres l'est ; une fonction qui lit CURDATE(), NOW(), RAND() ou le contenu d'une table ne l'est pas, puisque le résultat change avec le temps ou les données. Par défaut, une routine est NOT DETERMINISTIC.
Que fait-elle des données ?
| Caractéristique | Sens |
|---|---|
NO SQL | Aucune instruction SQL sur les données : un pur calcul |
READS SQL DATA | Lit des tables, sans les modifier |
MODIFIES SQL DATA | Modifie des données |
CONTAINS SQL (par défaut) | Contient du SQL, mais ni lecture ni écriture de tables (SET @x = 1) |
Pourquoi est-ce important ? Parce que MySQL 8.4 active par défaut le journal binaire (binary log), qui sert à la réplication et à la restauration à un instant donné (cours SQL). Une fonction qui donnerait un résultat différent sur un serveur répliqué rendrait les copies incohérentes. MySQL exige donc qu'une fonction déclare au moins une des caractéristiques DETERMINISTIC, NO SQL ou READS SQL DATA :
DELIMITER //
CREATE FUNCTION f_sans_caracteristique(p_id INT) RETURNS INT
BEGIN
RETURN p_id * 2;
END //
DELIMITER ;
ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its
declaration and binary logging is enabled (you *might* want to use the less safe
log_bin_trust_function_creators variable)
Trois exemples justes :
DELIMITER //
-- Un pur calcul : déterministe, sans SQL. 570 minutes donnent '9 h 30'.
CREATE FUNCTION libelle_duree(p_minutes INT) RETURNS VARCHAR(20)
DETERMINISTIC NO SQL
BEGIN
IF p_minutes IS NULL THEN
RETURN NULL;
END IF;
RETURN CONCAT(p_minutes DIV 60, ' h ', LPAD(p_minutes MOD 60, 2, '0'));
END //
-- Dépend de la date du jour : non déterministe, mais sans SQL
CREATE FUNCTION anciennete_ans(p_date DATE) RETURNS INT
NOT DETERMINISTIC NO SQL
BEGIN
RETURN TIMESTAMPDIFF(YEAR, p_date, CURDATE());
END //
DELIMITER ;
-- duree_totale_ticket, plus haut, lit une table : READS SQL DATA
Les fonctions se combinent :
SELECT id_ticket, titre, libelle_duree(duree_totale_ticket(id_ticket)) AS temps_passe
FROM ticket
WHERE id_ticket IN (1, 5, 9);
| id_ticket | titre | temps_passe |
|---|---|---|
| 1 | Écran noir au démarrage | 1 h 05 |
| 5 | Odoo très lent | 3 h 30 |
| 9 | Rançongiciel suspecté | 9 h 30 |
Une fonction qui modifie des données et le déclare honnêtement (MODIFIES SQL DATA, NOT DETERMINISTIC) ne coche aucune des trois cases exigées : elle est refusée avec l'erreur 1418, sauf à activer la variable log_bin_trust_function_creators. Cette variable lève le contrôle pour tout le serveur, demande un compte administrateur et ne se règle pas chez un hébergeur mutualisé. Le journal binaire impose aussi une condition au créateur : sans le privilège SUPER (ou cette variable à 1), créer une fonction échoue avec l'erreur 1419, You do not have the SUPER privilege and binary logging is enabled. Tu la rencontreras sur un serveur partagé où ton compte n'est pas administrateur. Le refus de l'erreur 1418 est un bon signal : une fonction ne devrait pas modifier les données. Si un calcul doit écrire, écris une procédure.
Des fonctions utiles pour la base parc
Les jours ouvrés d'attente. La procédure du chapitre 3 devient une fonction, utilisable dans une requête :
DELIMITER //
CREATE FUNCTION jours_ouvres(p_debut DATE, p_fin DATE) RETURNS INT
DETERMINISTIC NO SQL
BEGIN
DECLARE v_jour DATE DEFAULT p_debut;
DECLARE v_nb INT DEFAULT 0;
WHILE v_jour <= p_fin DO
IF DAYOFWEEK(v_jour) NOT IN (1, 7) THEN -- ni dimanche (1) ni samedi (7)
SET v_nb = v_nb + 1;
END IF;
SET v_jour = v_jour + INTERVAL 1 DAY;
END WHILE;
RETURN v_nb;
END //
DELIMITER ;
SELECT id_ticket, titre, DATE(date_ouverture) AS ouvert_le,
jours_ouvres(DATE(date_ouverture), '2026-11-06') AS jours_ouvres_attente
FROM ticket
WHERE statut <> 'resolu'
ORDER BY id_ticket;
| id_ticket | titre | ouvert_le | jours_ouvres_attente |
|---|---|---|---|
| 6 | Ventilateur bruyant | 2026-09-22 | 34 |
| 7 | Installer Sage pour Paul Martin | 2026-09-23 | 33 |
| 11 | Accès VPN impossible | 2026-10-19 | 15 |
| 12 | Clavier cassé | 2026-10-21 | 13 |
| 13 | Lenteurs réseau à l'entrepôt | 2026-10-26 | 10 |
| 14 | Batterie à remplacer | 2026-10-27 | 9 |
Elle est déterministe : deux dates données produisent toujours le même nombre. C'est la requête qui fixe la date de fin (ici le vendredi 6 novembre 2026) ; avec CURDATE() à la place, la requête change de résultat chaque jour, mais pas la fonction.
Le prochain numéro d'inventaire. La procédure ajouter_postes_stock du chapitre 3 calculait le numéro suivant ; ce calcul mérite sa fonction, que le formulaire de saisie peut afficher :
DELIMITER //
CREATE FUNCTION prochain_num_inventaire() RETURNS CHAR(7)
READS SQL DATA
BEGIN
DECLARE v_max INT;
SELECT MAX(CAST(SUBSTRING(num_inventaire, 4) AS UNSIGNED)) INTO v_max FROM poste;
RETURN CONCAT('PC-', LPAD(COALESCE(v_max, 0) + 1, 4, '0'));
END //
DELIMITER ;
SELECT prochain_num_inventaire(); -- PC-0016
Ce qu'une fonction n'a pas le droit de faire
| Interdit | Erreur |
|---|---|
Renvoyer un jeu de résultats (SELECT sans INTO, SHOW) | 1415, Not allowed to return a result set from a function, à la création |
Valider ou annuler une transaction (COMMIT, ROLLBACK, START TRANSACTION, et toute instruction du LDD qui valide implicitement) | 1422, Explicit or implicit commit is not allowed in stored function or trigger, à la création |
Exécuter du SQL dynamique (PREPARE, EXECUTE) | 1336, à la création |
| S'appeler elle-même (récursivité) | 1424, Recursive stored functions and triggers are not allowed, à l'exécution |
| Modifier une table que la requête appelante utilise déjà | 1442, à l'exécution (même erreur que pour les triggers) |
Servir dans une colonne générée, une contrainte CHECK ou une valeur DEFAULT | 3763 pour une colonne générée, à la création de la colonne |
La dernière ligne surprend souvent : on ne peut pas écrire ALTER TABLE ticket ADD COLUMN minutes INT AS (duree_totale_ticket(id_ticket)). Une colonne générée ne peut utiliser que des fonctions natives déterministes ; pour stocker un calcul qui dépend d'autres tables, on utilise un trigger qui tient la colonne à jour (chapitre 8).
Fonction et performance
Une fonction est appelée une fois par ligne. SELECT duree_totale_ticket(id_ticket) FROM ticket exécute 14 fois la requête de la fonction ; sur 100 000 tickets, 100 000 fois. Et une condition WHERE ma_fonction(colonne) = valeur empêche d'utiliser un index sur la colonne : toute la table est lue.
La même information s'obtient en une seule requête ensembliste :
SELECT t.id_ticket, t.titre, COALESCE(SUM(i.duree_min), 0) AS minutes
FROM ticket t
LEFT JOIN intervention i ON i.id_ticket = t.id_ticket
GROUP BY t.id_ticket, t.titre;
Réserve donc les fonctions qui lisent des tables aux petits volumes, ou aux calculs que tu veux absolument écrire une seule fois. Les fonctions de pur calcul (libelle_duree, jours_ouvres) ne posent pas ce problème.
Gérer ses fonctions
SHOW FUNCTION STATUS WHERE Db = 'parc';
SHOW CREATE FUNCTION libelle_duree;
DROP FUNCTION IF EXISTS libelle_duree;
SELECT routine_name, is_deterministic, sql_data_access
FROM information_schema.routines
WHERE routine_schema = 'parc' AND routine_type = 'FUNCTION';
| ROUTINE_NAME | IS_DETERMINISTIC | SQL_DATA_ACCESS |
|---|---|---|
| anciennete_ans | NO | NO SQL |
| duree_totale_ticket | NO | READS SQL DATA |
| jours_ouvres | YES | NO SQL |
| libelle_duree | YES | NO SQL |
| prochain_num_inventaire | NO | READS SQL DATA |
Une fonction et une procédure peuvent porter le même nom (ce sont deux espaces de noms distincts), mais évite-le. Évite aussi les noms des fonctions natives (now, count) : MySQL ne te laisserait appeler la tienne qu'avec une espace avant la parenthèse.
Exercices
Exercice 1 sur 4 Le nombre de postes d'un employé Réussi À revoir
Écris une fonction nb_postes(p_id_employe INT) qui renvoie le nombre de postes affectés à un employé. Utilise-la pour lister les employés qui n'ont pas exactement un poste, puis réécris la même requête sans fonction.
Voir la réponseMasquer la réponse de l'exercice 1
DELIMITER //
CREATE FUNCTION nb_postes(p_id_employe INT) RETURNS INT
READS SQL DATA
BEGIN
DECLARE v_nb INT;
SELECT COUNT(*) INTO v_nb FROM poste WHERE id_employe = p_id_employe;
RETURN v_nb;
END //
DELIMITER ;
SELECT nom, prenom, nb_postes(id_employe) AS postes
FROM employe
WHERE nb_postes(id_employe) <> 1
ORDER BY nom;
| nom | prenom | postes |
|---|---|---|
| Benali | Sofiane | 2 |
| Bonnet | Inès | 0 |
| Martin | Paul | 0 |
Sans fonction, une jointure externe et un regroupement :
SELECT e.nom, e.prenom, COUNT(p.id_poste) AS postes
FROM employe e
LEFT JOIN poste p ON p.id_employe = e.id_employe
GROUP BY e.id_employe, e.nom, e.prenom
HAVING COUNT(p.id_poste) <> 1
ORDER BY e.nom;
COUNT(p.id_poste) compte 0 pour un employé sans poste, alors que COUNT(*) compterait la ligne ajoutée par le LEFT JOIN. La version avec fonction exécute la requête de nb_postes 17 fois : 14 fois dans le WHERE, une par employé, puis 3 fois dans la liste du SELECT, une par ligne retenue. La version avec jointure n'envoie qu'une seule requête.
Tu avais trouvé ?
Exercice 2 sur 4 Le taux d'utilisation des licences Réussi À revoir
Écris une fonction taux_utilisation(p_id_logiciel INT) RETURNS DECIMAL(5,1) qui renvoie le pourcentage de licences utilisées (installations × 100 / licences), ou NULL pour un logiciel libre. Quel résultat donne la requête SELECT nom, taux_utilisation(id_logiciel) AS taux FROM logiciel WHERE nb_licences IS NOT NULL ORDER BY taux DESC; ?
Voir la réponseMasquer la réponse de l'exercice 2
DELIMITER //
CREATE FUNCTION taux_utilisation(p_id_logiciel INT) RETURNS DECIMAL(5,1)
READS SQL DATA
BEGIN
DECLARE v_licences INT;
DECLARE v_installees INT;
SELECT nb_licences INTO v_licences FROM logiciel WHERE id_logiciel = p_id_logiciel;
IF v_licences IS NULL OR v_licences = 0 THEN
RETURN NULL;
END IF;
SELECT COUNT(*) INTO v_installees FROM installation WHERE id_logiciel = p_id_logiciel;
RETURN v_installees * 100 / v_licences;
END //
DELIMITER ;
| nom | taux |
|---|---|
| Odoo | 87.5 |
| Microsoft 365 | 83.3 |
| Sage 100 Comptabilité | 66.7 |
| Acrobat Pro | 0.0 |
Odoo : 7 installations pour 8 licences ; Microsoft 365 : 10 pour 12 (83,33 arrondi à 83,3 par le type DECIMAL(5,1)) ; Sage : 2 pour 3 ; Acrobat Pro : aucune installation. Le test v_licences = 0 évite une division par zéro.
Tu avais trouvé ?
Exercice 3 sur 4 Les bonnes caractéristiques Réussi À revoir
Pour chaque fonction, donne la déclaration qui convient : DETERMINISTIC ou NOT DETERMINISTIC, et NO SQL ou READS SQL DATA.
ttc(p_ht DECIMAL(8,2))qui renvoiep_ht * 1.2.nb_tickets_ouverts()qui compte les tickets non résolus.delai_depasse(p_date_ouverture DATETIME, p_heures INT)qui renvoie vrai siNOW()dépasse la date d'ouverture plus le délai.initiales(p_prenom VARCHAR(50), p_nom VARCHAR(50))qui renvoie'C. L.'pour Chloé Lambert.
Voir la réponseMasquer la réponse de l'exercice 3
DETERMINISTIC NO SQL: un calcul sur le paramètre.NOT DETERMINISTIC READS SQL DATA: le résultat change avec le contenu de la table.NOT DETERMINISTIC NO SQL: aucune table lue, maisNOW()change à chaque appel. Pour la rendre déterministe et plus facile à tester, passe la date de référence en paramètre au lieu d'appelerNOW().DETERMINISTIC NO SQL.
Tu avais trouvé ?
Exercice 4 sur 4 Trouve l'erreur Réussi À revoir
Cette fonction se crée sans erreur. Pourquoi l'appel SELECT categorie_prix(1249); échoue-t-il, alors que categorie_prix(600) répond 'entrée' ? Corrige-la.
DELIMITER //
CREATE FUNCTION categorie_prix(p_prix DECIMAL(8,2)) RETURNS VARCHAR(10)
DETERMINISTIC NO SQL
BEGIN
IF p_prix < 700 THEN
RETURN 'entrée';
ELSEIF p_prix < 1150 THEN
RETURN 'milieu';
END IF;
END //
DELIMITER ;
Voir la réponseMasquer la réponse de l'exercice 4
Pour un prix d'au moins 1 150, aucune branche ne s'exécute et la fonction atteint son END sans RETURN : erreur 1321, FUNCTION categorie_prix ended without RETURN. MySQL ne le détecte qu'à l'exécution, sur la valeur qui emprunte ce chemin. Correction : ajouter une branche ELSE RETURN 'haut de gamme';, ou un RETURN final après le END IF. Pense aussi au prix NULL : aucune comparaison n'est vraie, il faut une branche qui le traite.
Tu avais trouvé ?