Startseite / Artikel / Nieselregen oder Prisma? Überprüfen Sie den Verbindungstyp und die protokollierte SQL-Abfrage, bevor Sie sich entscheiden.

Nieselregen oder Prisma? Überprüfen Sie den Verbindungstyp und die protokollierte SQL-Abfrage, bevor Sie sich entscheiden.

Modellieren Sie die gleichen Benutzer- und Rechnungstabellen in Drizzle und Prisma, vergleichen Sie die Typen der Ergebnisse der Verknüpfungen sowie den protokollierten SQL-Code und identifizieren Sie die Treiberabbildungen, die Gesamtbeträge in Zeichenketten umwandeln.

2461 Wörter

Debatten über ORM-Systeme drehen sich meist um Download-Zahlen und Konferenzslogans, doch die eigentliche Frage in der Produktion ist viel einfacher: Wenn man einen Benutzer mit seinen Rechnungen verknüpft, welchen Typ hat total, und kann man die generierte SQL-Abfrage einsehen? In diesem Leitfaden werden dieselben beiden Tabellen in Drizzle und Prisma erstellt, jeweils eine Einfüge- und eine Verknüpfungsoperation durchgeführt, sowie die abgeleiteten TypeScript-Typen, die protokollierten Abfragen, die Migrationsausgaben und das Verhalten bei der nativen TypeScript-Ausführung unter Node verglichen. Am Ende erhalten Sie ein kurzes, wiederholbares Laborexperiment, das die ORM-Frage für Ihre eigene Codebasis beantwortet – anstatt sich auf die Benchmarks anderer zu verlassen. Für ein umfassenderes Entscheidungskonzept, das auch rohe SQL-Abfragen berücksichtigt, siehe wie man zwischen roher SQL, Prisma und Drizzle eine Datenbankschicht auswählt.

Wofür jede Tool optimiert ist

Die beiden Bibliotheken machen unterschiedliche Versprechen. Drizzle bietet Abfragesprache, die wie SQL in TypeScript aussieht, ohne separaten Abfragemaschinenprozess und mit einer Architektur, die sich für Edge-Runtimes eignet. Prisma bietet einen schema-basierten Arbeitsablauf mit einer speziellen schema.prisma-Datei sowie einem generierten Client; in den neueren Versionen wird der Abfragemotor allmählich von Rust auf TypeScript umgestellt. Zu Zeitpunkt des Schreibens war dieser Umstieg noch im Gange, daher sollten Sie die aktuellen Prisma-Release-Notes prüfen, um herauszufinden, welchen Motor Ihre Version verwendet.

Auch die Beliebtheit hat zwei Gesichter: Prisma führt weiterhin bei den Installationen, während Drizzle im Fokus der Wachstumsdiskussionen steht. All das sagt Ihnen jedoch nichts über Ihre Entscheidung aus. Die beiden unten genannten Kriterien sind absichtlich eng gefasst und praktisch: ob total als Zahl angegeben wird und ob der SQL-Code im Protokoll so ist, dass man ihn bei einem Incident problemlos in psql einfügen könnte.

Das Szenario betrifft eine kleine Rechnungsstellungsanwendung, bei der eine /invoices-Seite einen Gesamtbetrag anzeigen muss. Etwas in der Technologiearchitektur muss diesem Gesamtbetrag einen Typ zuweisen – und genau da beginnt der Vergleich.

Die gleichen beiden Tabellen, zweimal

Erstellen Sie zwei separate Projektverzeichnisse für dieselbe PostgreSQL-Instanz und geben Sie jedem ein eigenes Schema-Name. Das Teilen von Tabellen zwischen zwei ORMs führt zu doppelt eingetragenen Zeilen, die wie Leistungsdaten aussehen, aber in Wirklichkeit Fehler sind.

In Drizzle befindet sich das Schema als gewöhnlicher TypeScript-Code in src/schema.ts. Beachten Sie, dass die Spaltennamen explizit im snake_case-Format (user_id) deklariert werden, während die Eigenschaften im camelCase-Format (userId) verwendet werden, und dass die Fremdschlüssel eine Funktionseintragung zu users.id sind:

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(),
});

In Prisma wird dasselbe Modell in prisma/schema.prisma abgelegt. Die Beziehung wird auf beiden Seiten deklariert: User besitzt ein invoices-Array, und Invoice enthält den Skalarwert userId sowie ein @relation, das eine Verbindung herstellt:

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])
}

Nun die wichtige Abfrage: Die Rechnungen eines Benutzers anhand seiner E-Mail abrufen. Drizzle formuliert dies als explizite innere Verknüpfung mit einer where-Klausel, während Prisma den Benutzer anfordert und angibt „die Rechnungen einbeziehen“:

// 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 },
});

Die Ergebnistypen spiegeln diese beiden Denkmuster wider. Drizzle gibt Zeilen zurück, die der Join-Struktur entsprechen, wobei jede Zeile über eine users-Schlüssel und eine invoices-Schlüssel verfügt. Prisma gibt hingegen User & { invoices: Invoice[] } zurück, ein verschachteltes Objekt. Beides ist korrekt. Die Struktur von Drizzle entspricht dem SQL, während die Struktur von Prisma der Seite entspricht, die gerade gerendert werden soll.

Auch bei aktiviertem Abfragen-Logging bleibt der Unterschied bestehen. Die Ausgabe von Drizzle ist ein Join, den Entwickler direkt lesen können. Die Ausgabe von Prisma ist ebenfalls voll funktionsfähig, handelt sich jedoch um generierten SQL-Code, den man nicht manuell bearbeiten möchte.

Bei einem so kleinen Schema verliefen die Migrations ohne Probleme. drizzle-kit generate erzeugte SQL-Dateien, die kommittiert werden können; prisma migrate erstellte seine eigene Migrationshistorie, die ebenfalls kommittiert werden kann. Keines der Tools hatte Schwierigkeiten mit zwei Tabellen, und ein Schema dieser Größe kann keine schwierigeren Migrationsfälle aufzeigen, daher sollte man daraus keine Schlussfolgerungen ziehen.

Das Labor lokalerweise nachbilden

Installieren Sie jede Toolchain in ihrem eigenen Verzeichnis. Drizzle benötigt den ORM, einen Driver (hier postgres) sowie drizzle-kit für die Migrations; Prisma benötigt die CLI und den Client sowie prisma init, um das Schema-Datei-Struktur zu erstellen:

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

pnpm add prisma @prisma/client
pnpm exec prisma init

In jedem Ordner soll ein Benutzer sowie zwei Rechnungen eingefügt werden, die Verknüpfung einmal ausgeführt und der total-Wert der ersten Rechnung zusammen mit ihrem Laufzeittyp ausgegeben werden. Beachten Sie die unterschiedlichen Zugriffswege: rows[0].invoices.total für die durch Drizzle erzeugten Verknüpfungsspalten im Vergleich zu user.invoices[0].total für die verschachtelten Objekte bei Prisma.

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

Falls ein Tool String und das andere Zahl meldet, liegt die Ursache fast immer in der Typkartei des Datenbank-Treibers und nicht in der Philosophie des ORMs. PostgreSQL-Treiber geben häufig bigint- und numeric-Spalten als Strings zurück, um eine Verlust von Präzision bei JavaScript-Zahlen zu vermeiden, während reine Integer-Spalten als Zahlen zurückkehren. Ein String-Zusammenfassung führt dazu, dass "1200" + 50 auf einer Rechnung stillschweigend zu "120050" wird. Notieren Sie das Ergebnis von typeof, bevor Sie eine Bibliothek auswählen.

Ausführung der Abfragedatei mit nativem TypeScript

Überprüfen Sie anschließend, ob der Code direkt unter Node’s eingebauter Typentfernungsfunktion läuft, die .ts-Dateien ohne separate Kompilierung durch Auslöschen der Typangaben ausführt:

node src/query.ts

Ein Drizzle-Modul, das ausschließlich aus Funktionen und Typangaben besteht, funktionierte ohne Probleme. Ein in node_modules erzeugter Prisma-Client funktionierte ebenfalls, wenn er von einem kleinen Wrapper aufgerufen wurde. Das Problem trat bei einer Datei auf, die Prismas im älteren Stil erzeugte Enums importierte. TypeScript-enum-Deklarationen sind nicht nur Typen; sie werden zu Laufzeitobjekten kompiliert, und Modus „strip-only“ von Node kann sie nicht löschen, wodurch die Ausführung fehlschlägt. Das ist kein Prisma-Defekt, sondern Eigenschaft des generierten Laufzeitcodes. Wenn Ihre Prisma-Version den neueren, auf TypeScript basierenden Engine und Generator verwendet, prüfen Sie genau, was prisma generate tatsächlich erzeugt, bevor Sie annehmen, dass dies weiterhin gilt, und geben Sie die getestete Version an.

Aktivierung des Abfragen-Loggens

Durch Vermutungen bezüglich der SQL-Abfragen ziehen sich die Probleme in die Länge. Beide Bibliotheken können jede Abfrage protokollieren – Drizzle über eine logger-Option und Prisma über den log-Array auf der Client-Seite:

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

Platzieren Sie die beiden protokollierten SQL-Strings neben den beiden Ergebnissen von typeof total. Diese vier Zeilen bilden das gesamte Datensatz, den dieses Labor benötigt.

Wo jedes Tool Kosten verursacht

Die Trade-offs zeigen sich an fünf Stellen.

Typen. Prismas include liefert genau die Struktur, die die /invoices-Seite erfordert. Drizzles Join liefert genau die Struktur, die man benötigt, um herauszufinden, warum eine Summe sich verdoppelt hat. Beide sind zu unterschiedlichen Zeiten nützlich – das spricht dafür, pro Datenbank eines der Tools zu verwenden, anstatt beide auf denselben Tabellen einzusetzen.

SQL-Sichtbarkeit. Wenn die Gesamtsumme falsch erscheint, klärt Drizzles Protokoll den Streit schneller, da die Abfrage lesbar ist. Wenn ein neues Teammitglied ein Feld hinzufügen muss, ist Prismas Schema-Datei der schnellere Weg. Es handelt sich dabei um unterschiedliche Situationen mit unterschiedlichen Gewinnern.

Der Generierungs-Schritt. Prisma erfordert nach jeder Schema-Änderung prisma generate; Drizzle verlangt, dass schema.ts stets korrekt bleibt. Den Generierungs-Schritt vergisst man in CI leicht, und wenn eine Client-Version hinter dem Schema zurückbleibt, führt das zu verwirrenden Fehlern. Lassen Sie CI fehlschlagen, wenn die Generierung ausgelassen wird.

Edge-Runtimes. Drizzles Kompatibilität mit Edge-Runtimes ist ein echter Verkaufsargument, doch sie spielt nur eine Rolle, wenn man auf einen Edge-Runtime deployt. Ein Node-Prozess, der neben PostgreSQL auf einem VPS läuft, profitiert davon nicht – lassen Sie daher dieses Argument nicht darüber entscheiden, ob eine Anwendung auf einem Server gehostet wird.

Grenzen der Bündel. Keines der ORM-Tools gehört in ein Client-Komponente. Wenn eines davon in ein "use client"-Modul importiert wird, beispielsweise in einen interaktiven Tabellenfilter, wurde die Klientengrenze zu hoch gezogen und ein Datenbanktreiber gelangt somit in den Browser. Der Artikel zu der richtigen Setzung der use client-Grenze erklärt, wie man das beheben kann.

Aufgeschlüsselte Rechnung

Weitere Aufschlüsselung der Kosten:

  • Zeit. Der „Reibungsfaktor“ von Drizzle entstand durch die Form einer Verknüpfung: rows[0].invoices.total oder rows[0].total, je nachdem, wie der SELECT-Befehl formuliert war. Der „Reibungsfaktor“ von Prisma bestand darin, nach jeder Änderung des Schemas erneut generiert werden zu müssen.
  • Laufzeitverhalten. Sowohl bei Einfügen als auch bei Verknüpfung werden zwei Rechnungen zurückgegeben. Zwei Tabellen werden niemals einen Gewinner hervorbringen.
  • Enums. Die von Prisma generierten Enums sind Laufzeitwerte. Das Ausführen dieser Dateien unter Verwendung von raw Node führt zum Scheitern; entweder kompiliert man das Paket oder man vermeidet es, die generierten Quellen direkt auszuführen.
  • Lock-in-Effekt. Prismas Client ist ein Produkt mit eigenem Generator und Engine; Drizzles Tabellen bestehen aus reinem TypeScript. Wer nach einem Jahr davon abweicht, muss die Abfrageschicht neu schreiben – es reicht nicht aus, nur eine Einstellung zu ändern. Das sollte in das RFC aufgenommen werden, bevor jemand schreibt „Wir können immer später wechseln“.
  • Auswahl und was man besser nicht tut

    Wählen Sie Drizzle, wenn SQL in den Code-Reviews sichtbar sein soll und das Team bereits mit JOINs arbeitet. Behalten Sie das Schema in schema.ts bei und stellen Sie sicher, dass jemand im Team gut mit innerJoin umgehen kann.

    Wählen Sie Prisma, wenn die Arbeitsweisen des Teams auf schema.prisma und include basieren. Planen Sie den Generierungsprozess im CI ein und lassen Sie die Pipeline fehlschlagen, wenn dieser nicht ausgeführt wurde.

    Achten Sie unabhängig von der Wahl auf Folgendes:

    • Den Einsatz beider ORMs gegen dieselben Produktivtabellen „zur Vergleichung“. Dadurch wird derselbe Wert zweimal erfasst, und jemand muss einen ganzen Tag damit verbringen, Rechnungen mit den Bankdaten abzugleichen.
    • Die Entscheidung auf der Grundlage wöchentlicher Downloads. Wählen Sie stattdessen das ORM aus, dessen JOIN-Ergebnistyp man unter Druck schnell lesen kann.
  • Importieren des Datenbankclients in eine Serveraktion sowie in ein Clientkomponente aus Gründen der Bequemlichkeit. Genau diese Bequemlichkeit führt dazu, dass eine Clientinsel schließlich einen Treiber mitliefert.
  • Eine als Zeichenkette dargestellte Gesamtsumme, die eigentlich ein Treiberproblem war

    Ein realistischer Fehler zeigt, warum die Überprüfung mit typeof wichtig ist. Ein Team modelliert in beiden Tools dieselben zwei Tabellen, verknüpft einen Benutzer mit zwei Rechnungen und protokolliert den Typ der total-Werte: in beiden Fällen number. Eine Woche später wird ein anderer Treiber eingeführt, der eine numerische Spalte auf string abbildet, wodurch Berichte anfangen, Werte zu concatenieren statt sie zu addieren und somit die angezeigten Zahlen zu verdoppeln.

    Die verlockende Lösung besteht darin, Number(total) um jeden Aufrufort zu schließen. Dadurch wird die Zuordnung zwar versteckt, aber nicht wirklich behoben – und die nächste Spalte mit dem gleichen Problem wird unentdeckt bleiben. Die nachhaltige Lösung ist es, den SQL-Code sowie die Ergebnistypen pro Bibliothek einmal zu protokollieren, die Version des Treibers festzulegen und sicherzustellen, dass nie zwei ORMs in dieselben Produktionstabellen schreiben.

    Auch bei der Handhabung von Enums gilt dieselbe Logik: Erstellte Enums sind Laufzeitcode, daher sollte dieses Paket kompiliert und die Ausgabe aus dist/ ausgeführt werden, anstatt den generierten TypeScript-Code direkt auszuführen. Unabhängig davon, welche Bibliothek gewinnt, sollten die Entscheidung sowie der Grund im README festgehalten werden, damit niemand später die andere Bibliothek hinzufügt, nur um sie auszuprobieren.

    Erstellen einer Umgebungsprotokoll vor dem Vergleich

    Ausgaben wie diese sind nur im Zusammenhang mit den Versionen, die sie erzeugt haben, sinnvoll. Die Referenzumgebung bestand hier aus Node 24, TypeScript 7 und Next.js 16.3, mit einem kleinen Anwendungsbeispiel mit vier Routen für Rechnungen. Halten Sie eine notes/lab.md-Datei im Repository bereit und fangen Sie damit an, die drei Versionen festzuhalten:

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

    Schreiben Sie diese an den Anfang der Notiz. Wenn eine Hauptversion von der in einer Anleitung angenommenen abweicht, stoppen Sie und klären Sie die Unterschiede, bevor Sie weitermachen, denn spätere Befehle können Sie auf subtilere Weise in die Irre führen.

    Dann starten Sie den Entwicklungsserver und durchlaufen Sie die Routen:

    pnpm exec next dev
    

    Besuchen Sie /, /invoices, /invoices/1 und /settings, dann wieder /invoices, wobei in den DevTools „Preserve log“ aktiviert sein sollte. Erfassen Sie das Filterfeld zusammen mit der URL – diese Kombination ist oft der Beweis, den Sie später benötigen.

    Führen Sie anschließend den Typprüfer aus und geben Sie seinen Abbruchcode aus:

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

    Ein Abbruchcode von null ist keine Besonderheit, sondern lediglich die Erlaubnis, zu den Laufzeitprüfungen überzugehen. Danach sollten Sie die Befehle aus dem obigen Lab-Bereich auf Ihrem eigenen Rechner ausführen, anstatt diesen Ergebnissen zu vertrauen; Hardware, Speichendruck sowie die Aktivitäten des Browsers können den Speicherverbrauch, die Dauer der Typprüfung sowie die Ladezeiten stärker verändern als eine kleine Versionserweiterung eines Frameworks.

    Außerdem hilft es, in den Notizen eine einzeilige Eintragung mit der Meldung „Fehlerbehebung fehlgeschlagen“ in Form von „Habe X ausprobiert, immer noch Y“ zu führen. Diese Zeile verwandelt die Datei in ein ehrliches Laborendprotokoll statt in eine Broschüre und ist das Nützlichste, was man einem Kollegen mitgeben kann, der die Untersuchung fortsetzt.

    Fehler, die man vermeiden sollte

    Drei Fehler bei dieser Art von Vergleich treten häufig auf:

    • Das Ausführen beider Migrationstools auf derselben Datenbank zur Vergleichbarkeit, was zu zwei Migrationshistorien sowie einer Tabelle mit zwei verschiedenen Namen führt. Die einzige saubere Wiederherstellungsmöglichkeit ist ein Restore aus einer Sicherungskopie.
    • Das Importieren der generierten Prisma-Enums in eine Datei, die anschließend mit Node’s Typentfernungsfunktion verarbeitet wird – was aus den oben genannten Gründen fehlschlägt. Stattdessen sollte das Paket kompiliert werden.
    • Die Beurteilung der Bibliotheken anhand der Downloadzahlen, die keinerlei Einfluss auf den Typ des Joins haben.

    Checkliste vor dem Hinzufügen eines ORMs

    • Ein ORM pro Datenbank.
    • Die SQL-Anweisung für den Schlüssel-Join muss mindestens einmal protokolliert werden.
    • Der Laufzeit-Wert von typeof für Geldspalten muss mindestens einmal protokolliert werden, sowie nach jedem Upgrade des Drivers erneut.
    • Die generierte Ausgabe, die Enums enthält, muss kompiliert werden und niemals durch rohe Typentfernungsfunktionen ausgeführt.
    • In der README-Datei müssen die gewählte Bibliothek sowie der Grund dafür genannt werden.

    Zusammenfassung

    Zwei Tabellen bilden kein Produktionsschema, und in diesem Labor wurden weder Tausende von Join-Operationen getestet noch Lösungen in einen Edge-Runtime eingesetzt. Was jedoch deutlich wird, ist, dass die entscheidenden Unterschiede konkret sind und bereits am Nachmittag überprüft werden können: die Form des Join-Ergebnisses, die Lesbarkeit der protokollierten SQL-Anfragen, die Kosten der Generierungsphase sowie ob ein Driver Zahlen oder Zeichenketten zurückgibt. Wählen Sie für jede Datenbank eine Bibliothek aus, notieren Sie den Grund dafür und machen Sie die Überprüfung mit typeof total zu einer Routine nach jedem Update eines Drivers. Wenn zwei Migrationstools eine Datenbank verwalten, endet das meist mit einem Restore – halten Sie daher solche Experimente unbedingt von Systemen fern, die mit echtem Geld arbeiten.