Die Dosis macht das Gift – wieviel Logik soll in eine Datenbank?

Ich versuche meine Meinung mit folgender Definition zu beginnen:

Object-relational impedance mismatch: Bezeichnet die Herausforderung, Objekte aus einer objektorientierten Programmiersprache in einer relationalen Datenbank zu speichern. Dafür gibt es ein oft verwendetes Hilfsmittel: ORM (Objekt-Relationales Mapping): Beispiele dafür sind Hibernate oder Entity-Framework. Es handelt sich um Libraries, die das Arbeiten mit relationalen Datenbanken – fast – transparent erledigen. Der Benutzer kann in seiner objektorientieren Welt bleiben und das Framework kümmert sich um Joinen, Change-Tracking, uvm.

Ich habe vermehrt beobachtet, dass durch den Einsatz von ORMs folgender Effekt eintritt: die Datenbank wird nur mehr als “dummer” Storage betrachtet und man verwendet nur mehr die nötigsten Features. Eines davon sind Constraints. Im Falle von PostgreSQL habe ich die Erfahrung gemacht, dass sich Entwickler ausschließlich auf folgende Constraint-Typen zurückgreifen:

  • NOT NULL: Verhindert, dass eine Spalte NULL Werte enthält
  • UNIQUE: Verhindert, dass in einer oder mehreren Spalten Duplikate entstehen
  • REFERENCES: Garantiert, dass der Datensatz in der referenzierten Tabelle existiert

Dabei bietet die Datenbank weitere:

  • CHECK: Garantiert, dass Werte in einer Zeile gewisse Werte haben (e.g. CHECK(price > 0) oder CHECK(old_price < new_price)). Auch SWITCH-CASE Statements sind möglich.
  • EXCLUDE constraints: garantiert, dass mehrere Rows eine gewisse Bedingung erfüllen. Ein schönes Beispiel findet man hier: https://www.cybertec-postgresql.com/en/postgresql-exclusion-constraints-beyond-unique/. Das Beispiel versichert, dass pro Auto Reservierung mindestens 1h Buffer ist:
CREATE TABLE car_reservation (
    car text,
    during tsrange,
    EXCLUDE USING GIST (car WITH =, add_buffer(during, '1 hours'::interval) WITH &&)); 

Wandert hier Business-Logik in die Datenbank? Ja. Ist es schlimm? Nein. Sobald ich ein NOT NULL oder einen PRIMARY KEY anwende, wandert bereits Logik in die Datenbank.

Im Fall der Auto-Reservierung: Werde ich den Check auch im Code haben? Vermutlich ja, weil ich im Frontend bereits eine falsche Buchung unterbinden will (Auswahl nicht ermöglichen oder per API die möglichen Zeiten retournieren). Und es sei am Rande noch erwähnt: Man muss sich auch in Hibernate mit LockModeType beschäftigen, um hier keine Race-Conditions zu bekommen.

Gehen wir einen Schritt weiter: Konsistenzüberprüfungen über mehrere Zeilen hinweg. Folgendes Beispiele zeigt eine Anwendung: https://github.com/mvodep/Playground/blob/main/PostgreSQL/Consistency/vehicle_mileages.sql

Aufgabe war es sicherzustellen, dass ein Fahrzeug nur einen steigenden Kilometerstand haben kann. Die Lösung sperrt die Zeilen (FOR NO KEY UPDATE). Ein ORDER BY verhindert Dead-Locks. Durch ERRCODE kann ich schön im Code mappen.

Hier bedarf es mehr Analyse.

Pros:

  • Man hat die volle SQL Power zur Verfügung – wie im ersten Beispiel Window-Functions.
  • Die meisten Tools listen Trigger sehr schön auf und ich sehe auf einen Blick: Welche Constraints gibt es auf der Tabelle
  • Performance ist hoch, da direkt auf der Datenbank ausgeführt – kein Round-Trip erforderlich

Cons:

  • Es ist ein relativ mächtiges Requirement in der Datenbank gelandet. Es könnte daher zu Fragmentierung von Logik kommen (ein Teil im Code, ein Teil in der Datenbank)
  • Der Entwickler muss die Datenbank beherrschen

Ein weiteres – sehr gelungenes Beispiel – findet man hier https://www.cybertec-postgresql.com/en/triggers-to-enforce-constraints/.

Warum bin ich trotz vorhandener Cons ein Befürworter dieser Technik: weil ich auf der untersten Ebene Konsistenz garantieren will. Es wird der Tag kommen, wo durch Bugs im Code fehlerhafte Daten in die Datenbank gelangen. Dann heißt es: fixen. Fixen heißt SQL Statements ausführen. Da will ich ein Sicherheitsnetz.

Ja – Fehler können auch bei Triggers passieren – aber es ist alles kompakter und übersichtlicher und folgt meines Erachtens KISS (“Keep it simple, stupid!”). Natürlich sollte man auch Regressionstests bei solchen etwas komplexeren Constraints haben. Aber hier ist Vorsicht geboten: Oft testen Leute, ob SQL wirklich das tut, was es tun soll. Das sollte der Test aber nicht (siehe Beispiel oben vehicle_mileages.sql – man kann hier mit Test-As-Documentation einen Fall zeigen, dass der Trigger feuert, sollte aber nicht testen, ob Window Functions korrekt funktionieren). Ein Framework für Tests: https://pgtap.org/documentation.html#usingpgtap

Kann man dem Prinzip der Trigger-Constraints blind folgen? Wie immer im Leben: nein. Wenn die Function > 100 LOC hat und vor lauter IF-Statements nur so strotzt, dann sollte man das nochmal überdenken, was man gerade macht.

Eine Steigerungsstufe sind natürlich Constraints über mehrere Tabellen (e.g. wenn der User vom Typ Admin ist, muss er mindestens eine aktive E-Mail haben). Hier wird es etwas haarig, da ich plötzlich auf 2 Tabellen Trigger benötige. Aber auch hier: man möge mir belegen, dass die Trigger-Lösung allgemein / immer absurd ist.

Neben Constraints gibt es natürlich auch schreibende Logik

Hier haben Sprachen wie PL/SQL vermutlich ihren schlechten Ruf erlangt, weil es hier zu Entartungen gekommen ist. Man kann in allen Sprachen schlechtwartbaren Code erzeugen. Aber PL/SQL macht es besonders leicht.

  • Verwende ich schreibende Trigger um eine Historie zu erstellen (Temporale Tabellen)? Ja: https://github.com/mvodep/Playground/blob/main/Hibernate%20History/schema.sql
  • Verwende ich PL/SQL um komplexe Business-Logik umzusetzen? Nein, weil das Testen ein Krampf werden wird. Ich will Sprachkonstrukte wie Interfaces, Klassen oder Techniken wie Dependency-Injection nutzen, um wartbaren Code bauen zu können.

Leider zeigt dieses Thema, dass wir uns in der Entwicklung immer zwischen Extremen bewegen – was ich persönlich sehr schade finde. Wir sollten wieder lernen einfach zu denken und nicht blind irgendwelche Frameworks nutzen.