Accueil / Articles / Pluie fine ou Prisma ? Vérifiez le type de jointure et la requête SQL enregistrée avant de choisir.

Pluie fine ou Prisma ? Vérifiez le type de jointure et la requête SQL enregistrée avant de choisir.

Modélisez les mêmes tables d’utilisateurs et de factures dans Drizzle et Prisma, comparez les types de résultats des jointures ainsi que le SQL enregistré, et identifiez les mappages de pilote qui transforment les totaux en chaînes de caractères.

2461 mots

Les débats sur les ORM tournent généralement autour des classements de téléchargements et des slogans des conférences, mais la question qui se pose en production est bien plus simple : lorsque l’on relie un utilisateur à ses factures, quel type a total, et peut-on lire le SQL qui l’a généré ? Ce guide crée les mêmes deux tables dans Drizzle et Prisma, effectue une insertion et un joint dans chacun d’eux, puis compare les types TypeScript déduits, les requêtes enregistrées, les résultats des migrations ainsi que le comportement lors de l’exécution natif en TypeScript sous Node. Vous obtiendrez ainsi un laboratoire court et reproductible qui répond à la question relative aux ORM pour votre propre base de code, plutôt que de vous fier aux benchmarks d’autrui. Pour un cadre décisionnel plus complet qui prend également en compte le SQL brut, consultez comment choisir un niveau de base de données entre SQL brut, Prisma et Drizzle.

Ce pour quoi chaque outil est optimisé

Ces deux bibliothèques offrent des fonctionnalités différentes. Drizzle fournit du code de requête qui ressemble à du SQL écrit en TypeScript, sans processus d’engine de requête distinct et avec une conception adaptée aux environnements edge. Prisma propose un flux de travail basé sur le schéma, avec un fichier schema.prisma dédié et un client généré ; ses dernières versions transforment progressivement son engine de requête en passant de Rust à TypeScript. Cette transition était encore en cours au moment de la rédaction, il convient donc de consulter les notes de version actuelles de Prisma pour savoir quel engine est utilisé par votre version.

La popularité a aussi des effets ambivalents : Prisma reste en tête en termes d’installations, tandis que Drizzle domine les discussions sur la croissance. Rien de tout cela ne vous renseigne sur votre choix. Les deux critères utilisés ci-dessous sont délibérément restreints et pratiques : savoir si total est fourni sous forme de number, et si le SQL présent dans le journal est quelque chose que vous seriez à l’aise de coller dans psql en cas d’incident.

Le scénario concerne une petite application de facturation où la page /invoices doit afficher un total. Quelque chose dans l’architecture doit attribuer un type à ce total, et c’est là que commence la comparaison.

Mêmes deux tables, deux fois

Créez deux dossiers de projet distincts connectés à la même instance PostgreSQL, et attribuez à chacun son propre nom de schéma. Partager des tables entre deux ORM entraîne l’écriture double de lignes qui ressemblent à des données de performance mais sont en réalité des bugs.

Dans Drizzle, le schéma se trouve dans src/schema.ts sous forme de TypeScript ordinaire. Notez que les noms des colonnes sont déclarés explicitement en snake_case (user_id) tandis que la propriété est en camelCase (userId), et que la clé étrangère est une référence fonctionnelle à users.id:

import { integer, pgTable, uuid, varchar } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: varchar("email", { length: 255 }).notNull().unique(),
});

export const invoices = pgTable("invoices", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull().references(() => users.id),
  total: integer("total").notNull(),
});

Dans Prisma, le même modèle se trouve dans prisma/schema.prisma. La relation est déclarée des deux côtés : User possède un tableau invoices, et Invoice contient la valeur scalaire userId ainsi qu’un attribut @relation qui le relie à nouveau :

model User {
  id       String    @id @default(uuid())
  email    String    @unique
  invoices Invoice[]
}

model Invoice {
  id     String @id @default(uuid())
  userId String
  total  Int
  user   User   @relation(fields: [userId], references: [id])
}

Voici maintenant la requête importante : récupérer les factures d’un utilisateur par e-mail. Drizzle l’exprime via une jointure interne explicite accompagnée d’une clause where, tandis que Prisma demande l’utilisateur et indique « inclure les factures » :

// drizzle
const rows = await db
  .select()
  .from(invoices)
  .innerJoin(users, eq(invoices.userId, users.id))
  .where(eq(users.email, email));

// prisma
const user = await prisma.user.findUnique({
  where: { email },
  include: { invoices: true },
});

Les types de résultats reflètent ces deux modèles mentaux. Drizzle renvoie des lignes ayant la même structure que celle d’une jointure, avec une clé users et une clé invoices dans chaque ligne. Prisma, quant à lui, renvoie User & { invoices: Invoice[] }, un objet imbriqué. Les deux approches sont correctes. La structure de Drizzle correspond à celle du SQL, tandis que la structure de Prisma correspond à la page que vous êtes sur le point de rendre.

Lorsque le journalisation des requêtes est activée, la différence persiste. La sortie de Drizzle correspond à une jointure que le développeur peut lire directement. La sortie de Prisma est tout à fait utilisable, mais il s’agit de SQL généré que l’on ne souhaiterait pas modifier manuellement.

Les migrations se sont déroulées sans problème pour un schéma aussi petit. drizzle-kit generate a généré des fichiers SQL qui peuvent être commités ; prisma migrate a créé son propre historique de migrations qui peut également être commité. Aucun des deux outils n’a eu de difficultés avec deux tables, et un schéma de cette taille ne permet pas de mettre en évidence les cas de migration plus complexes, il ne faut donc tirer aucune conclusion à ce sujet.

Réaliser l’expérience en local

Installez chaque ensemble d’outils dans son propre dossier. Drizzle a besoin de l’ORM, d’un pilote (ici postgres) et de drizzle-kit pour les migrations ; Prisma a besoin de la CLI et du client, puis de prisma init pour créer le fichier de schéma :

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

pnpm add prisma @prisma/client
pnpm exec prisma init

Dans chaque dossier, insérez un utilisateur et deux factures, exécutez l’opération de jointure une fois, puis affichez le total de la première facture ainsi que son type à l’exécution. Notez les chemins d’accès différents : rows[0].invoices.total pour les lignes issues de la jointure avec Drizzle, contre user.invoices[0].total pour l’objet imbriqué de Prisma.

console.log(rows[0]?.invoices.total, typeof rows[0]?.invoices.total);
console.log(user?.invoices[0]?.total, typeof user?.invoices[0]?.total);

Si un outil indique string et un autre number, la cause est presque toujours la correspondance des types du pilote de base de données, et non la philosophie de l’ORM. Les pilotes PostgreSQL renvoient généralement les colonnes bigint et numeric sous forme de chaînes de caractères afin d’éviter toute perte de précision dans un nombre JavaScript, tandis que les colonnes integer simples reviennent sous forme de nombres. Un total affiché en chaîne est ce qui fait que "1200" + 50 devient silencieusement "120050" sur une facture. Notez le résultat de typeof avant de choisir une bibliothèque.

Exécution du fichier de requête avec TypeScript natif

Vérifiez ensuite si le code s’exécute directement sous le débogage des types intégré à Node, qui exécute les fichiers .ts en supprimant les annotations de type sans nécessiter de compilation séparée :

node src/query.ts

node_modules fonctionnait également lorsqu’il était appelé depuis un petit wrapper. Le problème est apparu avec un fichier qui importait les enums générés par Prisma au style ancien. Les déclarations enum de TypeScript ne sont pas simplement des types ; elles se compilent en objets à l’exécution, et le mode de suppression uniquement de Node ne peut pas les effacer, ce qui entraîne une erreur d’exécution. Ce n’est pas un défaut de Prisma, c’est la nature du code généré à l’exécution. Si votre version de Prisma utilise le moteur et le générateur plus récents basés sur TypeScript, examinez ce que prisma generate génère réellement avant de supposer que cela s’applique toujours, et identifiez précisément la version que vous avez testée.

Activation du journalisation des requêtes

Deviner le code SQL est la raison pour laquelle les incidents s’éternisent. Les deux bibliothèques peuvent enregistrer chaque requête : Drizzle via une option logger et Prisma via le tableau log côté client :

const db = drizzle(client, { logger: true });
const prisma = new PrismaClient({ log: ["query"] });

Placez les deux chaînes SQL enregistrées à côté des deux résultats de typeof total. Ces quatre lignes constituent l’ensemble des données nécessaires à ce laboratoire.

Où chaque outil vous coûte cher

Les compromis se manifestent en cinq domaines.

Types. L’option include de Prisma produit exactement la structure souhaitée par la page /invoices. La jointure de Drizzle produit exactement la structure nécessaire pour déboguer pourquoi un total double. Les deux outils sont utiles à des moments différents, ce qui justifie le choix d’un outil par base de données plutôt que d’en utiliser deux sur les mêmes tables.

Visibilité SQL. Lorsque les totaux semblent incorrects, le journal de Drizzle permet de résoudre rapidement le problème car la requête est lisible. Lorsqu’un nouveau membre de l’équipe doit ajouter un champ, le fichier de schéma de Prisma offre une solution plus rapide. Il s’agit de situations différentes avec des solutions optimales distinctes.

Étape de génération. Prisma exige l’exécution de prisma generate après chaque modification du schéma ; Drizzle, quant à lui, exige que le fichier schema.ts reste exact. Il est facile d’oublier cette étape de génération dans les environnements CI, et si le client est à une version en retard par rapport au schéma, cela entraîne des erreurs déroutantes. Faites en sorte que les tests CI échouent si la génération est omise.

Environnements d’exécution Edge. La compatibilité de Drizzle avec les environnements Edge constitue un véritable atout, mais elle n’a d’importance que si vous déployez votre application dans un tel environnement. Un processus Node exécuté à côté de PostgreSQL sur un VPS n’en tire aucun avantage, donc ne laissez pas cet argument influencer le choix d’une application hébergée sur serveur.

Limites des bundles. Aucun ORM ne doit se trouver dans un composant client. Si l’un d’eux est importé dans un module "use client", comme pour un filtre de tableau interactif, la limite du côté client est définie trop haut et un pilote de base de données est envoyé au navigateur. L’article sur comment dessiner correctement la limite use client explique comment résoudre ce problème.

Facture détaillée

Décomposition plus poussée des coûts :

  • Délai. Le problème avec Drizzle provenait de la forme résultante d’une jointure : rows[0].invoices.total ou rows[0].total, selon la manière dont la requête avait été écrite. Avec Prisma, le problème résidait dans le fait de devoir régénérer les données après chaque modification du schéma.
  • Que ce soit lors de l’insertion, du joint ou du retour, deux factures sont toujours générées. Deux tables ne permettront jamais de déterminer un gagnant.
  • Les enums générés par Prisma sont des valeurs en temps de exécution. L’exécution de ces fichiers avec suppression brute des types Node échoue ; il faut soit compiler ce paquet, soit éviter d’exécuter directement les sources générées.
  • Le client Prisma est un produit disposant de son propre générateur et moteur ; les tables Drizzle ne sont que du TypeScript pur. Quitter l’un ou l’autre après un an signifie réécrire la couche de requêtes, et non simplement modifier une configuration. Incluez cela dans le RFC avant que quiconque n’écrive « nous pourrons toujours changer plus tard ».
  • Choix à faire, et ce qu’il ne faut pas faire

    Choisissez Drizzle lorsque vous souhaitez que le SQL soit visible lors des revues de code et que l’équipe pense déjà en termes de jointures. Conservez le schéma dans schema.ts et assurez-vous qu’un membre de l’équipe sache lire innerJoin.

    Choisissez Prisma lorsque les habitudes de l’équipe sont basées sur schema.prisma et include. Prévoyez un temps pour l’étape de génération dans le CI et faites échouer le pipeline si elle n’a pas été exécutée.

    Évitez ces pratiques quel que soit votre choix :

    • Déployer les deux ORM sur les mêmes tables de production « pour comparer ». C’est ainsi que des montants peuvent être enregistrés deux fois, ce qui oblige quelqu’un à passer une journée à concilier les factures avec les données bancaires.
    • Prendre une décision en fonction des téléchargements hebdomadaires. Choisissez plutôt en fonction du type de résultat de jointure que vous pouvez lire rapidement sous pression.
  • Importer le client de base de données dans une action serveur ainsi que dans un composant client pour plus de commodité. C’est précisément cette commodité qui conduit à ce qu’une île client finisse par inclure un pilote.
  • Un total en chaîne qui était en réalité un problème de pilote

    Une défaillance réelle montre pourquoi la vérification de typeof est importante. Une équipe modélise les mêmes deux tables dans les deux outils, associe un utilisateur à deux factures et enregistre le type de total : number dans les deux cas. Une semaine plus tard, un pilote différent est introduit qui mappe une colonne numérique en string, et un rapport commence alors à concaténer les valeurs au lieu de les additionner, doublant ainsi les chiffres affichés.

    La solution tentante consiste à entourer Number(total) autour de chaque point d’appel. Cela cache le problème plutôt que de le résoudre, et la colonne suivante présentant le même problème échappera à la détection. La solution fiable consiste à enregistrer l’SQL et le type de résultat une fois par bibliothèque, à fixer la version du pilote, et à s’assurer que deux ORM ne écrivent jamais dans les mêmes tables de production.

    La gestion des enums suit la même logique : les enums générés sont du code à exécution, il faut donc compiler ce package et lancer le résultat situé dans dist/ au lieu d’exécuter directement le TypeScript généré. Quelle que soit la bibliothèque choisie, il convient d’indiquer cette décision ainsi que les raisons dans le README, afin que personne n’ajoute l’autre bibliothèque par simple curiosité.

    Enregistrement de l’environnement avant comparaison

    De tels résultats n’ont de sens que par rapport aux versions qui les ont générés. L’environnement de référence ici était Node 24, TypeScript 7 et Next.js 16.3, exécutant une petite application de factures composée de quatre routes. Conservez un fichier notes/lab.md dans le répertoire de dépôt et commencez par enregistrer les trois versions :

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Écrivez-les en haut de la note. Si une version majeure diffère de celle présumée dans le guide, arrêtez-vous et mettez les choses en ordre avant d’exécuter quoi que ce soit d’autre, car des commandes ultérieures pourraient vous induire en erreur de manière plus subtile.

    Puis lancez le serveur de développement et explorez les routes :

    pnpm exec next dev
    

    Visitez /, /invoices, /invoices/1, /settings, puis à nouveau /invoices, en activant l’option « Conserver le journal » dans les outils de développement. Enregistrez la boîte de filtre ainsi que l’URL ; cette combinaison constitue souvent les preuves dont vous aurez besoin plus tard.

    Ensuite, exécutez le vérificateur de types et affichez son code de sortie :

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    • Utiliser les deux outils de migration sur la même base de données pour les comparer, ce qui entraîne deux historiques de migration et une table portant deux noms différents. La seule solution propre est un restauration à partir d’une sauvegarde.
    • Importer les énumérations Prisma générées dans un fichier qui est ensuite soumis au nettoyage des types de Node, ce qui échoue pour les raisons mentionnées ci-dessus. Il vaut mieux compiler ce paquet directement.
    • Juger les bibliothèques en se basant sur leur nombre de téléchargements, ce qui n’a aucun impact sur le type de jointure utilisée.

    Liste de contrôle avant d’ajouter un ORM

    • Un ORM par base de données.
    • La requête SQL correspondant à la jointure principale doit être enregistrée au moins une fois.
    • La valeur de typeof en temps de exécution des colonnes représentant de l’argent doit être enregistrée au moins une fois, ainsi qu’après chaque mise à jour du pilote.
    • Le résultat généré contenant les énumérations doit être compilé, et jamais exécuté via un nettoyage brut des types.
    • Le fichier README doit indiquer le nom de la bibliothèque choisie ainsi que les raisons de ce choix.

    Conclusion

    Deux tables ne constituent pas un schéma de production, et ce laboratoire n’a pas effectué de tests sur des milliers d’opérations de jointure ni déployé quoi que ce soit dans un environnement en temps réel. Ce qu’il montre, cependant, c’est que les différences clés sont concrètes et pouvant être vérifiées en une après-midi : la forme du résultat de la jointure, la lisibilité du SQL enregistré, le coût de l’étape de génération, ainsi que le fait que le driver vous fournisse des nombres ou des chaînes de caractères. Choisissez une bibliothèque par base de données, notez pourquoi, et faites du test typeof total une routine après chaque mise à jour de driver. Laisser deux outils de migration gérer une seule base de données se termine inévitablement par une restauration, donc gardez cette expérience bien éloignée de tout système qui traite de l’argent réel.