Zum Inhalt springen

7. Datenbankseitige Programmierung

Zu Zen-Modus wechseln

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.

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.

Eine Function gibt einen Wert zurück (skalar, Zeile oder Tabelle). Functions lassen sich in SQL-Ausdrücken, SELECT-Listen oder WHERE-Klauseln nutzen.

CREATE OR REPLACE FUNCTION calculate_vat_net_to_gross(
net_amount NUMERIC,
vat_rate NUMERIC DEFAULT 0.20
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
RETURN ROUND(net_amount * (1 + vat_rate), 2);
END;
$$;

Verwendung:

SELECT calculate_vat_net_to_gross(100.00); -- 120.00
SELECT calculate_vat_net_to_gross(100.00, 0.10); -- 110.00

Eine 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_price
FROM products
WHERE net_price > 50.00
ORDER 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_price
FROM products;

Eine Function bringt aber mehrere Vorteile:

  1. Eine einzige Wahrheit: Ändert sich das Steuerrecht (etwa von 20 % auf 21 %), wird nur die Function angepasst, nicht Dutzende Abfragen im Code.
  2. Konsistenz: Alle Teile der Anwendung nutzen dieselbe Logik, das verhindert Rechenfehler.
  3. Lesbarkeit: calculate_vat_net_to_gross(price) ist klarer als ROUND(price * 1.20, 2).
  4. Wiederverwendbarkeit: Dieselbe Function lässt sich aus Berichten, APIs, Batch-Jobs und anderen Routinen aufrufen.
  5. Flexibilität: Komplexere Logik (Rundungsregeln, Sondersätze, historische Sätze) lässt sich in der Function ergänzen, ohne alle Aufrufer zu ändern.

Ist eine Function nur eine einzige Abfrage, reicht oft LANGUAGE sql:

CREATE OR REPLACE FUNCTION full_name(first_name TEXT, last_name TEXT)
RETURNS TEXT
LANGUAGE sql
AS $$
SELECT CONCAT(first_name, ' ', last_name);
$$;
CREATE OR REPLACE FUNCTION active_students()
RETURNS TABLE(student_id INT, name TEXT)
LANGUAGE sql
AS $$
SELECT id, full_name
FROM students
WHERE is_active = TRUE
ORDER BY full_name;
$$;

Verwendung:

SELECT * FROM active_students();

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.

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 plpgsql
AS $$
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);

Ein Trigger ist ein Mechanismus, der automatisch Logik ausführt, wenn ein Tabellenereignis eintritt:

  • INSERT
  • UPDATE
  • DELETE
  • (und andere wie TRUNCATE)

In PostgreSQL führt ein Trigger eine Trigger-Funktion aus (eine spezielle Funktion, die TRIGGER zurückgibt).

  • Automatische updated_at-Zeitstempel
  • Audit-Logging (wer hat wann was geändert)
  • Prüfung komplexer Geschäftsregeln
  • Synchronisierung mit Summentabellen

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 TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$;

Trigger-Definition:

CREATE TRIGGER trg_courses_set_updated_at
BEFORE UPDATE ON courses
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
  • BEFORE UPDATE: Der Trigger feuert bevor das Update tatsächlich geschrieben wird, sodass wir NEW.updated_at rechtzeitig setzen können.
  • FOR EACH ROW: Der Trigger feuert einmal pro aktualisierter Zeile.
  • NEW: In einem BEFORE-Trigger enthält NEW die neuen Werte, die gleich eingefügt/aktualisiert werden. Wir setzen NEW.updated_at auf 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.

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 TRIGGER
LANGUAGE plpgsql
AS $$
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_audit
AFTER INSERT OR UPDATE OR DELETE ON students
FOR EACH ROW
EXECUTE 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 OLD die vorherige Zeile, NEW die 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; NULL zurü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 TRIGGER
LANGUAGE plpgsql
AS $$
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 ist
    • v_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_id ist die Kurs-ID des neuen Einschreibungssatzes, NEW.student_id die Schüler-ID.

  • SELECT … INTO: Fragt die Tabelle courses ab, um herauszufinden, zu welchem Semester dieser Kurs gehört, und speichert das Ergebnis in v_semester_id.

  • JOIN- und WHERE-Logik: Zählt, wie viele Einschreibungen dieser Schüler in diesem Semester bereits hat, indem enrollments und courses verbunden und nach student_id und semester_id gefiltert 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 NEW unverändert zurückgegeben, sodass das INSERT fortfährt.

Trigger-Definition:

CREATE TRIGGER trg_check_enrollment_limit
BEFORE INSERT ON enrollments
FOR EACH ROW
EXECUTE FUNCTION check_enrollment_limit();

Wie es funktioniert:

  1. Versucht ein Schüler, sich in einen Kurs einzuschreiben, feuert der Trigger vor dem INSERT.
  2. Der Trigger fragt ab, in wie viele Kurse der Schüler in diesem Semester bereits eingeschrieben ist.
  3. Ist die Anzahl 5 oder mehr, löst der Trigger eine Exception aus und verhindert die Einschreibung.
  4. Ist die Anzahl kleiner als 5, darf die Einschreibung fortfahren.

Test:

-- student 1 enrolls in 5 courses of semester 1
INSERT INTO enrollments (student_id, course_id) VALUES (1, 1); -- OK
INSERT INTO enrollments (student_id, course_id) VALUES (1, 2); -- OK
INSERT INTO enrollments (student_id, course_id) VALUES (1, 3); -- OK
INSERT INTO enrollments (student_id, course_id) VALUES (1, 4); -- OK
INSERT INTO enrollments (student_id, course_id) VALUES (1, 5); -- OK
-- attempt to enroll in a 6th course
INSERT INTO enrollments (student_id, course_id) VALUES (1, 6);
-- ERROR: Student 1 cannot enroll in more than 5 courses per semester

Das 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 TRIGGER
LANGUAGE plpgsql
AS $$
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_insert
AFTER INSERT ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_order_total_after_item_insert();

Wie es funktioniert:

  1. Eine neue Zeile wird in order_items eingefügt.
  2. Der AFTER-Trigger feuert einmal für diese Zeile.
  3. Die Funktion liest NEW.quantity, NEW.unit_price und NEW.order_id.
  4. Sie aktualisiert orders.total_amount, indem sie die Zwischensumme der Position addiert.
MerkmalFunctionProcedureTrigger
AufrufSELECT my_function(...)CALL my_procedure(...)automatisch bei Tabellenereignis
Muss einen Wert zurückgebenjaneingibt TRIGGER-Wert zurück (NEW/OLD)
Typischer EinsatzBerechnungen, wiederverwendbare Abfragelogikmehrstufige Operationen, Abläufeautomatische Reaktionen auf Datenänderungen
Direkt von der App aufrufbarjajanein (indirekt über DML-Ereignis)
  1. Least Privilege nutzen: EXECUTE auf freigegebene Routinen vergeben, nicht breite Tabellenrechte.
  2. Objektnamen schema-qualifizieren: Im Produktivcode public.students statt students verwenden.
  3. Trigger-Logik kurz halten: Schwerer Trigger-Code kann Schreibvorgänge verlangsamen.
  4. Nebenwirkungen dokumentieren: Jeder Trigger sollte in der Projektdokumentation sichtbar sein.
  5. Mit Transaktionen und Nebenläufigkeit testen: besonders bei Finanz- oder Lageroperationen.
  6. Routinen in Migrationen versionieren: Produktivobjekte nie manuell ohne Migrationsskripte ändern.

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:

  1. Views und Materialized Views (siehe Kapitel: Views im Detail)
  2. Constraints und Domains: Sie definieren und erzwingen gültige Datenstrukturen und Geschäftsregeln direkt auf Schemaebene.
  3. Row Level Security (RLS) Policies: Sie steuern, welche Zeilen ein Benutzer lesen oder ändern darf, abhängig von rollenspezifischen Bedingungen.
  4. Event Trigger und Extensions: Event Trigger reagieren auf DDL-Ereignisse, Extensions ergänzen wiederverwendbare Datenbankfunktionen wie Typen, Funktionen oder Module.