Aller au contenu
Réussir mon BTS SIO

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

Par exemple

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.

base MySQLmodèles Prisma, objets JavaScriptfiche_fraisidid_visiteurclé étrangèremoisetatmontant_valideutilisateuridloginnom, prenomid_visiteur → idFicheFraisidvisiteurIdmoisetatmontantValidevisiteur→ UtilisateurUtilisateuridloginnom, prenomfiches→ FicheFrais[ ]@@map("fiche_frais")@map("id_visiteur")@@map("utilisateur")la clé étrangère devientdeux champs de relationprisma.ficheFrais.findMany({ where: { visiteurId: 7 } })→ SELECT id, id_visiteur, mois, … FROM fiche_frais WHERE id_visiteur = ?
Une table devient un modèle, une colonne un champ, une clé étrangère une relation navigable
Monde relationnelMonde objet (Prisma)
Table fiche_fraisModèle FicheFrais, utilisé par prisma.ficheFrais
Colonne id_visiteurChamp visiteurId
LigneObjet JavaScript
Clé étrangèreChamp de relation : fiche.visiteur, visiteur.fiches
SELECT … WHERE … ORDER BY … LIMITfindMany({ where, orderBy, take })
INSERT, UPDATE, DELETEcreate, update, delete

Avantages et limites

Ce qu'apporte un ORMCe qu'il coûte
Moins de code répétitif : plus de conversion ligne par ligneUne 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 brutLe SQL généré n'est pas toujours le plus efficace
Autocomplétion et contrôle des noms de champs dans l'éditeurLes requêtes complexes (agrégats, fenêtrage) restent plus simples en SQL
Migrations : l'historique des évolutions du schéma, versionné avec le codeLe 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

bash
npm install -D prisma@7 dotenv
npm install @prisma/client@7 @prisma/adapter-mariadb@7
PaquetRôle
prisma (développement)La ligne de commande : générer le client, introspection, migrations
@prisma/clientLe client utilisé par l'API à l'exécution
@prisma/adapter-mariadbL'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

ts
// 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 },
});
bash
# .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
// 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'appelle FicheFrais dans le code, la table fiche_frais dans la base. @map fait 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 accepte NULL ;
  • @db.VarChar(30), @db.Decimal(8, 2) : le type MySQL exact ;
  • @relation(fields: [visiteurId], references: [id]) : la clé étrangère. Les champs visiteur et fiches n'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émarcheCommandeQuand
Introspection : la base existe, Prisma la lit et écrit le schémanpx prisma db pullLa 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 SQLnpx prisma migrate dev --name ajout-commentaire en développement, npx prisma migrate deploy en productionPrisma 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

bash
npx prisma generate
text
✔ 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.

js
// 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

js
// 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 SQLExemple
whereWHERE{ etat: 'CL', mois: { startsWith: '2026-' } }
orderByORDER BY{ mois: 'desc' }
skip, takeOFFSET, LIMITskip: 40, take: 20
selectLa liste des colonnes{ id: true, mois: true }
includeLes 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

js
// 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 :

CodeSignificationRéponse de l'API
P2002Violation d'unicité (l'équivalent de ER_DUP_ENTRY)409
P2003Violation de clé étrangère422 ou 404
P2025update ou delete n'a trouvé aucune ligne404

Les transactions

Deux formes. Un tableau d'opérations indépendantes, exécutées ensemble :

js
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 :

js
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 :

js
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.

js
// 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 :

js
// 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 :

js
// 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

ORMApprocheÀ retenir
PrismaUn schéma dédié, un client généréLe plus répandu aujourd'hui, très bien documenté
SequelizeModèles définis en JavaScriptAncien et stable, encore présent dans beaucoup de projets
DrizzleSchéma en TypeScript, requêtes proches du SQLLéger, apprécié de ceux qui veulent rester près du SQL
TypeORMClasses décorées, proche d'Entity Framework ou de DoctrineUtilisé avec NestJS
KnexConstructeur 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.

  1. SELECT id, mois FROM fiche_frais WHERE etat = 'CL' ORDER BY mois LIMIT 10
  2. SELECT COUNT(*) FROM fiche_frais WHERE id_visiteur = 7
  3. UPDATE frais_hors_forfait SET libelle = 'Péage A43' WHERE id = 60
  4. DELETE FROM fiche_frais WHERE id = 14 AND etat = 'CR'
Voir la réponseMasquer la réponse de l'exercice 1
js
// 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

prisma
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
sql
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

js
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 :

js
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 :

js
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é ?

Chapitre terminé !

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

chapitres
–
de série
–
du cours
–
Chapitre suivant : Valider les données d'une API avec Zod, et téléverser un fichierRevoir le sommaire du cours