Chapitre 635 minIntermédiaire
Node.js et MySQL avec mysql2 : requêtes préparées et transactions
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- connecter une API à MySQL avec un pool mysql2 ;
- écrire des requêtes préparées, y compris avec des filtres facultatifs ;
- regrouper plusieurs écritures dans une transaction ;
- transformer une erreur MySQL en réponse HTTP utile.
5 exercices corrigés1 schéma
Pour relier une API Node.js à MySQL ou MariaDB, on utilise un pilote : une bibliothèque qui ouvre les connexions, envoie les requêtes SQL et rend les résultats sous forme d'objets JavaScript. Le pilote de référence est mysql2, avec son interface à promesses. Ce chapitre crée la base d'AppliFrais, configure un pool de connexions, écrit le dépôt des fiches avec des requêtes préparées, regroupe les écritures liées dans des transactions et transforme les erreurs MySQL en réponses HTTP.
La base d'AppliFrais
Le schéma relationnel, au format des sujets (clé primaire en gras, clés étrangères précédées de #) :
utilisateur (id, login, nom, prenom, mot_de_passe, role)
frais_forfait (code, libelle, montant)
fiche_frais (id, #id_visiteur, mois, etat, montant_valide, date_modification)
ligne_frais_forfait (#id_fiche, #code_forfait, quantite)
frais_hors_forfait (id, #id_fiche, date_frais, libelle, montant, justificatif)
Le script de création des tables, pour MySQL 8.4 (il fonctionne aussi sous MariaDB 11). Il ne crée pas la base elle-même : le même script sert ainsi pour la base de développement, pour la base de test et dans la CI.
-- sql/schema.sql : les tables, à exécuter dans une base existante
CREATE TABLE utilisateur (
id INT AUTO_INCREMENT PRIMARY KEY,
login VARCHAR(30) NOT NULL UNIQUE,
nom VARCHAR(50) NOT NULL,
prenom VARCHAR(50) NOT NULL,
mot_de_passe VARCHAR(255) NOT NULL, -- hachage argon2id, jamais le mot de passe
role ENUM('visiteur', 'comptable') NOT NULL DEFAULT 'visiteur'
) ENGINE = InnoDB;
CREATE TABLE frais_forfait (
code CHAR(3) PRIMARY KEY, -- ETP, KM, NUI, REP
libelle VARCHAR(50) NOT NULL,
montant DECIMAL(6,2) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE fiche_frais (
id INT AUTO_INCREMENT PRIMARY KEY,
id_visiteur INT NOT NULL,
mois CHAR(7) NOT NULL, -- '2026-10'
etat ENUM('CR', 'CL', 'VA', 'MP') NOT NULL DEFAULT 'CR',
montant_valide DECIMAL(8,2) NULL,
date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uq_fiche_visiteur_mois UNIQUE (id_visiteur, mois),
CONSTRAINT fk_fiche_visiteur FOREIGN KEY (id_visiteur) REFERENCES utilisateur (id)
) ENGINE = InnoDB;
CREATE TABLE ligne_frais_forfait (
id_fiche INT NOT NULL,
code_forfait CHAR(3) NOT NULL,
quantite INT NOT NULL DEFAULT 0 CHECK (quantite >= 0),
PRIMARY KEY (id_fiche, code_forfait),
CONSTRAINT fk_ligne_fiche FOREIGN KEY (id_fiche) REFERENCES fiche_frais (id) ON DELETE CASCADE,
CONSTRAINT fk_ligne_forfait FOREIGN KEY (code_forfait) REFERENCES frais_forfait (code)
) ENGINE = InnoDB;
CREATE TABLE frais_hors_forfait (
id INT AUTO_INCREMENT PRIMARY KEY,
id_fiche INT NOT NULL,
date_frais DATE NOT NULL,
libelle VARCHAR(100) NOT NULL,
montant DECIMAL(8,2) NOT NULL CHECK (montant > 0),
justificatif VARCHAR(255) NULL, -- nom du fichier stocké, pas le fichier
CONSTRAINT fk_hf_fiche FOREIGN KEY (id_fiche) REFERENCES fiche_frais (id) ON DELETE CASCADE
) ENGINE = InnoDB;
Les règles de gestion sont déjà dans la structure : la contrainte UNIQUE (id_visiteur, mois) interdit deux fiches pour le même mois, les CHECK refusent une quantité négative ou un montant nul, ON DELETE CASCADE supprime les lignes d'une fiche supprimée. La base est le dernier rempart : même si le code oublie une vérification, elle refuse les données incohérentes. Le cours SQL détaille chacune de ces instructions.
Le jeu de données sql/donnees.sql crée trois utilisateurs (Anne Durand, comptable ; Lucas Martin, visiteur n° 7 ; Sarah Vincent, visiteuse n° 8), les quatre forfaits et trois fiches : la 11 de Lucas (septembre, validée), la 12 de Lucas (octobre, en cours) et la 13 de Sarah (octobre, clôturée). Il commence par vider les tables (DELETE FROM …), ce qui permet de le rejouer pour remettre la base dans un état connu, notamment avant chaque test (chapitre 12).
-- sql/donnees.sql : jeu de démonstration et de test, rejouable (vide puis remplit)
DELETE FROM frais_hors_forfait;
DELETE FROM ligne_frais_forfait;
DELETE FROM fiche_frais;
DELETE FROM frais_forfait;
DELETE FROM utilisateur;
-- Mots de passe : Comptable#2026 pour adurand, Visiteur#2026 pour les deux autres
INSERT INTO utilisateur (id, login, nom, prenom, mot_de_passe, role) VALUES
(1, 'adurand', 'Durand', 'Anne', '$argon2id$v=19$m=65536,p=4,t=3$0wk4bG2QzUP0uhzuSMo6oA$/WMVqyn1u8K8nbwnfLw7CwHZtcLEHOu5rKdnmVL1dPY', 'comptable'),
(7, 'lmartin', 'Martin', 'Lucas', '$argon2id$v=19$m=65536,p=4,t=3$Dgts2gA9wpYgVIEfwBEhYQ$CPbE0ae6USdangrpcLEhpkWahdkUyvSEQ2WZba188lU', 'visiteur'),
(8, 'svincent', 'Vincent', 'Sarah', '$argon2id$v=19$m=65536,p=4,t=3$fTKbOS3dBtCJVjmmn3L4AQ$76hP8Y5dtTRzbLf1beXjyiJmIQHG8YH6E9h5Z3LowYo', 'visiteur');
INSERT INTO frais_forfait (code, libelle, montant) VALUES
('ETP', 'Forfait étape', 110.00),
('KM', 'Frais kilométriques', 0.62),
('NUI', 'Nuitée hôtel', 80.00),
('REP', 'Repas restaurant', 25.00);
INSERT INTO fiche_frais (id, id_visiteur, mois, etat, montant_valide) VALUES
(11, 7, '2026-09', 'VA', 389.20),
(12, 7, '2026-10', 'CR', NULL),
(13, 8, '2026-10', 'CL', NULL);
INSERT INTO ligne_frais_forfait (id_fiche, code_forfait, quantite) VALUES
(11, 'ETP', 1), (11, 'KM', 160), (11, 'NUI', 1), (11, 'REP', 3),
(12, 'ETP', 3), (12, 'KM', 420), (12, 'NUI', 2), (12, 'REP', 5),
(13, 'ETP', 2), (13, 'KM', 250), (13, 'NUI', 1), (13, 'REP', 4);
INSERT INTO frais_hors_forfait (id, id_fiche, date_frais, libelle, montant) VALUES
(57, 11, '2026-09-17', 'Taxi gare – hôtel', 25.00),
(58, 12, '2026-10-14', 'Repas client à Lyon', 23.50),
(59, 13, '2026-10-09', 'Parking gare de Grenoble', 12.00);
Les hachages correspondent aux mots de passe de démonstration Visiteur#2026 (lmartin, svincent) et Comptable#2026 (adurand) ; ils ont été produits par argon2.hash, comme au chapitre 9. Ces comptes n'existent que dans les bases de développement et de test, jamais en production.
Avec les tarifs des forfaits (110 € l'étape, 0,62 € le kilomètre, 80 € la nuitée, 25 € le repas), les trois fiches valent :
| Fiche | Visiteur | Mois | État | Forfaits | Hors forfait | Total |
|---|---|---|---|---|---|---|
| 11 | Lucas Martin | 2026-09 | VA (validée) | 364,20 € | 25,00 € (frais 57) | 389,20 € |
| 12 | Lucas Martin | 2026-10 | CR (en cours) | 875,40 € | 23,50 € (frais 58) | 898,90 € |
| 13 | Sarah Vincent | 2026-10 | CL (clôturée) | 555,00 € | 12,00 € (frais 59) | 567,00 € |
mysql -u root -p -e "CREATE DATABASE applifrais CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"
mysql -u root -p applifrais < sql/schema.sql
mysql -u root -p applifrais < sql/donnees.sql
Sous MariaDB, remplace l'interclassement utf8mb4_0900_ai_ci, propre à MySQL 8, par utf8mb4_unicode_ci.
Un compte MySQL pour l'API, avec le moins de droits possible
L'API ne se connecte jamais avec le compte root. Elle utilise un compte dédié, qui n'a que les droits dont elle a besoin : lire et modifier des lignes, pas créer ni supprimer des tables, ni gérer les comptes.
CREATE USER 'applifrais_api'@'%' IDENTIFIED BY 'un-mot-de-passe-long-et-aleatoire';
GRANT SELECT, INSERT, UPDATE, DELETE ON applifrais.* TO 'applifrais_api'@'%';
Si une faille permet un jour d'exécuter du SQL arbitraire, l'attaquant reste limité à ces droits : pas de DROP TABLE, pas d'accès aux autres bases. C'est le principe du moindre privilège, que l'E7 fait souvent appliquer à partir d'une matrice d'habilitations (GRANT et rôles MySQL). En production, remplace '%' (n'importe quelle machine) par l'adresse du serveur de l'API.
Installer mysql2 et créer un pool de connexions
npm install mysql2
// src/db.js
import mysql from 'mysql2/promise';
import { config } from './config.js';
// Un pool partagé par toute l'application : des connexions ouvertes une fois, réutilisées.
export const pool = mysql.createPool({
...config.db,
connectionLimit: 10,
decimalNumbers: true, // DECIMAL renvoyé en nombre JavaScript, pas en chaîne
dateStrings: true, // DATE et DATETIME renvoyés en chaînes, sans décalage de fuseau
});
Ouvrir une connexion MySQL coûte cher : connexion TCP, authentification, parfois négociation TLS. Un pool garde un petit nombre de connexions ouvertes et les prête aux requêtes qui arrivent. Quand toutes sont occupées, les demandes suivantes attendent leur tour dans une file, au lieu de saturer le serveur MySQL.
Deux options changent les types reçus, et méritent d'être comprises :
| Type MySQL | Par défaut, mysql2 renvoie | Avec l'option du cours | Pourquoi |
|---|---|---|---|
DECIMAL(8,2) | une chaîne "23.50" | decimalNumbers: true : le nombre 23.5 | Le front attend des nombres ; calcule ensuite en centimes |
DATE | un objet Date à minuit heure locale, qui peut s'afficher la veille une fois converti en UTC | dateStrings: true : la chaîne "2026-10-14" | Une date de frais n'a pas d'heure ni de fuseau |
DATETIME | un objet Date | la chaîne "2026-10-28 17:42:05" | Même raison ; à convertir si tu veux un format ISO |
INT, VARCHAR, ENUM | nombre, chaîne, chaîne | inchangé |
Le pool n'ouvre ses connexions qu'à la première requête : une erreur de mot de passe n'apparaît donc pas au démarrage mais au premier appel. Pour la détecter tout de suite, tu peux faire un await pool.query('SELECT 1') dans server.js avant app.listen.
Exécuter une requête et lire le résultat
pool.execute(sql, valeurs) envoie une requête et renvoie une promesse d'un tableau de deux éléments : le résultat et la description des colonnes. On le déstructure :
const [lignes] = await pool.execute(
'SELECT id, id_visiteur, mois, etat FROM fiche_frais WHERE id_visiteur = ? ORDER BY mois DESC',
[7],
);
console.log(lignes);
// [ { id: 12, id_visiteur: 7, mois: '2026-10', etat: 'CR' },
// { id: 11, id_visiteur: 7, mois: '2026-09', etat: 'VA' } ]
Pour un SELECT, le résultat est un tableau de lignes, chaque ligne étant un objet dont les clés sont les noms (ou les alias) des colonnes. Pour un INSERT, un UPDATE ou un DELETE, c'est un objet ResultSetHeader :
const [resultat] = await pool.execute(
'INSERT INTO frais_hors_forfait (id_fiche, date_frais, libelle, montant) VALUES (?, ?, ?, ?)',
[12, '2026-10-21', 'Péage A43', 12.2],
);
console.log(resultat.insertId); // l'identifiant AUTO_INCREMENT généré, par exemple 60
console.log(resultat.affectedRows); // le nombre de lignes concernées : 1
| Je veux… | J'écris |
|---|---|
| Toutes les lignes | const [lignes] = await pool.execute(sql, valeurs) |
Une seule ligne, ou null | const [lignes] = …; const fiche = lignes[0] ?? null |
| Une seule valeur | const [[{ total }]] = await pool.execute('SELECT COUNT(*) AS total …') |
| L'identifiant créé | const [resultat] = …; resultat.insertId |
| Savoir si une ligne a été modifiée | resultat.affectedRows === 1 |
Les requêtes préparées contre l'injection SQL
Les ? sont des marqueurs : les valeurs sont envoyées séparément du texte de la requête. Avec execute, mysql2 crée une requête préparée côté serveur : MySQL analyse d'abord la requête avec ses marqueurs, puis reçoit les valeurs, qui ne peuvent plus être interprétées comme du SQL, quel que soit leur contenu.
// FAILLE : la saisie est collée dans le texte SQL
const [lignes] = await pool.query(`SELECT * FROM utilisateur WHERE login = '${req.body.login}'`);
// CORRECT : requête préparée, la valeur voyage à part
const [lignes] = await pool.execute('SELECT id, login, role FROM utilisateur WHERE login = ?', [req.body.login]);
Dans la première version, une saisie contenant une apostrophe suivie de SQL termine la chaîne et fait exécuter ce SQL par le serveur : c'est l'injection SQL, qui permet de contourner une authentification, de lire des tables entières ou de modifier des données. Le principe, les conséquences et les protections sont détaillés dans le cours SQL, et l'attaque figure dans presque tous les sujets d'E7. La règle tient en une ligne :
Deux précisions :
pool.query(sql, valeurs)accepte aussi des?: mysql2 échappe alors les valeurs avant de les insérer dans le texte, côté client. C'est sûr pour les valeurs, maisexecute(vraie requête préparée) est le réflexe à prendre ;- les marqueurs ne remplacent que des valeurs, jamais un nom de colonne ou un mot-clé. Pour un tri choisi par le client (
?tri=mois), on vérifie le nom dans une liste blanche avant de l'écrire dans la requête :
const TRIS = { mois: 'mois', etat: 'etat', date: 'date_modification' };
const colonne = TRIS[req.valide.query.tri] ?? 'mois'; // seule une valeur prévue peut passer
const sql = `SELECT id, mois, etat FROM fiche_frais WHERE id_visiteur = ? ORDER BY ${colonne} DESC`;
L'option multipleStatements (plusieurs requêtes séparées par ; dans un seul appel) est désactivée par défaut dans mysql2 : laisse-la ainsi, elle aggrave les conséquences d'une injection.
Du résultat SQL à l'objet de l'API
Les colonnes sont en snake_case (id_visiteur), le JSON de l'API en camelCase (visiteurId), comme le veulent les conventions JavaScript et le contrat de l'API. Le dépôt fait la conversion, dans une petite fonction :
// src/depots/fiches.depot.js
import { pool } from '../db.js';
import { ErreurApi } from '../erreurs.js';
// Une ligne de la table (snake_case) devient un objet de l'API (camelCase).
function versFiche(ligne) {
return {
id: ligne.id,
visiteurId: ligne.id_visiteur,
mois: ligne.mois,
etat: ligne.etat,
montantValide: ligne.montant_valide,
dateModification: ligne.date_modification,
};
}
export async function trouverParId(id) {
const [lignes] = await pool.execute(
`SELECT id, id_visiteur, mois, etat, montant_valide, date_modification
FROM fiche_frais WHERE id = ?`,
[id],
);
return lignes.length === 1 ? versFiche(lignes[0]) : null;
}
Paginer et filtrer en SQL
La liste des fiches accepte des filtres facultatifs (mois, etat, visiteur) et une pagination. On construit la clause WHERE morceau par morceau, en gardant le texte SQL et les valeurs dans deux tableaux parallèles :
export async function lister({ visiteurId, mois, etat, page, limite }) {
const conditions = [];
const valeurs = [];
if (visiteurId) {
conditions.push('id_visiteur = ?');
valeurs.push(visiteurId);
}
if (mois) {
conditions.push('mois = ?');
valeurs.push(mois);
}
if (etat) {
conditions.push('etat = ?');
valeurs.push(etat);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const [[{ total }]] = await pool.execute(`SELECT COUNT(*) AS total FROM fiche_frais ${where}`, valeurs);
const [lignes] = await pool.execute(
`SELECT id, id_visiteur, mois, etat, montant_valide, date_modification
FROM fiche_frais ${where}
ORDER BY mois DESC, id DESC
LIMIT ? OFFSET ?`,
[...valeurs, String(limite), String((page - 1) * limite)],
);
return { donnees: lignes.map(versFiche), pagination: { page, limite, total, pages: Math.ceil(total / limite) } };
}
Seuls des morceaux écrits par toi ('id_visiteur = ?') entrent dans le texte SQL ; les valeurs vont toutes dans valeurs. Pour la page 3 avec 20 fiches par page, OFFSET vaut (3 − 1) × 20 = 40 : MySQL saute 40 lignes et renvoie les 20 suivantes (LIMIT et OFFSET).
Lire une fiche et ses lignes
Le détail d'une fiche réunit quatre tables. Trois requêtes simples, lancées l'une après l'autre, restent lisibles :
export async function trouverDetail(id) {
const fiche = await trouverParId(id);
if (!fiche) return null;
const [[visiteur]] = await pool.execute('SELECT id, nom, prenom FROM utilisateur WHERE id = ?', [fiche.visiteurId]);
const [lignesForfait] = await pool.execute(
`SELECT l.code_forfait AS code, f.libelle, l.quantite, f.montant AS montantUnitaire
FROM ligne_frais_forfait l
JOIN frais_forfait f ON f.code = l.code_forfait
WHERE l.id_fiche = ?
ORDER BY f.code`,
[id],
);
const [fraisHorsForfait] = await pool.execute(
`SELECT id, date_frais AS date, libelle, montant, justificatif IS NOT NULL AS justificatif
FROM frais_hors_forfait WHERE id_fiche = ? ORDER BY date_frais, id`,
[id],
);
const { visiteurId, ...reste } = fiche;
return {
...reste,
visiteur,
lignesForfait,
fraisHorsForfait: fraisHorsForfait.map((f) => ({ ...f, justificatif: Boolean(f.justificatif) })),
};
}
Les alias SQL (AS montantUnitaire) donnent directement les noms de l'API. justificatif IS NOT NULL vaut 1 ou 0 en MySQL : le dépôt le convertit en booléen, et le nom du fichier stocké n'est jamais exposé. Le service ajoute ensuite le total, calculé en centimes ; la réponse à GET /v1/fiches/12 :
{
"id": 12, "mois": "2026-10", "etat": "CR", "montantValide": null,
"dateModification": "2026-11-02 14:10:38",
"visiteur": { "id": 7, "nom": "Martin", "prenom": "Lucas" },
"lignesForfait": [
{ "code": "ETP", "libelle": "Forfait étape", "quantite": 3, "montantUnitaire": 110 },
{ "code": "KM", "libelle": "Frais kilométriques", "quantite": 420, "montantUnitaire": 0.62 },
{ "code": "NUI", "libelle": "Nuitée hôtel", "quantite": 2, "montantUnitaire": 80 },
{ "code": "REP", "libelle": "Repas restaurant", "quantite": 5, "montantUnitaire": 25 }
],
"fraisHorsForfait": [
{ "id": 58, "date": "2026-10-14", "libelle": "Repas client à Lyon", "montant": 23.5, "justificatif": false }
],
"total": 898.9
}
Le total : 330 + 260,40 + 160 + 125 (forfaits) + 23,50 (hors forfait) = 898,90 €.
Les transactions : tout ou rien
Créer une fiche, c'est insérer une ligne dans fiche_frais et ses quatre lignes de forfait à zéro. Si la deuxième insertion échoue, la fiche ne doit pas rester à moitié créée. On regroupe les deux dans une transaction : soit tout est validé (COMMIT), soit rien (ROLLBACK). Le principe est détaillé dans le cours SQL.
Une transaction se déroule sur une seule connexion : on l'emprunte au pool, et on la rend dans tous les cas.
// Crée la fiche et ses quatre lignes de forfait : tout ou rien.
export async function creer(visiteurId, mois) {
const connexion = await pool.getConnection();
try {
await connexion.beginTransaction();
const [resultat] = await connexion.execute(
'INSERT INTO fiche_frais (id_visiteur, mois) VALUES (?, ?)',
[visiteurId, mois],
);
await connexion.execute(
`INSERT INTO ligne_frais_forfait (id_fiche, code_forfait, quantite)
SELECT ?, code, 0 FROM frais_forfait`,
[resultat.insertId],
);
await connexion.commit();
return resultat.insertId;
} catch (erreur) {
await connexion.rollback();
if (erreur.code === 'ER_DUP_ENTRY') {
throw new ErreurApi(409, 'fiche-existante', 'Fiche déjà créée', `Une fiche existe déjà pour le mois ${mois}.`);
}
throw erreur;
} finally {
connexion.release();
}
}
Les trois blocs ont chacun leur rôle :
try: les requêtes, puiscommit()si tout a réussi ;catch:rollback()annule tout ce qui a été fait, puis l'erreur est transformée ou relancée ;finally:release()rend la connexion au pool, même en cas d'erreur. Une connexion jamais rendue est perdue ; après dix oublis, le pool est vide et l'API se fige en attendant une connexion qui ne revient pas.
Modifier sans conflit : la condition dans le UPDATE
Deux comptables valident la même fiche à la même seconde. Si le code lit l'état (CL), vérifie la transition, puis écrit, les deux lectures peuvent voir CL et les deux écritures passer. On ferme cette fenêtre en mettant la condition dans la requête de modification :
// Change l'état seulement si la fiche est encore dans l'état attendu.
export async function changerEtat(id, etatAttendu, nouvelEtat, montantValide = null) {
const [resultat] = await pool.execute(
`UPDATE fiche_frais
SET etat = ?, montant_valide = COALESCE(?, montant_valide)
WHERE id = ? AND etat = ?`,
[nouvelEtat, montantValide, id, etatAttendu],
);
return resultat.affectedRows === 1;
}
MySQL exécute chaque UPDATE de façon atomique : la seconde requête ne trouve plus de fiche à l'état CL et modifie zéro ligne. Le service teste le booléen renvoyé et répond 409 à la seconde. COALESCE(?, montant_valide) garde le montant existant quand on passe null (pour une clôture ou une mise en paiement).
Transformer les erreurs MySQL
Une erreur de mysql2 porte un code (texte), un errno (numéro MySQL), un sqlState et un sqlMessage. Les cas fréquents :
code (errno) | Signification | Réponse de l'API |
|---|---|---|
ER_DUP_ENTRY (1062) | Valeur déjà présente dans une colonne UNIQUE ou une clé primaire | 409 (fiche du mois déjà créée) |
ER_NO_REFERENCED_ROW_2 (1452) | Clé étrangère vers une ligne qui n'existe pas | 404 ou 422 selon le cas |
ER_ROW_IS_REFERENCED_2 (1451) | Suppression d'une ligne encore référencée | 409 |
ER_CHECK_CONSTRAINT_VIOLATED (3819) | Une contrainte CHECK refuse la valeur | 422, mais la validation aurait dû la refuser avant |
ECONNREFUSED, ETIMEDOUT | MySQL injoignable | 500 (ou 503), et une alerte dans le journal |
ER_ACCESS_DENIED_ERROR (1045) | Mauvais compte ou mot de passe | Erreur de configuration : vérifie le .env |
ER_BAD_FIELD_ERROR (1054), ER_NO_SUCH_TABLE (1146) | Colonne ou table inconnue | Un bogue dans ton SQL |
Le dépôt traduit les erreurs qu'il sait interpréter (le doublon) en ErreurApi, et relance les autres. Le gestionnaire d'erreurs du chapitre 4 en fait une 500 générique : le sqlMessage, qui contient des noms de tables et de contraintes, ne sort jamais vers le client.
Fermer le pool proprement
À l'arrêt du serveur, on attend la fin des requêtes en cours, puis on ferme les connexions du pool :
// src/server.js
function arreter(signal) {
logger.info({ signal }, 'Arrêt demandé');
serveur.close(async () => {
await pool.end();
process.exit(0);
});
}
process.on('SIGTERM', arreter);
process.on('SIGINT', arreter);
Docker et systemd envoient SIGTERM pour arrêter un service ; Ctrl+C envoie SIGINT. Le chapitre 14 y revient.
Exercices
Exercice 1 sur 5 Trouver la faille Réussi À revoir
export async function rechercher(req, res) {
const { libelle } = req.query;
const [lignes] = await pool.query(
"SELECT * FROM frais_hors_forfait WHERE libelle LIKE '%" + libelle + "%'",
);
res.json(lignes);
}
- Nomme la vulnérabilité et explique son principe en deux phrases.
- Réécris la requête de façon sûre, en gardant la recherche « contient ».
- Relève deux autres défauts de cette fonction.
Voir la réponseMasquer la réponse de l'exercice 1
- Une injection SQL : la saisie
libelleest concaténée dans le texte de la requête. Si elle contient une apostrophe, elle ferme la chaîne SQL et ce qui suit est exécuté comme du SQL, avec les droits du compte de l'API. - Le
%se met dans la valeur, pas dans le texte SQL :
const [lignes] = await pool.execute(
'SELECT id, date_frais AS date, libelle, montant FROM frais_hors_forfait WHERE libelle LIKE ?',
[`%${libelle}%`],
);
SELECT *expose toutes les colonnes, dont le nom du fichier justificatif stocké ; la fonction mélange SQL et HTTP (elle devrait appeler un dépôt) ; elle ne filtre pas par visiteur, donc chacun voit les frais de tout le monde (contrôle d'accès, chapitre 9) ; elle ne pagine pas ; et silibelleest absent, le gabarit produit la chaîne'%undefined%', une recherche absurde : le paramètre doit être validé.
Tu avais trouvé ?
Exercice 2 sur 5 Lire les résultats Réussi À revoir
Que valent a, b, c et d après ces lignes, avec le jeu de données du chapitre ?
const [a] = await pool.execute('SELECT id FROM fiche_frais WHERE id_visiteur = ? ORDER BY id', [7]);
const [[{ b }]] = await pool.execute('SELECT COUNT(*) AS b FROM fiche_frais WHERE etat = ?', ['CL']);
const [c] = await pool.execute('UPDATE fiche_frais SET etat = ? WHERE id = ? AND etat = ?', ['CL', 11, 'CR']);
const [d] = await pool.execute('SELECT id FROM fiche_frais WHERE id = ?', [99]);
Voir la réponseMasquer la réponse de l'exercice 2
a:[{ id: 11 }, { id: 12 }], les deux fiches de Lucas.b:1, seule la fiche 13 est clôturée.c: unResultSetHeaderavecaffectedRowsà0: la fiche 11 est à l'étatVA, la conditionetat = 'CR'ne trouve rien. Aucune erreur n'est levée : c'est au code de testeraffectedRows.d:[], un tableau vide. Pas d'erreur non plus : « aucune ligne » n'est pas une erreur SQL. Le dépôt renvoienull, le service lève l'erreur 404.
Tu avais trouvé ?
Exercice 3 sur 5 La connexion perdue Réussi À revoir
Cette fonction fonctionne en test, mais après une dizaine d'erreurs en production, toute l'API cesse de répondre. Explique pourquoi et corrige-la.
export async function enregistrerQuantites(id, quantites) {
const connexion = await pool.getConnection();
await connexion.beginTransaction();
for (const [code, quantite] of Object.entries(quantites)) {
await connexion.execute(
'UPDATE ligne_frais_forfait SET quantite = ? WHERE id_fiche = ? AND code_forfait = ?',
[quantite, id, code],
);
}
await connexion.commit();
connexion.release();
}
Voir la réponseMasquer la réponse de l'exercice 3
Si une requête échoue (une quantité négative refusée par le CHECK, par exemple), l'exception sort de la fonction avant release() : la connexion n'est jamais rendue au pool, et la transaction reste ouverte. Au bout de dix erreurs (la taille du pool), plus aucune connexion n'est disponible et toutes les requêtes attendent indéfiniment.
export async function enregistrerQuantites(id, quantites) {
const connexion = await pool.getConnection();
try {
await connexion.beginTransaction();
for (const [code, quantite] of Object.entries(quantites)) {
await connexion.execute(
'UPDATE ligne_frais_forfait SET quantite = ? WHERE id_fiche = ? AND code_forfait = ?',
[quantite, id, code],
);
}
await connexion.commit();
} catch (erreur) {
await connexion.rollback();
throw erreur;
} finally {
connexion.release();
}
}
Tu avais trouvé ?
Exercice 4 sur 5 Pagination Réussi À revoir
Une comptable affiche les fiches clôturées, 25 par page. Il y en a 63.
- Combien y a-t-il de pages ?
- Quelles valeurs reçoivent
LIMITetOFFSETpour la page 3, et combien de fiches contient-elle ? - Que renvoie l'API pour la page 4 ?
Voir la réponseMasquer la réponse de l'exercice 4
- 63 ÷ 25 = 2,52, arrondi au supérieur : 3 pages.
LIMIT 25 OFFSET 50((3 − 1) × 25 = 50). La page contient les fiches 51 à 63, soit 13 fiches.200avecdonneesvide etpagination={ page: 4, limite: 25, total: 63, pages: 3 }. Ce n'est pas une erreur : le client sait qu'il a dépassé la dernière page.
Tu avais trouvé ?
Exercice 5 sur 5 Écrire une fonction de dépôt Réussi À revoir
Écris supprimerFraisHorsForfait(id) dans le dépôt : elle supprime le frais et renvoie true si une ligne a été supprimée, false sinon. Puis écris la requête qui calcule, en SQL, le montant total des frais hors forfait de la fiche 12.
Voir la réponseMasquer la réponse de l'exercice 5
export async function supprimerFraisHorsForfait(id) {
const [resultat] = await pool.execute('DELETE FROM frais_hors_forfait WHERE id = ?', [id]);
return resultat.affectedRows === 1;
}
const [[{ total }]] = await pool.execute(
'SELECT COALESCE(SUM(montant), 0) AS total FROM frais_hors_forfait WHERE id_fiche = ?',
[12],
);
Avec le jeu de données, total vaut 23.5. COALESCE(…, 0) renvoie 0 au lieu de NULL pour une fiche sans frais hors forfait. Rappel : la vérification que le frais appartient bien au visiteur connecté se fait avant, dans le service.
Tu avais trouvé ?