Chapitre 729 minIntermédiaire
ORM Prisma avec Node.js et MySQL
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer ce qu'est un outil de correspondance objet-relationnel ;
- décrire une base existante dans un schéma Prisma et générer le client ;
- lire et écrire des données avec relations et transactions ;
- choisir entre SQL écrit à la main et ORM selon le besoin.
4 exercices corrigés1 schéma
Un ORM (Object-Relational Mapping, ou outil de correspondance objet-relationnel) est une bibliothèque qui fait correspondre les tables d'une base relationnelle aux objets du programme : tu manipules des objets JavaScript, l'ORM écrit et exécute le SQL. Prisma est l'ORM le plus utilisé avec Node.js. Ce chapitre explique ce qu'apporte un ORM et ce qu'il coûte, puis réécrit le dépôt des fiches d'AppliFrais avec Prisma 7, sans changer une ligne du service ni des contrôleurs.
C'est quoi un ORM ?
Au chapitre 6, tu as écrit le SQL à la main, puis converti chaque ligne en objet (id_visiteur devient visiteurId). Un ORM automatise ce va-et-vient à partir d'une description des tables.
| Monde relationnel | Monde objet (Prisma) |
|---|---|
Table fiche_frais | Modèle FicheFrais, utilisé par prisma.ficheFrais |
Colonne id_visiteur | Champ visiteurId |
| Ligne | Objet JavaScript |
| Clé étrangère | Champ de relation : fiche.visiteur, visiteur.fiches |
SELECT … WHERE … ORDER BY … LIMIT | findMany({ where, orderBy, take }) |
INSERT, UPDATE, DELETE | create, update, delete |
Avantages et limites
| Ce qu'apporte un ORM | Ce qu'il coûte |
|---|---|
| Moins de code répétitif : plus de conversion ligne par ligne | Une couche de plus à apprendre, avec sa syntaxe et ses erreurs |
| Requêtes paramétrées par défaut : pas d'injection tant qu'on n'écrit pas de SQL brut | Le SQL généré n'est pas toujours le plus efficace |
| Autocomplétion et contrôle des noms de champs dans l'éditeur | Les requêtes complexes (agrégats, fenêtrage) restent plus simples en SQL |
| Migrations : l'historique des évolutions du schéma, versionné avec le code | Le risque d'oublier qu'il y a une base derrière : problème N+1, index absents |
| Un même code pour MySQL, PostgreSQL ou SQLite (presque) | Une dépendance importante, avec ses changements de version majeure |
Un ORM ne dispense pas de connaître SQL : les jointures, les index, les transactions et les droits restent ta responsabilité, et tu devras lire le SQL qu'il génère pour comprendre une lenteur. À l'E6, les deux choix se défendent ; ce qui compte, c'est de savoir expliquer le tien.
Prisma 7 : installer la bonne version
npm install -D prisma@7 dotenv
npm install @prisma/client@7 @prisma/adapter-mariadb@7
| Paquet | Rôle |
|---|---|
prisma (développement) | La ligne de commande : générer le client, introspection, migrations |
@prisma/client | Le client utilisé par l'API à l'exécution |
@prisma/adapter-mariadb | L'adaptateur qui relie le client au pilote mariadb, utilisé pour MySQL et MariaDB |
dotenv (développement) | Charge le .env pour la ligne de commande Prisma |
Prisma 7 a changé d'architecture par rapport aux versions 5 et 6 que montrent beaucoup de tutoriels : le client n'utilise plus de moteur binaire écrit en Rust, il est généré en TypeScript dans ton projet, la connexion passe par un adaptateur de pilote, et l'adresse de la base se règle dans un fichier prisma.config.ts. Si un tutoriel écrit url = env("DATABASE_URL") dans le bloc datasource du schéma, ou crée le client sans adaptateur, il a été écrit pour une version antérieure.
La configuration et le schéma Prisma
// prisma.config.ts, à la racine du projet
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL },
});
# .env : la même base, sous forme d'adresse pour la ligne de commande Prisma
DATABASE_URL="mysql://applifrais_admin:mot-de-passe@localhost:3306/applifrais"
Le schéma décrit les modèles. Celui d'AppliFrais, pour la base créée au chapitre 6 :
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "mysql"
}
enum Role {
visiteur
comptable
}
enum Etat {
CR
CL
VA
MP
}
model Utilisateur {
id Int @id @default(autoincrement())
login String @unique @db.VarChar(30)
nom String @db.VarChar(50)
prenom String @db.VarChar(50)
motDePasse String @map("mot_de_passe") @db.VarChar(255)
role Role @default(visiteur)
fiches FicheFrais[]
@@map("utilisateur")
}
model FicheFrais {
id Int @id @default(autoincrement())
visiteurId Int @map("id_visiteur")
mois String @db.Char(7)
etat Etat @default(CR)
montantValide Decimal? @map("montant_valide") @db.Decimal(8, 2)
dateModification DateTime @default(now()) @updatedAt @map("date_modification") @db.DateTime(0)
visiteur Utilisateur @relation(fields: [visiteurId], references: [id])
lignesForfait LigneFraisForfait[]
fraisHorsForfait FraisHorsForfait[]
@@unique([visiteurId, mois])
@@map("fiche_frais")
}
model FraisForfait {
code String @id @db.Char(3)
libelle String @db.VarChar(50)
montant Decimal @db.Decimal(6, 2)
lignes LigneFraisForfait[]
@@map("frais_forfait")
}
model LigneFraisForfait {
ficheId Int @map("id_fiche")
codeForfait String @map("code_forfait") @db.Char(3)
quantite Int @default(0)
fiche FicheFrais @relation(fields: [ficheId], references: [id], onDelete: Cascade)
forfait FraisForfait @relation(fields: [codeForfait], references: [code])
@@id([ficheId, codeForfait])
@@map("ligne_frais_forfait")
}
model FraisHorsForfait {
id Int @id @default(autoincrement())
ficheId Int @map("id_fiche")
date DateTime @map("date_frais") @db.Date
libelle String @db.VarChar(100)
montant Decimal @db.Decimal(8, 2)
justificatif String? @db.VarChar(255)
fiche FicheFrais @relation(fields: [ficheId], references: [id], onDelete: Cascade)
@@map("frais_hors_forfait")
}
Comment lire ce schéma :
model FicheFrais { … @@map("fiche_frais") }: le modèle s'appelleFicheFraisdans le code, la tablefiche_fraisdans la base.@mapfait de même pour une colonne ;@id,@default(autoincrement()),@unique,@@unique([…]),@@id([…]): clé primaire, auto-incrément, contraintes d'unicité, clé primaire composée ;?après le type : la colonne accepteNULL;@db.VarChar(30),@db.Decimal(8, 2): le type MySQL exact ;@relation(fields: [visiteurId], references: [id]): la clé étrangère. Les champsvisiteuretfichesn'existent pas en base : ce sont les relations, navigables dans les deux sens ;@updatedAt: Prisma met la date à jour à chaque modification faite par Prisma.
Base existante ou migrations ?
Deux façons de travailler, à ne pas mélanger :
| Démarche | Commande | Quand |
|---|---|---|
| Introspection : la base existe, Prisma la lit et écrit le schéma | npx prisma db pull | La base a été créée en SQL (c'est le cas ici), ou elle est partagée avec une autre application |
| Migrations : tu modifies le schéma, Prisma écrit et applique le SQL | npx prisma migrate dev --name ajout-commentaire en développement, npx prisma migrate deploy en production | Prisma est maître de la structure de la base |
db pull produit des modèles nommés comme les tables (model fiche_frais, champs id_visiteur) : on les renomme ensuite avec @map et @@map, comme ci-dessus. Avec les migrations, chaque évolution produit un fichier SQL dans prisma/migrations/, versionné avec le code : la base de production suit exactement les mêmes étapes que la tienne. Ce compte de migration a besoin de droits de structure (CREATE, ALTER) : ce n'est pas le compte applifrais_api aux droits limités, mais un compte d'administration réservé au déploiement.
Générer et créer le client
npx prisma generate
✔ Generated Prisma Client (7.10.1) to ./src/generated/prisma
Le client est généré dans ton projet, en TypeScript, dans src/generated/prisma/ : ajoute ce dossier au .gitignore et relance generate après chaque modification du schéma, et dans la CI. Node.js 24 sait exécuter directement ces fichiers .ts en retirant les annotations de type : un fichier JavaScript de l'API peut les importer sans étape de compilation.
// src/prisma.js
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from './generated/prisma/client.ts';
import { config } from './config.js';
const adapter = new PrismaMariaDb({ ...config.db, connectionLimit: 10 });
export const prisma = new PrismaClient({ adapter });
Comme le pool de mysql2, le client Prisma se crée une seule fois pour toute l'application, et se ferme à l'arrêt avec await prisma.$disconnect().
Lire des données
// Les fiches clôturées de Lucas, les plus récentes d'abord, 20 par page
const fiches = await prisma.ficheFrais.findMany({
where: { visiteurId: 7, etat: 'CL' },
orderBy: [{ mois: 'desc' }, { id: 'desc' }],
skip: 0,
take: 20,
});
// Une fiche par sa clé primaire, ou null
const fiche = await prisma.ficheFrais.findUnique({ where: { id: 12 } });
// Une fiche avec son visiteur (trois champs seulement) et ses lignes
const detail = await prisma.ficheFrais.findUnique({
where: { id: 12 },
include: {
visiteur: { select: { id: true, nom: true, prenom: true } },
lignesForfait: { include: { forfait: true } },
},
});
// Compter
const nb = await prisma.ficheFrais.count({ where: { etat: 'CL' } });
| Option | Équivalent SQL | Exemple |
|---|---|---|
where | WHERE | { etat: 'CL', mois: { startsWith: '2026-' } } |
orderBy | ORDER BY | { mois: 'desc' } |
skip, take | OFFSET, LIMIT | skip: 40, take: 20 |
select | La liste des colonnes | { id: true, mois: true } |
include | Les relations à charger, par jointure ou requête supplémentaire | { visiteur: true } |
Dans where, une valeur undefined est ignorée : { visiteurId: 7, etat: undefined } filtre seulement sur le visiteur. Pratique pour des filtres facultatifs ; dangereux si une variable est undefined par erreur, car le filtre disparaît sans bruit.
Créer, modifier, supprimer
// Créer une fiche et ses quatre lignes en une seule opération (écriture imbriquée)
const forfaits = await prisma.fraisForfait.findMany({ select: { code: true } });
const nouvelle = await prisma.ficheFrais.create({
data: {
visiteurId: 7,
mois: '2026-11',
lignesForfait: { create: forfaits.map(({ code }) => ({ codeForfait: code, quantite: 0 })) },
},
});
// Modifier une ligne précise (clé primaire composée)
await prisma.ligneFraisForfait.update({
where: { ficheId_codeForfait: { ficheId: 12, codeForfait: 'KM' } },
data: { quantite: 420 },
});
// Modifier sous condition : renvoie le nombre de lignes modifiées
const { count } = await prisma.ficheFrais.updateMany({
where: { id: 12, etat: 'CR' },
data: { etat: 'CL' },
});
// Supprimer
await prisma.fraisHorsForfait.delete({ where: { id: 58 } });
L'écriture imbriquée de create insère la fiche et ses lignes dans une même transaction : le tout-ou-rien du chapitre 6, sans écrire beginTransaction. updateMany avec la condition sur l'état reproduit la mise à jour protégée contre les accès concurrents.
Les erreurs connues de Prisma sont des PrismaClientKnownRequestError avec un code :
| Code | Signification | Réponse de l'API |
|---|---|---|
P2002 | Violation d'unicité (l'équivalent de ER_DUP_ENTRY) | 409 |
P2003 | Violation de clé étrangère | 422 ou 404 |
P2025 | update ou delete n'a trouvé aucune ligne | 404 |
Les transactions
Deux formes. Un tableau d'opérations indépendantes, exécutées ensemble :
const [total, fiches] = await prisma.$transaction([
prisma.ficheFrais.count({ where }),
prisma.ficheFrais.findMany({ where, skip, take }),
]);
Une fonction, quand une opération dépend du résultat d'une autre. Tout ce qui passe par tx fait partie de la transaction ; une exception annule tout :
await prisma.$transaction(async (tx) => {
const fiche = await tx.ficheFrais.findUnique({ where: { id } });
if (fiche?.etat !== 'CL') {
throw new ErreurApi(409, 'transition-interdite', "Changement d'état interdit", "La fiche n'est pas clôturée.");
}
return tx.ficheFrais.update({ where: { id }, data: { etat: 'VA', montantValide } });
});
Décimaux et dates
Prisma renvoie les colonnes DECIMAL sous forme d'objets Prisma.Decimal, qui calculent sans erreur d'arrondi (montant.times(3)), et qui deviennent des chaînes en JSON : "110", pas 110. Les dates deviennent des objets Date. Convertis-les dans le dépôt pour garder le même contrat d'API qu'avec mysql2 :
montantUnitaire: Number(ligne.forfait.montant), // Decimal → nombre
date: frais.date.toISOString().slice(0, 10), // Date → "2026-10-14"
Le dépôt des fiches avec Prisma
Le dépôt Prisma expose les mêmes fonctions, avec les mêmes paramètres et les mêmes formes de résultat, que le dépôt mysql2. Le service n'en sait rien : on change une ligne d'import dans fiches.service.js, et tout le reste fonctionne.
// src/depots/fiches.depot.prisma.js (extrait)
import { Prisma } from '../generated/prisma/client.ts';
import { prisma } from '../prisma.js';
import { ErreurApi } from '../erreurs.js';
function versFiche(fiche) {
return {
id: fiche.id,
visiteurId: fiche.visiteurId,
mois: fiche.mois,
etat: fiche.etat,
montantValide: fiche.montantValide === null ? null : Number(fiche.montantValide),
dateModification: fiche.dateModification.toISOString(),
};
}
export async function lister({ visiteurId, mois, etat, page, limite }) {
const where = { visiteurId, mois, etat }; // une condition undefined est ignorée
const [total, fiches] = await prisma.$transaction([
prisma.ficheFrais.count({ where }),
prisma.ficheFrais.findMany({
where,
orderBy: [{ mois: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limite,
take: limite,
}),
]);
return { donnees: fiches.map(versFiche), pagination: { page, limite, total, pages: Math.ceil(total / limite) } };
}
export async function creer(visiteurId, mois) {
const forfaits = await prisma.fraisForfait.findMany({ select: { code: true } });
try {
const fiche = await prisma.ficheFrais.create({
data: {
visiteurId,
mois,
lignesForfait: { create: forfaits.map(({ code }) => ({ codeForfait: code, quantite: 0 })) },
},
});
return fiche.id;
} catch (erreur) {
if (erreur instanceof Prisma.PrismaClientKnownRequestError && erreur.code === 'P2002') {
throw new ErreurApi(409, 'fiche-existante', 'Fiche déjà créée', `Une fiche existe déjà pour le mois ${mois}.`);
}
throw erreur;
}
}
export async function changerEtat(id, etatAttendu, nouvelEtat, montantValide = null) {
const { count } = await prisma.ficheFrais.updateMany({
where: { id, etat: etatAttendu },
data: { etat: nouvelEtat, ...(montantValide !== null && { montantValide }) },
});
return count === 1;
}
Compare avec la version mysql2 : la fonction lister n'a plus besoin de construire la clause WHERE à la main, et creer n'a plus de transaction explicite. En échange, il faut connaître l'API de Prisma et convertir les décimaux.
Les pièges : N+1 et SQL brut
Le problème N+1. Charger une liste, puis accéder à une relation dans une boucle, déclenche une requête par élément :
// N+1 : 1 requête pour les fiches, puis 1 par fiche
const fiches = await prisma.ficheFrais.findMany();
for (const fiche of fiches) {
fiche.lignes = await prisma.ligneFraisForfait.findMany({ where: { ficheId: fiche.id } });
}
// Chargement anticipé : Prisma regroupe les lectures
const fichesAvecLignes = await prisma.ficheFrais.findMany({ include: { lignesForfait: true } });
Le SQL brut. Pour une requête que l'API de Prisma exprime mal (un agrégat complexe, une fonction propre à MySQL), Prisma accepte du SQL :
// Sûr : gabarit étiqueté, ${7} devient un paramètre de requête préparée
const lignes = await prisma.$queryRaw`SELECT COUNT(*) AS n FROM fiche_frais WHERE id_visiteur = ${7}`;
$queryRaw avec des accents graves (un gabarit étiqueté) transforme chaque ${…} en paramètre : c'est protégé. Sa variante $queryRawUnsafe(chaine) reçoit une chaîne déjà construite : si tu y concatènes une saisie, l'injection SQL revient. Les résultats de COUNT arrivent en BigInt (1n), à convertir avec Number(…).
Prisma Studio (npx prisma studio) ouvre une interface web pour parcourir et modifier les données : pratique en développement, jamais sur une base de production.
Prisma, Sequelize, Drizzle, TypeORM
| ORM | Approche | À retenir |
|---|---|---|
| Prisma | Un schéma dédié, un client généré | Le plus répandu aujourd'hui, très bien documenté |
| Sequelize | Modèles définis en JavaScript | Ancien et stable, encore présent dans beaucoup de projets |
| Drizzle | Schéma en TypeScript, requêtes proches du SQL | Léger, apprécié de ceux qui veulent rester près du SQL |
| TypeORM | Classes décorées, proche d'Entity Framework ou de Doctrine | Utilisé avec NestJS |
| Knex | Constructeur de requêtes, pas un ORM complet | Écrire du SQL avec des fonctions, gérer des migrations |
En C#, l'équivalent est Entity Framework Core ; en PHP, Doctrine ou Eloquent. Les concepts (modèle, relation, migration, chargement anticipé, N+1) sont les mêmes partout : ce que tu apprends ici se transpose.
Exercices
Exercice 1 sur 4 Traduire en Prisma Réussi À revoir
Écris l'appel Prisma équivalent à chaque requête SQL.
SELECT id, mois FROM fiche_frais WHERE etat = 'CL' ORDER BY mois LIMIT 10SELECT COUNT(*) FROM fiche_frais WHERE id_visiteur = 7UPDATE frais_hors_forfait SET libelle = 'Péage A43' WHERE id = 60DELETE FROM fiche_frais WHERE id = 14 AND etat = 'CR'
Voir la réponseMasquer la réponse de l'exercice 1
// 1.
await prisma.ficheFrais.findMany({
where: { etat: 'CL' },
select: { id: true, mois: true },
orderBy: { mois: 'asc' },
take: 10,
});
// 2.
await prisma.ficheFrais.count({ where: { visiteurId: 7 } });
// 3.
await prisma.fraisHorsForfait.update({ where: { id: 60 }, data: { libelle: 'Péage A43' } });
// 4.
await prisma.ficheFrais.deleteMany({ where: { id: 14, etat: 'CR' } });
Pour le 4, delete n'accepte qu'un critère unique : avec une condition supplémentaire sur l'état, on utilise deleteMany, qui renvoie { count }. count vaut 0 si la fiche n'existe pas ou n'est plus en cours.
Tu avais trouvé ?
Exercice 2 sur 4 Lire un modèle Réussi À revoir
model Pret {
id Int @id @default(autoincrement())
materielId Int @map("id_materiel")
emprunteur String @db.VarChar(80)
debut DateTime @db.Date
retour DateTime? @db.Date
materiel Materiel @relation(fields: [materielId], references: [id])
@@map("pret")
}
Écris le CREATE TABLE MySQL correspondant. Que signifie le ? de retour ? Le champ materiel est-il une colonne ?
Voir la réponseMasquer la réponse de l'exercice 2
CREATE TABLE pret (
id INT AUTO_INCREMENT PRIMARY KEY,
id_materiel INT NOT NULL,
emprunteur VARCHAR(80) NOT NULL,
debut DATE NOT NULL,
retour DATE NULL,
FOREIGN KEY (id_materiel) REFERENCES materiel (id)
);
Le ? rend le champ facultatif : la colonne accepte NULL (un prêt en cours n'a pas de date de retour). materiel n'est pas une colonne : c'est le champ de relation, qui permet d'écrire include: { materiel: true }. La colonne, c'est id_materiel, déclarée par materielId et @relation(fields: [materielId] …).
Tu avais trouvé ?
Exercice 3 sur 4 Trouver les défauts Réussi À revoir
export async function listerAvecVisiteurs() {
const fiches = await prisma.ficheFrais.findMany({ where: { etat: 'CL' } });
for (const fiche of fiches) {
fiche.visiteur = await prisma.utilisateur.findUnique({ where: { id: fiche.visiteurId } });
}
return fiches;
}
export async function rechercher(texte) {
return prisma.$queryRawUnsafe(`SELECT * FROM frais_hors_forfait WHERE libelle LIKE '%${texte}%'`);
}
Voir la réponseMasquer la réponse de l'exercice 3
Première fonction. Problème N+1 : une requête par fiche. Et chaque visiteur est chargé en entier, hachage du mot de passe compris, puis renvoyé. Correction :
return prisma.ficheFrais.findMany({
where: { etat: 'CL' },
include: { visiteur: { select: { id: true, nom: true, prenom: true } } },
});
Deuxième fonction. $queryRawUnsafe avec une saisie concaténée : injection SQL. Utilise l'API de Prisma, qui paramètre la valeur :
return prisma.fraisHorsForfait.findMany({
where: { libelle: { contains: texte } },
select: { id: true, date: true, libelle: true, montant: true },
});
Tu avais trouvé ?
Exercice 4 sur 4 ORM ou SQL ? Réussi À revoir
Pour ta réalisation d'E6, un membre du jury te demande pourquoi tu as choisi Prisma plutôt que d'écrire le SQL, ou l'inverse. Rédige une réponse de quatre à six phrases pour chacun des deux choix.
Voir la réponseMasquer la réponse de l'exercice 4
Si tu as choisi Prisma : « Prisma décrit la base dans un schéma unique, versionné avec le code, et ses migrations rejouent les évolutions de la structure à l'identique sur chaque environnement. Les requêtes sont paramétrées par défaut, ce qui écarte l'injection SQL tant que je n'écris pas de SQL brut. Le client généré vérifie les noms de champs et me fait gagner du temps sur le code répétitif. Je garde la main sur le SQL : je relis les requêtes générées en développement et j'utilise include pour éviter le problème N+1. »
Si tu as écrit le SQL avec mysql2 : « J'ai voulu maîtriser chaque requête envoyée à MySQL, ce qui facilite l'optimisation et le diagnostic. Toutes les requêtes sont préparées, avec des marqueurs, ce qui empêche l'injection. L'accès aux données est isolé dans des dépôts : remplacer mysql2 par un ORM ne toucherait que ces fichiers. Le schéma est créé par un script SQL versionné, et les évolutions par des scripts numérotés. »
Dans les deux cas, montre que tu connais les forces et les limites de l'autre option.
Tu avais trouvé ?