Artikel

Prisma statt handgeschriebener Queries

Warum ich meine SQL-Strings aufgegeben habe, was ein Schema als einzige Wahrheit bringt — und die zwei Stellen, an denen ich bis heute lieber selbst SQL schreibe.

BlackZackBlackzack

938 Wörter · 5 Min. Lesezeit

  • yurna
  • datenbank
  • werkzeuge

Ein Jahr nach dem Umzug von JSON auf eine Datenbank hatte ich ungefähr achtzig SQL-Abfragen im Code verteilt. Sie funktionierten, und trotzdem war das Arbeiten damit zunehmend unangenehm.

Der Auslöser für den Wechsel war eine Kleinigkeit: Ich habe eine Spalte umbenannt und danach zwei Tage lang Fehler an Stellen gefunden, an denen ich die alte Schreibweise übersehen hatte. Ein SQL-String ist für den Editor bloß Text. Er weiß nicht, dass darin ein Spaltenname steht, und kann mir deshalb auch nicht sagen, dass es ihn nicht mehr gibt.

Was sich mit einem Schema ändert

Der Kern ist eine Datei, die die Struktur beschreibt:

model Guild {
  id        String   @id
  prefix    String   @default("!")
  language  String   @default("de")
  members   Member[]
  updatedAt DateTime @updatedAt
}
 
model Member {
  guildId String
  userId  String
  xp      Int    @default(0)
  coins   Int    @default(0)
  guild   Guild  @relation(fields: [guildId], references: [id], onDelete: Cascade)
 
  @@id([guildId, userId])
  @@index([guildId, xp])
}

Daraus entstehen zwei Dinge: die Datenbankstruktur und ein typisierter Zugriff im Code. Und plötzlich ist ein Spaltenname kein Text mehr, sondern etwas, das der Editor kennt:

const rangliste = await db.member.findMany({
  where: { guildId },
  orderBy: { xp: "desc" },
  take: 10,
});

Wenn ich xp in punkte umbenenne, zeigt mir der Editor jede Stelle, die noch xp sagt — bevor ich das Programm überhaupt starte. Genau das war mein Auslöser, und es hat allein den Umstieg gerechtfertigt.

Die vier Dinge, die ich dadurch geschenkt bekommen habe

Beziehungen ohne Handarbeit. Mitglieder eines Servers mitzuladen ist eine Zeile statt eines Joins mit Ergebnisumbau. Der Code, der aus flachen Zeilen wieder verschachtelte Objekte baut, war bei mir eine erstaunlich große Fehlerquelle.

Aufräumen per Definition. onDelete: Cascade sorgt dafür, dass beim Löschen eines Servers dessen Mitgliederdaten mitgehen. Vorher hatte ich dafür eine Funktion, die man aufrufen musste und die man vergessen konnte.

Sichere Werte ohne Nachdenken. Alle Werte werden als Parameter übergeben, nicht in den Text eingebaut. Das Grundmuster von SQL-Injection ist damit weg — nicht, weil ich besonders vorsichtig bin, sondern weil die unsichere Variante gar nicht der bequeme Weg ist.

Transaktionen als normaler Vorgang. Mehrere Änderungen, die zusammen gelten sollen, sind ein Aufruf. Ich habe das vorher vermieden, weil es mit rohen Verbindungen umständlich war — und deshalb an mehreren Stellen Zustände produziert, die halb geändert waren.

Wo ich vorsichtig geworden bin

Massenoperationen. Für „schreibe zehntausend Zeilen" ist ein solches Werkzeug nicht gebaut. Wer in einer Schleife zehntausend Einzelaufrufe macht, wartet lange. Dafür gibt es Sammeloperationen, und für den Rest schreibe ich weiterhin gezielt SQL.

Komplexe Auswertungen. Eine Statistik mit mehreren Gruppierungen, Fensterfunktionen und Unterabfragen ist in SQL kurz und klar. Über eine Abfrageschnittstelle wird sie lang und unklar. Ich habe mir angewöhnt, in solchen Fällen rohes SQL zu benutzen — das geht, und es ist kein Scheitern des Werkzeugs.

Das versteckte N+1. Der Klassiker: eine Liste laden, dann in einer Schleife für jedes Element etwas nachladen. Aus einer Abfrage werden hundertundeine. Dass so ein Werkzeug den Zugriff angenehm macht, verdeckt, wie viele Abfragen tatsächlich losgehen. Ich habe seitdem die Abfrageprotokollierung im Entwicklungsmodus dauerhaft an — es ist erschreckend lehrreich zu sehen, was ein harmlos aussehender Codeblock auslöst.

Tipp

Wer mit einem solchen Werkzeug anfängt, sollte einmal bewusst die erzeugten Abfragen anschauen. Nicht aus Misstrauen, sondern weil man dabei versteht, welche Formulierung im Code welche Abfrage erzeugt. Danach schreibt man automatisch besseren Zugriffscode.

Die Sache mit den Migrationen

Der Teil, der mir am meisten Kopfzerbrechen bereitet hat, war nicht der Zugriff, sondern die Entwicklung des Schemas über die Zeit.

Es gibt zwei Wege. Der eine erzeugt für jede Änderung eine Migrationsdatei mit den nötigen Schritten, die man versioniert und nacheinander ausführt. Der andere gleicht das Schema direkt an — bequem, aber ohne Historie und mit dem Risiko, dass eine Änderung Daten verwirft.

Ich habe in diesem Jahr mit Migrationsdateien gearbeitet, bin Jahre später bewusst auf den direkten Abgleich umgestiegen und habe für diese Entscheidung gute Gründe — aber das gehört in einen eigenen Eintrag. Was hier zählt: Beides sind gültige Wege, und die Wahl hängt daran, wie viele Umgebungen man hat und wie schmerzhaft ein Fehler wäre.

Was ich beim ersten Schema falsch gemacht habe

Ein typisierter Zugriff verführt dazu, das Datenmodell aus der Sicht des Codes zu entwerfen statt aus der Sicht der Daten. Drei Sachen, die ich deshalb korrigieren musste:

Zu viele Beziehungen. Ich habe zwischen fast allem Verknüpfungen angelegt, weil es so bequem ist, überall mitzuladen. Das Ergebnis waren Abfragen, die deutlich mehr geladen haben als nötig — und ein Schema, in dem man kaum noch etwas löschen konnte, ohne an fünf Stellen aufzuräumen.

Zu wenige Indizes. Die Abfrageschnittstelle macht das Formulieren so einfach, dass man nicht darüber nachdenkt, was dabei entsteht. Erst mit eingeschalteter Abfrageprotokollierung habe ich gesehen, welche Sortierungen über die ganze Tabelle liefen.

Berechenbare Felder gespeichert. Ein Zähler, den man auch zählen könnte. Solche Felder laufen irgendwann auseinander, und dann hat man zwei Wahrheiten.

Die Regel, die ich mir daraus gemacht habe

Bei jedem Feld, das ich neu anlege, beantworte ich zwei Fragen: Woher kommt der Wert, und wer darf ihn ändern?

Wenn ein Wert aus anderen Daten ableitbar ist, wird er nicht gespeichert — es sei denn, die Ableitung ist teuer und die Aktualität darf leiden. Dann wird er gespeichert, und es gibt genau eine Stelle im Code, die ihn schreibt.

Diese eine Stelle ist der entscheidende Teil. Ein abgeleiteter Wert, den drei verschiedene Codepfade aktualisieren, ist eine Frage der Zeit, bis einer davon einen Fall vergisst. Bei mir war das ein Mitgliederzähler, der nach einem Massenaustritt falsch stand — weil der Austrittspfad ihn dekrementierte, der Kick-Pfad aber nicht.

Was mich am meisten überrascht hat

Dass die größte Verbesserung nicht die Bequemlichkeit war, sondern die Rückmeldung beim Schreiben.

Vorher war eine falsche Abfrage ein Laufzeitfehler — vielleicht sofort sichtbar, vielleicht erst in einem selten benutzten Befehl. Danach war sie ein Fehler beim Tippen. Das verkürzt die Schleife zwischen Fehler und Erkennen von Stunden auf Sekunden, und diese Verkürzung ist am Ende mehr wert als jede einzelne Funktion des Werkzeugs.