7. Datenbankseitige Programmierung
Datenbankseitige Programmierung in PostgreSQL
Abschnitt betitelt „Datenbankseitige Programmierung in PostgreSQL“In vielen Projekten steckt die Geschäftslogik in einem Anwendungsserver (etwa in Python, Java oder TypeScript). Relationale Datenbanksysteme können Logik aber auch direkt in der Datenbank ausführen. Das nennt man datenbankseitige Programmierung.
In PostgreSQL sind die drei wichtigsten Bausteine:
- Functions (
CREATE FUNCTION) - Stored Procedures (
CREATE PROCEDURE) - Trigger (
CREATE TRIGGER+ Trigger-Funktion)
Diese Mittel helfen, Regeln zu zentralisieren, Datenqualität zu erzwingen und doppelte Logik über mehrere Anwendungen hinweg zu vermeiden.
Warum Logik in der Datenbank?
Abschnitt betitelt „Warum Logik in der Datenbank?“Typische Gründe:
- Konsistenz: Geschäftsregeln werden an einer zentralen Stelle erzwungen.
- Sicherheit: Anwendungen bekommen eingeschränkten Zugriff und rufen freigegebene Routinen auf, statt beliebiges SQL zu schreiben.
- Performance: Manche Operationen laufen schneller, wenn sie nahe an den Daten ausgeführt werden.
- Wiederverwendbarkeit: Dieselbe Logik lässt sich von Web-Apps, Batch-Jobs und BI-Tools nutzen.
Mögliche Nachteile:
- Mehr Logik in SQL/PL/pgSQL kann die Komplexität erhöhen.
- Versionierung und Deployment müssen sorgfältig verwaltet werden.
- Herstellerspezifische Syntax kann die Portabilität mindern.
- Eingeschränktes Debugging: PostgreSQL bietet nicht die ausgereiften Debugging-Werkzeuge moderner Programmiersprachen, was die Fehlersuche in komplexen Procedures und Functions erschwert.
1. PostgreSQL-Functions
Abschnitt betitelt „1. PostgreSQL-Functions“Eine Function gibt einen Wert zurück (skalar, Zeile oder Tabelle). Functions lassen sich in SQL-Ausdrücken, SELECT-Listen oder WHERE-Klauseln nutzen.
1.1 Einfaches Function-Beispiel
Abschnitt betitelt „1.1 Einfaches Function-Beispiel“CREATE OR REPLACE FUNCTION calculate_vat_net_to_gross( net_amount NUMERIC, vat_rate NUMERIC DEFAULT 0.20)RETURNS NUMERICLANGUAGE plpgsqlAS $$BEGIN RETURN ROUND(net_amount * (1 + vat_rate), 2);END;$$;Verwendung:
SELECT calculate_vat_net_to_gross(100.00); -- 120.00SELECT calculate_vat_net_to_gross(100.00, 0.10); -- 110.00Eine Function in einer praxisnahen Abfrage:
Angenommen, es gibt eine Tabelle products mit Nettopreisen:
SELECT product_id, name, net_price, calculate_vat_net_to_gross(net_price) AS gross_price, calculate_vat_net_to_gross(net_price, 0.07) AS reduced_vat_priceFROM productsWHERE net_price > 50.00ORDER BY gross_price DESC;Diese Abfrage berechnet für alle Produkte über 50 EUR Nettopreis sowohl den Standardsatz (20 %) als auch den ermäßigten Satz (7 %).
Warum hier eine Function statt einer Inline-Berechnung?
Man könnte die Formel direkt ins SELECT schreiben:
-- without a function (harder to maintain):SELECT product_id, name, net_price, ROUND(net_price * 1.20, 2) AS gross_price, ROUND(net_price * 1.07, 2) AS reduced_vat_priceFROM products;Eine Function bringt aber mehrere Vorteile:
- Eine einzige Wahrheit: Ändert sich das Steuerrecht (etwa von 20 % auf 21 %), wird nur die Function angepasst, nicht Dutzende Abfragen im Code.
- Konsistenz: Alle Teile der Anwendung nutzen dieselbe Logik, das verhindert Rechenfehler.
- Lesbarkeit:
calculate_vat_net_to_gross(price)ist klarer alsROUND(price * 1.20, 2). - Wiederverwendbarkeit: Dieselbe Function lässt sich aus Berichten, APIs, Batch-Jobs und anderen Routinen aufrufen.
- Flexibilität: Komplexere Logik (Rundungsregeln, Sondersätze, historische Sätze) lässt sich in der Function ergänzen, ohne alle Aufrufer zu ändern.
1.2 SQL-Function (ohne PL/pgSQL)
Abschnitt betitelt „1.2 SQL-Function (ohne PL/pgSQL)“Ist eine Function nur eine einzige Abfrage, reicht oft LANGUAGE sql:
CREATE OR REPLACE FUNCTION full_name(first_name TEXT, last_name TEXT)RETURNS TEXTLANGUAGE sqlAS $$ SELECT CONCAT(first_name, ' ', last_name);$$;1.3 Eine Tabelle zurückgeben
Abschnitt betitelt „1.3 Eine Tabelle zurückgeben“CREATE OR REPLACE FUNCTION active_students()RETURNS TABLE(student_id INT, name TEXT)LANGUAGE sqlAS $$ SELECT id, full_name FROM students WHERE is_active = TRUE ORDER BY full_name;$$;Verwendung:
SELECT * FROM active_students();2. Stored Procedures in PostgreSQL
Abschnitt betitelt „2. Stored Procedures in PostgreSQL“Stored Procedures wurden in PostgreSQL 11 eingeführt (CREATE PROCEDURE).
Der Hauptunterschied zu Functions:
- Functions werden mit
SELECT ...aufgerufen und müssen einen Wert zurückgeben. - Procedures werden mit
CALL ...aufgerufen und müssen keinen Wert zurückgeben.
Ebenfalls wichtig:
- Procedures können in gültigen Aufrufkontexten Transaktionssteuerung (
COMMIT,ROLLBACK) durchführen.
2.1 Procedure-Beispiel: Geld überweisen
Abschnitt betitelt „2.1 Procedure-Beispiel: Geld überweisen“Angenommen, diese Tabellenstruktur:
CREATE TABLE accounts ( account_id INT PRIMARY KEY, owner_name TEXT NOT NULL, balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0));
CREATE TABLE transfer_log ( transfer_id BIGSERIAL PRIMARY KEY, from_account INT NOT NULL, to_account INT NOT NULL, amount NUMERIC(12,2) NOT NULL CHECK (amount > 0), transferred_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Procedure:
CREATE OR REPLACE PROCEDURE transfer_funds( p_from_account INT, p_to_account INT, p_amount NUMERIC(12,2))LANGUAGE plpgsqlAS $$DECLARE v_source_balance NUMERIC(12,2);BEGIN IF p_amount <= 0 THEN RAISE EXCEPTION 'Amount must be greater than zero'; END IF;
IF p_from_account = p_to_account THEN RAISE EXCEPTION 'Source and target account must differ'; END IF;
SELECT balance INTO v_source_balance FROM accounts WHERE account_id = p_from_account FOR UPDATE;
IF NOT FOUND THEN RAISE EXCEPTION 'Source account % does not exist', p_from_account; END IF;
IF v_source_balance < p_amount THEN RAISE EXCEPTION 'Insufficient funds in account %', p_from_account; END IF;
UPDATE accounts SET balance = balance - p_amount WHERE account_id = p_from_account;
UPDATE accounts SET balance = balance + p_amount WHERE account_id = p_to_account;
IF NOT FOUND THEN RAISE EXCEPTION 'Target account % does not exist', p_to_account; END IF;
INSERT INTO transfer_log(from_account, to_account, amount) VALUES (p_from_account, p_to_account, p_amount);END;$$;Die Procedure aufrufen:
CALL transfer_funds(1, 2, 50.00);3. Trigger
Abschnitt betitelt „3. Trigger“Ein Trigger ist ein Mechanismus, der automatisch Logik ausführt, wenn ein Tabellenereignis eintritt:
INSERTUPDATEDELETE- (und andere wie
TRUNCATE)
In PostgreSQL führt ein Trigger eine Trigger-Funktion aus (eine spezielle Funktion, die TRIGGER zurückgibt).
3.1 Typische Einsatzfälle für Trigger
Abschnitt betitelt „3.1 Typische Einsatzfälle für Trigger“- Automatische
updated_at-Zeitstempel - Audit-Logging (wer hat wann was geändert)
- Prüfung komplexer Geschäftsregeln
- Synchronisierung mit Summentabellen
3.2 BEFORE-UPDATE-Trigger: updated_at setzen
Abschnitt betitelt „3.2 BEFORE-UPDATE-Trigger: updated_at setzen“Dieses Beispiel zeigt, wie sich eine Zeitstempel-Spalte automatisch aktualisieren lässt, sobald eine Zeile geändert wird, ohne manuelle Logik in der Anwendung.
CREATE TABLE courses ( course_id SERIAL PRIMARY KEY, title TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Trigger-Funktion:
CREATE OR REPLACE FUNCTION set_updated_at()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN NEW.updated_at := NOW(); RETURN NEW;END;$$;Trigger-Definition:
CREATE TRIGGER trg_courses_set_updated_atBEFORE UPDATE ON coursesFOR EACH ROWEXECUTE FUNCTION set_updated_at();- BEFORE UPDATE: Der Trigger feuert bevor das Update tatsächlich geschrieben wird, sodass wir
NEW.updated_atrechtzeitig setzen können. - FOR EACH ROW: Der Trigger feuert einmal pro aktualisierter Zeile.
- NEW: In einem BEFORE-Trigger enthält
NEWdie neuen Werte, die gleich eingefügt/aktualisiert werden. Wir setzenNEW.updated_atauf die aktuelle Zeit. - RETURN NEW: Wir geben die geänderte
NEW-Zeile zurück, damit das Update mit dem aktualisierten Zeitstempel fortfährt.
Ergebnis: Bei jedem Update der Tabelle courses wird die Spalte updated_at automatisch auf die aktuelle Zeit gesetzt, ohne manuelles Zutun der Anwendung.
3.3 Audit-Trigger-Beispiel
Abschnitt betitelt „3.3 Audit-Trigger-Beispiel“Dieses Beispiel zeigt, wie man einen umfassenden Audit-Trail aufbaut, indem alle INSERT-, UPDATE- und DELETE-Operationen auf der Tabelle students protokolliert werden, samt Benutzername, Zeitstempel sowie alten und neuen Datenwerten.
Audit-Tabelle:
CREATE TABLE student_audit ( audit_id BIGSERIAL PRIMARY KEY, action TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), db_user TEXT NOT NULL, old_data JSONB, new_data JSONB);Trigger-Funktion:
CREATE OR REPLACE FUNCTION audit_students_changes()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN IF TG_OP = 'INSERT' THEN INSERT INTO student_audit(action, db_user, new_data) VALUES ('INSERT', CURRENT_USER, to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO student_audit(action, db_user, old_data, new_data) VALUES ('UPDATE', CURRENT_USER, to_jsonb(OLD), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'DELETE' THEN INSERT INTO student_audit(action, db_user, old_data) VALUES ('DELETE', CURRENT_USER, to_jsonb(OLD)); RETURN OLD; END IF;
RETURN NULL;END;$$;Trigger an die Tabelle students hängen:
CREATE TRIGGER trg_students_auditAFTER INSERT OR UPDATE OR DELETE ON studentsFOR EACH ROWEXECUTE FUNCTION audit_students_changes();- AFTER INSERT OR UPDATE OR DELETE: Der Trigger feuert nachdem die Änderung in die Tabelle geschrieben wurde (nicht davor), wir zeichnen also den Endzustand auf.
- TG_OP: Eine spezielle PostgreSQL-Variable, die sagt, welche Operation die Funktion ausgelöst hat (
'INSERT','UPDATE'oder'DELETE'). - OLD und NEW: In AFTER-Triggern (und bei UPDATE-Operationen) enthält
OLDdie vorherige Zeile,NEWdie aktualisierte. - to_jsonb(): Wandelt die ganze Zeile in JSON um, ein flexibles Format für Audit-Zwecke.
- CURRENT_USER: Eine PostgreSQL-Funktion, die den Benutzernamen zurückgibt, der die Änderung vorgenommen hat.
- RETURN NULL: Bei AFTER-Triggern wird der Rückgabewert für INSERT/UPDATE ignoriert;
NULLzurückzugeben ist Konvention.
Warum das wichtig ist:
Diese Audit-Tabelle enthält nun eine vollständige Historie, wer wann was geändert hat. Solche Aufzeichnungen sind oft für Compliance (DSGVO, SOX, Finanzvorschriften) oder zur Fehlersuche bei unerwarteten Datenänderungen nötig. Das Audit-Log lässt sich abfragen, um Fragen zu beantworten wie „Wer hat Kunde #42 wann gelöscht?” oder „Wie lautete die Note vor der Korrektur?“.
3.4 BEFORE-INSERT-Trigger: Geschäftsregel erzwingen
Abschnitt betitelt „3.4 BEFORE-INSERT-Trigger: Geschäftsregel erzwingen“Dieses Beispiel zeigt, wie man eine Geschäftsregel erzwingt: Ein Schüler darf pro Semester in nicht mehr als 5 Kurse eingeschrieben sein.
Angenommen, diese Tabellen:
CREATE TABLE semesters ( semester_id SERIAL PRIMARY KEY, name TEXT NOT NULL, -- e.g. "Fall 2025/26", "Spring 2026" start_date DATE NOT NULL, end_date DATE NOT NULL);
CREATE TABLE students ( student_id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE courses ( course_id SERIAL PRIMARY KEY, name TEXT NOT NULL, semester_id INT NOT NULL REFERENCES semesters(semester_id));
CREATE TABLE enrollments ( enrollment_id SERIAL PRIMARY KEY, student_id INT NOT NULL REFERENCES students(student_id), course_id INT NOT NULL REFERENCES courses(course_id), enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Trigger-Funktion, die die Regel erzwingt:
CREATE OR REPLACE FUNCTION check_enrollment_limit()RETURNS TRIGGERLANGUAGE plpgsqlAS $$DECLARE v_courses_count INT; v_semester_id INT;BEGIN -- determine the semester of the course being enrolled in SELECT semester_id INTO v_semester_id FROM courses WHERE course_id = NEW.course_id;
-- count how many courses this student is already enrolled in this semester SELECT COUNT(*) INTO v_courses_count FROM enrollments e JOIN courses c ON e.course_id = c.course_id WHERE e.student_id = NEW.student_id AND c.semester_id = v_semester_id;
-- enforce the limit IF v_courses_count >= 5 THEN RAISE EXCEPTION 'Student % cannot enroll in more than 5 courses per semester', NEW.student_id; END IF;
RETURN NEW;END;$$;Erklärung von check_enrollment_limit():
-
DECLARE: Zwei lokale Variablen:
v_courses_count: hält die Anzahl der Kurse, in die der Schüler bereits eingeschrieben istv_semester_id: hält die Semester-ID des Kurses, in den eingeschrieben wird
-
NEW: Eine spezielle PostgreSQL-Trigger-Variable mit den Zeilendaten, die gleich eingefügt werden.
NEW.course_idist die Kurs-ID des neuen Einschreibungssatzes,NEW.student_iddie Schüler-ID. -
SELECT … INTO: Fragt die Tabelle
coursesab, um herauszufinden, zu welchem Semester dieser Kurs gehört, und speichert das Ergebnis inv_semester_id. -
JOIN- und WHERE-Logik: Zählt, wie viele Einschreibungen dieser Schüler in diesem Semester bereits hat, indem
enrollmentsundcoursesverbunden und nachstudent_idundsemester_idgefiltert werden. -
IF … RAISE EXCEPTION: Ist die Anzahl 5 oder mehr, wird eine Exception mit der Schüler-ID ausgelöst. Diese Exception bricht das INSERT ab, bevor es die Datenbank erreicht.
-
RETURN NEW: Besteht die Prüfung (Anzahl < 5), wird
NEWunverändert zurückgegeben, sodass das INSERT fortfährt.
Trigger-Definition:
CREATE TRIGGER trg_check_enrollment_limitBEFORE INSERT ON enrollmentsFOR EACH ROWEXECUTE FUNCTION check_enrollment_limit();Wie es funktioniert:
- Versucht ein Schüler, sich in einen Kurs einzuschreiben, feuert der Trigger vor dem INSERT.
- Der Trigger fragt ab, in wie viele Kurse der Schüler in diesem Semester bereits eingeschrieben ist.
- Ist die Anzahl 5 oder mehr, löst der Trigger eine Exception aus und verhindert die Einschreibung.
- Ist die Anzahl kleiner als 5, darf die Einschreibung fortfahren.
Test:
-- student 1 enrolls in 5 courses of semester 1INSERT INTO enrollments (student_id, course_id) VALUES (1, 1); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 2); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 3); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 4); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 5); -- OK
-- attempt to enroll in a 6th courseINSERT INTO enrollments (student_id, course_id) VALUES (1, 6);-- ERROR: Student 1 cannot enroll in more than 5 courses per semesterDas ist ein klassisches Beispiel für eine Geschäftsregel aus einem ER-Diagramm, die sich nicht mit einfachen Fremdschlüsseln oder CHECK-Constraints ausdrücken lässt, aber mit einem Trigger leicht auf Datenbankebene durchsetzbar ist.
3.5 AFTER-INSERT-Trigger: Bestellsummen synchron halten
Abschnitt betitelt „3.5 AFTER-INSERT-Trigger: Bestellsummen synchron halten“Dieses Beispiel zeigt einen typischen Einsatz eines AFTER-Triggers: Nach dem Einfügen einer Bestellposition wird der Bestellkopf mit der neuen Gesamtsumme aktualisiert.
Angenommen, diese Tabellen:
CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INT NOT NULL, total_amount NUMERIC(12,2) NOT NULL DEFAULT 0);
CREATE TABLE order_items ( order_item_id SERIAL PRIMARY KEY, order_id INT NOT NULL REFERENCES orders(order_id), product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), unit_price NUMERIC(12,2) NOT NULL CHECK (unit_price >= 0));Trigger-Funktion:
CREATE OR REPLACE FUNCTION update_order_total_after_item_insert()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN UPDATE orders SET total_amount = total_amount + (NEW.quantity * NEW.unit_price) WHERE order_id = NEW.order_id;
RETURN NULL;END;$$;Trigger-Definition:
CREATE TRIGGER trg_order_items_after_insertAFTER INSERT ON order_itemsFOR EACH ROWEXECUTE FUNCTION update_order_total_after_item_insert();Wie es funktioniert:
- Eine neue Zeile wird in
order_itemseingefügt. - Der AFTER-Trigger feuert einmal für diese Zeile.
- Die Funktion liest
NEW.quantity,NEW.unit_priceundNEW.order_id. - Sie aktualisiert
orders.total_amount, indem sie die Zwischensumme der Position addiert.
4. Functions vs. Procedures vs. Trigger
Abschnitt betitelt „4. Functions vs. Procedures vs. Trigger“| Merkmal | Function | Procedure | Trigger |
|---|---|---|---|
| Aufruf | SELECT my_function(...) | CALL my_procedure(...) | automatisch bei Tabellenereignis |
| Muss einen Wert zurückgeben | ja | nein | gibt TRIGGER-Wert zurück (NEW/OLD) |
| Typischer Einsatz | Berechnungen, wiederverwendbare Abfragelogik | mehrstufige Operationen, Abläufe | automatische Reaktionen auf Datenänderungen |
| Direkt von der App aufrufbar | ja | ja | nein (indirekt über DML-Ereignis) |
5. Richtlinien für Sicherheit und Wartung
Abschnitt betitelt „5. Richtlinien für Sicherheit und Wartung“- Least Privilege nutzen:
EXECUTEauf freigegebene Routinen vergeben, nicht breite Tabellenrechte. - Objektnamen schema-qualifizieren: Im Produktivcode
public.studentsstattstudentsverwenden. - Trigger-Logik kurz halten: Schwerer Trigger-Code kann Schreibvorgänge verlangsamen.
- Nebenwirkungen dokumentieren: Jeder Trigger sollte in der Projektdokumentation sichtbar sein.
- Mit Transaktionen und Nebenläufigkeit testen: besonders bei Finanz- oder Lageroperationen.
- Routinen in Migrationen versionieren: Produktivobjekte nie manuell ohne Migrationsskripte ändern.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“Datenbankseitige Programmierung in PostgreSQL ist ein wirkungsvoller Weg, Geschäftsregeln nah an den Daten zu halten. Functions eignen sich für wiederverwendbare Berechnungen und Abfragelogik, Procedures für kontrollierte mehrstufige Operationen und Trigger automatisieren Reaktionen auf Datenänderungen. Sorgsam eingesetzt verbessern diese Mittel Konsistenz, Sicherheit und Wartbarkeit in realen Datenbanksystemen.
Über Functions, Stored Procedures und Trigger hinaus gibt es weitere datenbankseitige Möglichkeiten, die man kennen sollte:
- Views und Materialized Views (siehe Kapitel: Views im Detail)
- Constraints und Domains: Sie definieren und erzwingen gültige Datenstrukturen und Geschäftsregeln direkt auf Schemaebene.
- Row Level Security (RLS) Policies: Sie steuern, welche Zeilen ein Benutzer lesen oder ändern darf, abhängig von rollenspezifischen Bedingungen.
- Event Trigger und Extensions: Event Trigger reagieren auf DDL-Ereignisse, Extensions ergänzen wiederverwendbare Datenbankfunktionen wie Typen, Funktionen oder Module.