🕰️Historie & Audit

Gültig-von/bis per Trigger, Audit-Log als JSON, Temporal Tables und Zeitreise.

A-100  ├──── 24,90 € ────┤├──── 26,50 € ─────┤├──── 22,00 € ─────────▶
       01.01.          01.02.             15.03.              gueltig_bis = NULL
                                                              (aktuelle Version)
       Intervalle halb-offen: [gueltig_von, gueltig_bis)
       → der Wechseltag gehört genau zu einer Version

Drei Fragen, drei Werkzeuge: Was galt wann? (Historie mit Gültigkeit), Wer hat was geändert? (Audit-Log) und Wie sah die Tabelle damals aus? (System-Versionierung, Zeitreise).

Alle Trigger lesen die Zeit aus einer kleinen Tabelle uhr statt aus CURRENT_TIMESTAMP – so kannst du die Uhr „vorstellen“ und die Ergebnisse sind reproduzierbar.

🕰️ Tipp 1★★ FortgeschrittenTriggerSCD Typ 2Historisierung

Preis-Historie mit gültig_von / gültig_bis (SCD Typ 2)

😖 Problem
Ein Preis wird überschrieben – und niemand weiß mehr, was der Artikel im Februar gekostet hat. Rechnungen, Reklamationen und Auswertungen brauchen aber den damals gültigen Preis.
💡 Lösung
Ein AFTER-UPDATE-Trigger schließt die aktuelle Version (gueltig_bis = jetzt) und legt eine neue offene Version an (gueltig_bis = NULL). Das ist eine „Slowly Changing Dimension Typ 2“. Die WHEN-Bedingung sorgt dafür, dass nur echte Preisänderungen eine Version erzeugen.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 11 (EXECUTE FUNCTION)
▶ ausführbar · PGlite
CREATE TRIGGER artikel_preis_historie
AFTER UPDATE OF preis ON artikel
FOR EACH ROW WHEN (OLD.preis IS DISTINCT FROM NEW.preis)
EXECUTE FUNCTION preis_historie_fn();   -- Funktion siehe PGlite-Code

Vor 11 hieß es EXECUTE PROCEDURE (weiterhin akzeptiert, aber veraltet).

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
DELIMITER //
CREATE TRIGGER artikel_preis_historie
AFTER UPDATE ON artikel
FOR EACH ROW
BEGIN
  IF NOT (OLD.preis <=> NEW.preis) THEN
    UPDATE preis_historie SET gueltig_bis = NOW()
     WHERE artikel_nr = NEW.artikel_nr AND gueltig_bis IS NULL;
    INSERT INTO preis_historie (artikel_nr, preis, gueltig_von)
    VALUES (NEW.artikel_nr, NEW.preis, NOW());
  END IF;
END//
DELIMITER ;

Kein UPDATE OF spalte und keine WHEN-Klausel – die Bedingung steht im Rumpf. <=> ist der NULL-sichere Vergleich.

SQL Server
nur gezeigt, nicht ausgeführt
CREATE OR ALTER TRIGGER artikel_preis_historie
ON artikel AFTER UPDATE
AS
BEGIN
  SET NOCOUNT ON;
  DECLARE @jetzt datetime2 = SYSUTCDATETIME();
  -- anweisungsweise: inserted/deleted enthalten ALLE geänderten Zeilen
  UPDATE h SET gueltig_bis = @jetzt
    FROM preis_historie h
    JOIN inserted i ON i.artikel_nr = h.artikel_nr
    JOIN deleted  d ON d.artikel_nr = i.artikel_nr
   WHERE h.gueltig_bis IS NULL AND i.preis <> d.preis;
  INSERT INTO preis_historie (artikel_nr, preis, gueltig_von)
  SELECT i.artikel_nr, i.preis, @jetzt
    FROM inserted i JOIN deleted d ON d.artikel_nr = i.artikel_nr
   WHERE i.preis <> d.preis;
END;

Trigger feuern einmal pro Anweisung – immer mengenbasiert schreiben, nie „SELECT @x = preis FROM inserted“. CREATE OR ALTER ab 2016 SP1.

Oracle
nur gezeigt, nicht ausgeführt
CREATE OR REPLACE TRIGGER artikel_preis_historie
AFTER UPDATE OF preis ON artikel
FOR EACH ROW
WHEN (OLD.preis <> NEW.preis)
BEGIN
  UPDATE preis_historie SET gueltig_bis = SYSTIMESTAMP
   WHERE artikel_nr = :NEW.artikel_nr AND gueltig_bis IS NULL;
  INSERT INTO preis_historie (artikel_nr, preis, gueltig_von)
  VALUES (:NEW.artikel_nr, :NEW.preis, SYSTIMESTAMP);
END;
/

In der WHEN-Klausel ohne Doppelpunkt (NEW.preis), im Rumpf mit (:NEW.preis).

SQLite
▶ ausgeführt · sql.js
CREATE TRIGGER artikel_preis_historie
AFTER UPDATE OF preis ON artikel
WHEN old.preis IS NOT new.preis
BEGIN
  UPDATE preis_historie SET gueltig_bis = CURRENT_TIMESTAMP
   WHERE artikel_nr = new.artikel_nr AND gueltig_bis IS NULL;
  INSERT INTO preis_historie (artikel_nr, preis, gueltig_von)
  VALUES (new.artikel_nr, new.preis, CURRENT_TIMESTAMP);
END;

IS NOT ist in SQLite der NULL-sichere Ungleich-Vergleich.

⚠️ Fallstricke

  • Intervalle halb-offen speichern: [gueltig_von, gueltig_bis). Dann schließen sich Versionen lückenlos an, und eine Abfrage „Stand am“ trifft genau eine Zeile.
  • Die Historie nur per Trigger zu füllen heißt: Bulk-Importe, die Trigger umgehen (z. B. Bulk-Load ohne FIRE_TRIGGERS in SQL Server), erzeugen Lücken.
  • Zwei Änderungen in derselben Uhrzeit-Auflösung erzeugen zwei Versionen mit gleichem gueltig_von – der Primärschlüssel (artikel_nr, gueltig_von) schlägt dann Alarm. Feinere Zeitstempel oder eine Versionsnummer verwenden.
  • Einen Index auf (artikel_nr, gueltig_bis) bzw. einen partiellen Index WHERE gueltig_bis IS NULL anlegen – sonst sucht jede Änderung die offene Version per Scan.

📚 Belege

🕰️ Tipp 2★★ FortgeschrittenAuditJSONTrigger

Audit-Log: wer hat was geändert – alt und neu als JSON

😖 Problem
Für Nachvollziehbarkeit (Revision, DSGVO-Auskunft, Fehlersuche) soll jede Änderung an kunden protokolliert werden – mit altem und neuem Zustand, ohne für jede Spalte eine eigene Log-Spalte zu pflegen.
💡 Lösung
Drei Trigger (INSERT, UPDATE, DELETE) schreiben je eine Zeile in audit_log: Aktion, Schlüssel und die Zeile als JSON (alt bzw. neu). Neue Spalten landen so ohne Schemaänderung im Log.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
CREATE TRIGGER kunden_audit
AFTER INSERT OR UPDATE OR DELETE ON kunden
FOR EACH ROW EXECUTE FUNCTION audit_fn();
-- audit_fn: to_jsonb(OLD), to_jsonb(NEW), TG_OP, TG_TABLE_NAME

Ein generischer Trigger reicht für beliebig viele Tabellen. Die PostgreSQL-Doku zeigt ein ähnliches Audit-Beispiel.

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
CREATE TRIGGER kunden_audit_upd
AFTER UPDATE ON kunden FOR EACH ROW
INSERT INTO audit_log (tabelle, aktion, schluessel, alt, neu, zeit)
VALUES ('kunden', 'UPDATE', NEW.kunde_id,
        JSON_OBJECT('name', OLD.name, 'punkte', OLD.punkte),
        JSON_OBJECT('name', NEW.name, 'punkte', NEW.punkte),
        NOW());
-- analog für INSERT und DELETE (je Ereignis ein Trigger)

Ein Trigger pro Ereignis – kein „INSERT OR UPDATE“ in einem Trigger.

SQL Server
nur gezeigt, nicht ausgeführt
CREATE OR ALTER TRIGGER kunden_audit ON kunden
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
  SET NOCOUNT ON;
  INSERT INTO audit_log (tabelle, aktion, schluessel, alt, neu, zeit)
  SELECT 'kunden',
         CASE WHEN i.kunde_id IS NULL THEN 'DELETE'
              WHEN d.kunde_id IS NULL THEN 'INSERT' ELSE 'UPDATE' END,
         COALESCE(i.kunde_id, d.kunde_id),
         (SELECT d.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
         (SELECT i.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
         SYSUTCDATETIME()
    FROM inserted i
    FULL JOIN deleted d ON d.kunde_id = i.kunde_id;
END;

inserted/deleted per FULL JOIN: nur inserted = INSERT, nur deleted = DELETE, beide = UPDATE.

Oracle
nur gezeigt, nicht ausgeführt
CREATE OR REPLACE TRIGGER kunden_audit
AFTER INSERT OR UPDATE OR DELETE ON kunden
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (tabelle, aktion, schluessel, alt, neu, zeit)
  VALUES ('kunden',
          CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END,
          NVL(:NEW.kunde_id, :OLD.kunde_id),
          JSON_OBJECT('name' VALUE :OLD.name, 'punkte' VALUE :OLD.punkte),
          JSON_OBJECT('name' VALUE :NEW.name, 'punkte' VALUE :NEW.punkte),
          SYSTIMESTAMP);
END;
/

Prädikate INSERTING/UPDATING/DELETING unterscheiden die Ereignisse in einem Trigger.

SQLiteab 3.38.0 (JSON eingebaut)
▶ ausgeführt · sql.js
CREATE TRIGGER kunden_audit_upd AFTER UPDATE ON kunden BEGIN
  INSERT INTO audit_log (tabelle, aktion, schluessel, alt, neu, zeit)
  VALUES ('kunden', 'UPDATE', new.kunde_id,
          json_object('name', old.name, 'punkte', old.punkte),
          json_object('name', new.name, 'punkte', new.punkte),
          CURRENT_TIMESTAMP);
END;

Kein „ganze Zeile als JSON“ – die Spalten müssen aufgezählt werden. JSON-Funktionen sind ab 3.38.0 standardmäßig eingebaut.

⚠️ Fallstricke

  • Audit-Logs wachsen schnell: Aufbewahrungsfrist festlegen, nach Zeit partitionieren oder archivieren.
  • Personenbezogene Daten im Log sind ebenfalls personenbezogen – Löschkonzepte müssen das Log einschließen.
  • Wer geändert hat, kennt die Datenbank oft nicht (Anwendung mit einem technischen Benutzer). Den Anwendungsnutzer z. B. per Sitzungsvariable übergeben (PostgreSQL set_config, SQL Server SESSION_CONTEXT).
  • Trigger laufen in derselben Transaktion: Ein Rollback verwirft auch den Log-Eintrag – für „auch fehlgeschlagene Versuche protokollieren“ braucht es andere Mittel.

📚 Belege

🕰️ Tipp 3★★★ ProfiTemporal TablesSYSTEM_TIMESQL:2011

System-versionierte Tabellen: die Datenbank führt die Historie

😖 Problem
Selbstgebaute Historien-Trigger sind Code, der gepflegt, getestet und bei jeder Schemaänderung angepasst werden muss.
💡 Lösung
SQL Server (ab 2016) und MariaDB (ab 10.3.4) können Tabellen system-versioniert führen: Jede Änderung wandert automatisch in die Historie, abgefragt wird mit FOR SYSTEM_TIME AS OF. SQLite und PostgreSQL haben das nicht eingebaut – der ausführbare Code unten baut das Prinzip in SQLite nach (Zeilenzeitraum sys_von/sys_bis plus Historientabelle). Die Temporal-Syntax der anderen Dialekte wird nur gezeigt, nicht ausgeführt.

🧪 Ausprobieren – Vorher / Nachher

PostgreSQL-Fassung nur statisch (unten)jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- Keine System-Versionierung eingebaut (SQL:2011-Feature T180 fehlt).
-- Üblich: Trigger wie im SQLite-Nachbau oder eine Erweiterung.
-- Ab PostgreSQL 18: Anwendungszeit-Constraints, z. B.
CREATE TABLE konto_limit (
  konto_id  integer,
  gueltig   daterange,
  limit_eur integer,
  PRIMARY KEY (konto_id, gueltig WITHOUT OVERLAPS)
);   -- benötigt die Erweiterung btree_gist

WITHOUT OVERLAPS (18) verhindert überlappende Gültigkeiten – das ist Anwendungszeit, keine automatische Systemzeit-Historie.

MySQL / MariaDBab MariaDB 10.3.4
nur gezeigt, nicht ausgeführt
-- MariaDB (MySQL hat keine System-Versionierung):
CREATE TABLE konto (
  konto_id  INT PRIMARY KEY,
  inhaber   VARCHAR(100),
  limit_eur INT
) WITH SYSTEM VERSIONING;

SELECT * FROM konto FOR SYSTEM_TIME AS OF TIMESTAMP '2026-02-15 00:00:00';
SELECT * FROM konto FOR SYSTEM_TIME ALL WHERE konto_id = 1;

Die Historie liegt in derselben Tabelle (unsichtbare Spalten ROW_START/ROW_END); normale Abfragen sehen nur aktuelle Zeilen.

SQL Serverab 2016 (13.x)
nur gezeigt, nicht ausgeführt
CREATE TABLE dbo.konto (
  konto_id  int PRIMARY KEY,
  inhaber   nvarchar(100) NOT NULL,
  limit_eur int NOT NULL,
  sys_von   datetime2 GENERATED ALWAYS AS ROW START,
  sys_bis   datetime2 GENERATED ALWAYS AS ROW END,
  PERIOD FOR SYSTEM_TIME (sys_von, sys_bis)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.konto_historie));

SELECT * FROM dbo.konto FOR SYSTEM_TIME AS OF '2026-02-15';
-- außerdem: FROM … TO, BETWEEN … AND, CONTAINED IN (…), ALL
Oracleab 9i (Flashback Query)
nur gezeigt, nicht ausgeführt
-- Flashback Query liest aus UNDO-Daten (begrenzte Aufbewahrung):
SELECT * FROM konto AS OF TIMESTAMP TIMESTAMP '2026-02-15 00:00:00';

-- Für lange Aufbewahrung: Flashback Data Archive (ab 11g)
ALTER TABLE konto FLASHBACK ARCHIVE archiv_5_jahre;

Andere Technik, ähnliche Abfrage: kein SQL:2011-SYSTEM_TIME, sondern AS OF auf Basis von UNDO bzw. Archiv.

SQLitefehlt – Ersatzweg
▶ ausgeführt · sql.js
-- keine System-Versionierung
-- Nachbau: Historientabelle + Trigger + Sicht (siehe ausführbarer Code)
SELECT * FROM konto_alle
 WHERE sys_von <= :stichtag AND sys_bis > :stichtag;

⚠️ Fallstricke

  • System-Zeit ist Transaktionszeit (wann wurde es gespeichert) – nicht Geschäftszeit (ab wann gilt der Vertrag). Für rückwirkende Änderungen braucht es Anwendungszeit (gültig_von/bis selbst gepflegt).
  • Die Historie wächst unbegrenzt: Aufbewahrung planen (SQL Server: HISTORY_RETENTION_PERIOD, MariaDB: Partitionierung/DELETE HISTORY).
  • Schemaänderungen an versionierten Tabellen sind eingeschränkt (z. B. in SQL Server Versionierung vorübergehend ausschalten).

📚 Belege

🕰️ Tipp 4★★ FortgeschrittenAS OFStichtaghalb-offen

Zeitreise-Abfrage: „Wie war der Stand am …?“

😖 Problem
Aus einer Historientabelle soll der Stand zu einem Stichtag gelesen werden – genau eine gültige Version je Artikel, auch an Tagen, an denen sich ein Preis ändert.
💡 Lösung
Gültig ist die Version mit gueltig_von <= Stichtag und (gueltig_bis IS NULL oder gueltig_bis > Stichtag). Durch das halb-offene Intervall gehört der Wechseltag eindeutig zur neuen Version. Schiebe den Regler: Die Abfrage läuft bei jeder Bewegung neu in SQLite.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SELECT artikel_nr, preis
  FROM preis_historie
 WHERE gueltig_von <= DATE '2026-02-01'
   AND (gueltig_bis IS NULL OR gueltig_bis > DATE '2026-02-01');

-- elegant mit Bereichstyp: gueltig daterange
-- … WHERE gueltig @> DATE '2026-02-01'

Mit daterange-Spalte und GiST-Index ist die Stichtagssuche kurz und schnell.

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
SELECT artikel_nr, preis
  FROM preis_historie
 WHERE gueltig_von <= '2026-02-01'
   AND (gueltig_bis IS NULL OR gueltig_bis > '2026-02-01');
SQL Server
nur gezeigt, nicht ausgeführt
SELECT artikel_nr, preis
  FROM preis_historie
 WHERE gueltig_von <= '2026-02-01'
   AND (gueltig_bis IS NULL OR gueltig_bis > '2026-02-01');
-- bei Temporal Tables: FOR SYSTEM_TIME AS OF '2026-02-01'
Oracle
nur gezeigt, nicht ausgeführt
SELECT artikel_nr, preis
  FROM preis_historie
 WHERE gueltig_von <= DATE '2026-02-01'
   AND (gueltig_bis IS NULL OR gueltig_bis > DATE '2026-02-01');
SQLite
▶ ausgeführt · sql.js
SELECT artikel_nr, preis
  FROM preis_historie
 WHERE gueltig_von <= '2026-02-01'
   AND (gueltig_bis IS NULL OR gueltig_bis > '2026-02-01');

Datumswerte als ISO-Text (JJJJ-MM-TT) lassen sich korrekt als Text vergleichen.

⚠️ Fallstricke

  • BETWEEN gueltig_von AND gueltig_bis ist geschlossen – am Wechseltag passen zwei Versionen.
  • Statt gueltig_bis IS NULL ein fernes Datum (9999-12-31) für „offen“ zu speichern vereinfacht Abfragen und Indizes – dann aber konsequent überall.
  • Überlappungen verhindern: in PostgreSQL per Exclusion-Constraint bzw. ab 18 WITHOUT OVERLAPS, sonst per Trigger oder Prüfabfrage.

📚 Belege