Prüfregeln aus dem Schema ableiten — was die Metadaten schon wissen

Die Regel „country_code ist Pflicht“ steht zweimal in der Datenbank: einmal als NOT NULL im Schema der Zieltabelle, einmal als handgetippte Zeile in der Prüf-Konfiguration. Beim nächsten ALTER TABLE ändert sich nur eine der beiden Stellen, und die Prüfung wird still falsch. Wer Prüfregeln ableiten kann, statt sie zu tippen, muss diese Wiederholung nicht mehr pflegen: Die Metadaten wissen längst, welche Spalten Pflicht sind, welche Schlüssel eindeutig sein müssen und welche Wertebereiche die Typen erlauben. Und wer den abgeleiteten Stand beim Deployment festschreibt, erkennt Schema-Drift, statt sie zu erleiden.

Das Wichtigste vorab:

  • NOT-NULL, Schlüssel-Eindeutigkeit und Typ-Grenzen lassen sich mechanisch aus information_schema projizieren — samt Meldungstext und fachlichem Schlüssel.
  • Beim Deployment projiziert, nicht bei jedem Lauf: Ein ETL-Prozess ist ein Vertrag zwischen Quell- und Zielsystem. Die abgeleiteten Regeln werden beim Deployment erzeugt und persistiert, Schema-Änderungen werden nicht automatisch übernommen.
  • Die Live-Projektion bleibt als Drift-Detektor: Ein Diff gegen den deployten Stand zeigt, wann der Vertrag neu verhandelt werden muss.
  • Read-only ist keine Bequemlichkeit: Abgeleitete Regeln werden weder editiert noch deaktiviert, das is_active-Flag gehört den manuellen Regeln. Wer ändern will, ändert das Schema und deployt neu.
  • Handarbeit bleibt für die Fachlogik, die im Schema nicht steht. Genau die verdient die Review-Zeit.

Voraussetzung: Postgres als Beispiel-Engine und das Framework aus dem Sub-Hub Datenqualität mit SQL prüfen mit der gemeinsamen Fehlertabelle und der Regel-Config dq.check_rule. Das Metadaten-Prinzip braucht nur information_schema und ist damit auf jede Engine übertragbar, die es anbietet. Der gezeigte Code verwendet Postgres-Syntax und muss für andere Engines an deren Dialekt angepasst werden.

Inhalt

Die Regel, die schon dasteht

Das Framework aus dem Sub-Hub arbeitet mit einer Config-Tabelle: eine Zeile ist eine Regel. Das ist bewusst so, denn eine neue Prüfung ist eine Datenzeile statt einer Code-Änderung, und die Fachseite kann mitlesen. Ausgerollt wird auch so eine Zeile über ein Deployment — warum das so sein muss, zeigt dieser Artikel weiter unten. Der Preis der Konfiguration zeigt sich erst mit der Zeit. Ein großer Teil der Zeilen, die man in so eine Konfiguration tippt, wiederholt nur, was die Zieltabelle ohnehin erzwingt:

  1: CREATE SCHEMA IF NOT EXISTS core;
  2: 
  3: CREATE TABLE core.customer
  4: (
  5:     customer_id    int          NOT NULL
  6:    ,source_system  varchar(10)  NOT NULL
  7:    ,source_id      varchar(20)  NOT NULL
  8:    ,country_code   varchar(2)   NOT NULL
  9:    ,email          varchar(100)
 10:    ,age            numeric(3,0)
 11:    ,CONSTRAINT pk_customer         PRIMARY KEY (customer_id)
 12:    ,CONSTRAINT uq_customer_source  UNIQUE (source_system, source_id)
 13: );

Diese eine Tabelle diktiert bereits sieben Prüfregeln für die Staging-Schicht: vier Pflichtfelder (Zeilen 5 bis 8), zwei eindeutige Schlüssel (Zeilen 11 und 12) und die Längen- und Wertebereichs-Grenzen der Typen. Wer sie von Hand in dq.check_rule einträgt, erzeugt sieben Kopien einer Wahrheit, die schon im Schema steht.

Kopien driften. Kommt später eine Spalte phone varchar(30) NOT NULL dazu, muss jemand daran denken, auch die Konfiguration zu ergänzen. Vergisst er es, schweigt die Prüfung, und der Load bricht am Ziel-Constraint ab — genau das Szenario, das das Vorsieb verhindern sollte. Fällt umgekehrt ein NOT NULL weg, prüft die alte Regel weiter gegen eine Anforderung, die es nicht mehr gibt, und produziert Befunde ohne Grundlage. Beide Fehler sind leise. Niemand bekommt eine Meldung, dass Config und Schema auseinandergelaufen sind.

Welche Prüfregeln sich ableiten lassen

Aus dem Drift-Problem führt ein einfacher Weg heraus: Man kann diese Prüfregeln ableiten, statt sie zu tippen. Alles, was das Zielschema als Constraint oder als deklarierte Typ-Grenze erzwingt, lässt sich mechanisch in eine Regel-Zeile übersetzen. Die Zuordnung zu den Regel-Typen des Frameworks ist dabei eins zu eins:

Metadaten-QuelleAbgeleitete PrüfungRegel-Typ im FrameworkDimension
is_nullable = 'NO'Pflichtfeld-Prüfungconstraint (WHERE-Klausel)Vollständigkeit
PRIMARY KEY / UNIQUEEindeutigkeit, Kardinalität 1 (Schlüsselspalten sämtlich NOT NULL)uniqueEindeutigkeit
FOREIGN KEYReferenz-Prüfung gegen die Master-Tabelle (siehe Ausblick unten)lookupKonsistenz / Integrität
character_maximum_lengthnumeric_precision/_scaleLängen- und Wertebereichs-Grenzeconstraint (WHERE-Klausel)Validität

Auf welche Qualitäts-Dimensionen diese Prüfungen einzahlen, ordnet der Konzept-Artikel Datenqualität: Dimensionen und Fehlerklassen ein. Das Muster selbst stammt aus einem Metadaten-getriebenen ETL-Projekt: Dort lief die Projektion über ein eigenes Metadaten-Modell mit fachlichen Zusatz-Informationen wie Business- und Alternate-Keys und einem Null-Handling je Spalte. Für diesen Artikel ist es auf information_schema übertragen, damit es ohne eigenes Modell nachvollziehbar bleibt. Am Prinzip ändert das nichts: Die Regeln stehen in den Metadaten, die Projektion liest sie nur aus.

NOT-NULL aus is_nullable

Die einfachste Projektion. Jede Pflicht-Spalte der Zieltabelle wird zur Pflichtfeld-Prüfung auf der Staging-Schicht — als fertige Zeile im Format der Config-Tabelle, inklusive where_clause und Meldungstext:

  1: SELECT
  2:     'constraint'                            AS check_type
  3:    ,'staging'                               AS schema_name
  4:    ,T01.table_name
  5:    ,T01.column_name                         AS check_column
  6:    ,format('%I IS NULL', T01.column_name)   AS where_clause
  7:    ,'E'                                     AS severity
  8:    ,format('%s ist Pflichtfeld im Zielschema', T01.column_name) AS message
  9: FROM
 10:    information_schema.columns T01
 11: WHERE
 12:        T01.table_schema = 'core'
 13:    AND T01.table_name   = 'customer'
 14:    AND T01.is_nullable  = 'NO'
 15: ORDER BY
 16:    T01.ordinal_position;

Für die Demo-Tabelle liefert das vier Zeilen:

check_columnwhere_clauseseveritymessage
customer_idcustomer_id IS NULLEcustomer_id ist Pflichtfeld im Zielschema
source_systemsource_system IS NULLEsource_system ist Pflichtfeld im Zielschema
source_idsource_id IS NULLEsource_id ist Pflichtfeld im Zielschema
country_codecountry_code IS NULLEcountry_code ist Pflichtfeld im Zielschema

Zwei Details lohnen den Blick. Das format() mit %I in Zeile 6 quotet den Spaltennamen als Bezeichner — ein Spaltenname mit Sonderzeichen ergibt eine korrekte, keine kaputte where_clause. Und die Projektion zielt auf staging (Zeile 3), obwohl sie aus core liest: Geprüft wird in der Quelle, erzwungen wird am Ziel. Das ist die Arbeitsteilung des ganzen Frameworks. Was am Ziel als Constraint scheitern würde, findet das Vorsieb vorher.

Eindeutigkeit aus PRIMARY KEY und UNIQUE

Schlüssel liegen in zwei Katalog-Sichten: information_schema.table_constraints kennt die Constraints, information_schema.key_column_usage ihre Spalten. Bei mehrspaltigen Schlüsseln müssen die Spalten positionsrichtig sortiert werden, also nach ordinal_position innerhalb des Schlüssels. Dieses Feld zählt nicht die Spaltenposition in der Tabelle, sondern die Position im Schlüssel, und liefert damit genau die Reihenfolge aus dem DDL. Ohne ORDER BY gibt array_agg die Spalten in der Reihenfolge aus, in der der Join sie zufällig liefert, und diese Reihenfolge ist in Postgres weder garantiert noch über die Zeit stabil. Ein anderer Ausführungsplan genügt, und aus source_system,source_id wird beim nächsten Lauf source_id,source_system.

Für das Prüfergebnis wäre das folgenlos, denn ein GROUP BY über beide Spalten findet in jeder Reihenfolge dieselben Duplikate. Der Schaden entsteht eine Ebene höher, weil check_column die Schlüsselspalten als Komma-Liste trägt und diese Liste die Regel identifiziert. Der Runner splittet sie zur Laufzeit wieder auf, baut daraus das mehrspaltige GROUP BY und schreibt im Fehlerprotokoll zu jeder gelisteten Spalte den zugehörigen Wert nach error_value. Kippt die Reihenfolge, sieht dieselbe Regel beim nächsten Lauf aus wie eine andere, der String-Abgleich gegen das Ziel-Constraint scheitert, und die Spalte-Wert-Paare im Protokoll stehen in anderer Ordnung. Auch die Meldung leidet, denn wer „Schluessel (source_id, source_system) nicht eindeutig“ liest, muss die Reihenfolge im Kopf gegen das DDL zurücksortieren. Die Projektion sortiert deshalb genau einmal, beim Einsammeln in das Array:

  1: WITH
  2: CTE_key_column AS
  3: (
  4:    SELECT
  5:        T01.table_name
  6:       ,T01.constraint_name
  7:       ,array_agg(T02.column_name ORDER BY T02.ordinal_position) AS key_column
  8:    FROM
  9:       information_schema.table_constraints T01
 10:       INNER JOIN information_schema.key_column_usage T02
 11:       ON
 12:             T02.constraint_schema = T01.constraint_schema
 13:         AND T02.constraint_name   = T01.constraint_name
 14:       INNER JOIN information_schema.columns T03
 15:       ON
 16:             T03.table_schema = T02.table_schema
 17:         AND T03.table_name   = T02.table_name
 18:         AND T03.column_name  = T02.column_name
 19:    WHERE
 20:           T01.table_schema    = 'core'
 21:       AND T01.table_name      = 'customer'
 22:       AND T01.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
 23:    GROUP BY
 24:        T01.table_name
 25:       ,T01.constraint_name
 26:    HAVING
 27:       bool_and(T03.is_nullable = 'NO')
 28: )
 29: SELECT
 30:     'unique'                                  AS check_type
 31:    ,'staging'                                 AS schema_name
 32:    ,table_name
 33:    ,array_to_string(key_column, ',')          AS check_column
 34:    ,1                                         AS max_occurrence
 35:    ,'E'                                       AS severity
 36:    ,format('Schluessel (%s) nicht eindeutig'
 37:           ,array_to_string(key_column, ', ')) AS message
 38: FROM
 39:    CTE_key_column;

Das Ergebnis: eine Regel pro Schlüssel, nicht pro Spalte.

check_columnmax_occurrencemessage
customer_id1Schluessel (customer_id) nicht eindeutig
source_system,source_id1Schluessel (source_system, source_id) nicht eindeutig

Der zusammengesetzte Schlüssel aus Zeile 12 des Tabellen-DDL landet positionsrichtig als Komma-Liste source_system,source_id in check_column, also genau in der Form, die der Runner erwartet und wieder aufsplittet.

Das HAVING in Zeile 27 zieht zusätzlich eine semantische Grenze. Projiziert werden nur Schlüssel, deren Spalten sämtlich NOT NULL sind. Dafür sorgt der zusätzliche Join auf information_schema.columns. Der Grund liegt in der NULL-Semantik von UNIQUE: Postgres erlaubt unter dem Default NULLS DISTINCT beliebig viele fehlende Werte in einer UNIQUE-Spalte, während das GROUP BY der Prüfung mehrere NULL zu einer Gruppe zusammenfasst und als Duplikat melden würde. Eine ungefiltert projizierte Regel auf einen nullable Schlüssel wäre also strenger als der Constraint, den sie zu spiegeln behauptet — sie würde Zeilen beanstanden, die das Ziel anstandslos annimmt. Bei einem PRIMARY KEY ist die Bedingung immer erfüllt. Ein UNIQUE über nullable Spalten bleibt dagegen bewusst draußen und gehört als benutzerdefinierte Regel angelegt, mit der NULL-Behandlung, die fachlich gemeint ist. Wie die Eindeutigkeits-Prüfung mit zusammengesetzten Schlüsseln und der NULL-Semantik von UNIQUE im Detail umgeht, vertieft der Spoke Duplikate finden mit SQL. Die Kardinalität ist bei abgeleiteten Schlüssel-Regeln immer 1: Ein UNIQUE-Constraint kennt kein „höchstens dreimal“.

Auch die Fremdschlüssel der Zieltabelle lassen sich zu lookup-Regeln projizieren. Das ist hier ausdrücklich ein Ausblick und kein Rezept, denn diese Projektion braucht mehr Metadaten als die beiden oben. information_schema.referential_constraints nennt nur die beiden beteiligten Constraints, also den Fremdschlüssel und den referenzierten Primär- oder Unique-Schlüssel. Die Spalten selbst stehen wieder in key_column_usage, und zwar auf beiden Seiten: Die verweisenden Spalten liefert der Zugriff über den Fremdschlüssel-Namen in ihrer ordinal_position, die referenzierten Spalten ein zweiter Zugriff auf dieselbe Sicht über den Namen des Ziel-Constraints. Zusammengeführt werden beide Seiten über position_in_unique_constraint, das für jede Fremdschlüssel-Spalte angibt, an welcher Stelle des referenzierten Schlüssels ihr Gegenstück steht. Erst diese Zuordnung ergibt die Spaltenpaare, aus denen die Prüfung ihr LEFT JOIN … IS NULL baut. Die Lookup-Routine nimmt Kind- und Master-Spalten dafür je als Komma-Liste entgegen, und beide Listen müssen positionsgleich sein: Das erste Kind-Feld gehört zum ersten Master-Feld, Paar für Paar. Die Projektion muss also beide Listen in exakt derselben Schlüssel-Reihenfolge erzeugen, sonst joint die generierte Prüfung falsche Paare. Bei der NULL-Semantik sind Katalog und Routine sich dagegen einig: Unter dem Postgres-Standard MATCH SIMPLE akzeptiert ein zusammengesetzter Fremdschlüssel jede Zeile, in der auch nur eine der beteiligten Spalten NULL ist, und genau diese Zeilen lässt die Lookup-Prüfung ebenfalls aus, weil ein fehlender Wert ein Vollständigkeits-Befund ist und kein Integritäts-Befund. Wer den Match-Typ im Katalog nachschlägt, findet ihn dort als match_option = 'NONE' und nicht als SIMPLE. Ein als MATCH FULL angelegter Fremdschlüssel stünde dort als FULL und bräuchte eine strengere Prüfung, denn er weist teilweise gefüllte Schlüssel zurück — auch deshalb bleibt die Projektion hier Ausblick. Wie die Prüfung auf verwaiste Datensätze selbst funktioniert, zeigt der zugehörige Spoke.

Typ-Grenzen aus numeric_precision und character_maximum_length

Auch die Datentypen selbst sind Prüfregeln. Ein varchar(2) sagt „höchstens zwei Zeichen“, ein numeric(3,0) sagt „betragsmäßig unter 1000″. In der Staging-Schicht, die solche Werte noch als ungeprüfte Texte oder großzügige Typen hält, wird daraus eine Wertebereichs-Regel:

  1: SELECT
  2:     'constraint'      AS check_type
  3:    ,'staging'         AS schema_name
  4:    ,T01.table_name
  5:    ,T01.column_name   AS check_column
  6:    ,CASE
  7:        WHEN T01.character_maximum_length IS NOT NULL
  8:        THEN format('length(%I) > %s'
  9:                   ,T01.column_name
 10:                   ,T01.character_maximum_length)
 11:        ELSE format('abs(%I) >= %s'
 12:                   ,T01.column_name
 13:                   ,trim_scale(10::numeric ^ (T01.numeric_precision - T01.numeric_scale))::text)
 14:     END               AS where_clause
 15:    ,'E'               AS severity
 16:    ,CASE
 17:        WHEN T01.character_maximum_length IS NOT NULL
 18:        THEN format('%s laenger als %s Zeichen'
 19:                   ,T01.column_name
 20:                   ,T01.character_maximum_length)
 21:        ELSE format('%s ausserhalb von numeric(%s,%s)'
 22:                   ,T01.column_name
 23:                   ,T01.numeric_precision
 24:                   ,T01.numeric_scale)
 25:     END               AS message
 26: FROM
 27:    information_schema.columns T01
 28: WHERE
 29:        T01.table_schema = 'core'
 30:    AND T01.table_name   = 'customer'
 31:    AND (   T01.character_maximum_length IS NOT NULL
 32:         OR T01.numeric_precision_radix = 10)
 33: ORDER BY
 34:    T01.ordinal_position;

check_columnwhere_clausemessage
source_systemlength(source_system) > 10source_system laenger als 10 Zeichen
source_idlength(source_id) > 20source_id laenger als 20 Zeichen
country_codelength(country_code) > 2country_code laenger als 2 Zeichen
emaillength(email) > 100email laenger als 100 Zeichen
ageabs(age) >= 1000age ausserhalb von numeric(3,0)

Der Filter in Zeile 32 ist das Kleingedruckte dieser Projektion. Bei int– und bigint-Spalten zählt numeric_precision Bits, nicht Dezimalstellen — information_schema.columns verrät das über numeric_precision_radix = 2. Ohne den Radix-Filter würde die Projektion für customer_id eine Grenze von 10 hoch 32 behaupten, was weder der Typ-Semantik entspricht noch eine sinnvolle Prüfung ergibt. Nur bei explizit deklarierten numeric(p,s)-Typen (Radix 10) trägt die Präzision eine fachliche Aussage.

Beim Deployment projizieren, nicht bei jedem Lauf

Was macht man mit den drei Abfragen? Zwei naheliegende Antworten haben je einen Haken. Die erste: die Ergebnisse von Hand per INSERT INTO dq.check_rule in die Konfiguration übernehmen. Das automatisiert nur den Tipp-Aufwand. Die eingefügten Zeilen wären editierbare Kopien, und beim nächsten ALTER TABLE wären sie veraltet. Die zweite: die Projektion als View direkt zum Laufzeit-Regelwerk machen, dann ist jede Regel in jedem Moment frisch. Genau diese Frische ist der Haken der zweiten Antwort.

Ein ETL-Prozess ist ein Vertrag zwischen Quell- und Zielsystem, und dieser Vertrag gilt zum Zeitpunkt der Entwicklung. Ändert sich eine der beiden Seiten, wird er neu verhandelt und nicht automatisch angepasst. Eine Live-View würde aber genau das tun, in beide Richtungen. Wird das Ziel strenger, etwa durch eine neue NOT NULL-Spalte, scheitert der Lauf, der gestern grün war, heute ohne jedes Deployment auf der ETL-Seite. Wird das Ziel laxer, weil ein Constraint fällt, lässt die View die zugehörige Prüfung stillschweigend fallen, und das Vorsieb weicht auf, ohne dass es jemand entschieden hat. Dazu kommt der Audit-Blick: Welche Regeln bei einem bestimmten Lauf galten, kann eine View prinzipiell nicht beantworten.

Zur Einordnung: Innerhalb einer einzelnen Anwendung, die ihr eigenes Schema validiert, ist die Live-Ableitung das richtige Modell. Dort gibt es keine zweite Vertragspartei, das Schema ist die einzige Wahrheit. Die Vertragslogik beginnt, sobald zwei Systeme beteiligt sind.

Der tragfähige Schluss trennt deshalb zwei Zeitpunkte. Die Projektion selbst bleibt eine View — das Werkzeug, das die abgeleiteten Regeln jederzeit frisch aus den Metadaten berechnen kann:

  1: CREATE OR REPLACE VIEW dq.derived_rule AS
  2: WITH
  3: CTE_pk_column AS
  4: (
  5:    -- Primaerschluessel der Zieltabelle = fachlicher Schluessel der Fehlertabelle
  6:    SELECT
  7:        T01.table_name
  8:       ,array_agg(T02.column_name ORDER BY T02.ordinal_position) AS pk_column
  9:    FROM
 10:       information_schema.table_constraints T01
 11:       INNER JOIN information_schema.key_column_usage T02
 12:       ON
 13:             T02.constraint_schema = T01.constraint_schema
 14:         AND T02.constraint_name   = T01.constraint_name
 15:    WHERE
 16:           T01.table_schema    = 'core'
 17:       AND T01.constraint_type = 'PRIMARY KEY'
 18:    GROUP BY
 19:       T01.table_name
 20:    HAVING
 21:       count(*) <= 3
 22: )
 23: ,CTE_key_column AS
 24: (
 25:    -- jeder Schluessel als geordnetes Spalten-Array; nur Schluessel, deren
 26:    -- Spalten saemtlich NOT NULL sind (sonst prueft die Regel strenger als
 27:    -- der Constraint - Postgres-Default NULLS DISTINCT)
 28:    SELECT
 29:        T01.table_name
 30:       ,T01.constraint_name
 31:       ,array_agg(T02.column_name ORDER BY T02.ordinal_position) AS key_column
 32:    FROM
 33:       information_schema.table_constraints T01
 34:       INNER JOIN information_schema.key_column_usage T02
 35:       ON
 36:             T02.constraint_schema = T01.constraint_schema
 37:         AND T02.constraint_name   = T01.constraint_name
 38:       INNER JOIN information_schema.columns T03
 39:       ON
 40:             T03.table_schema = T02.table_schema
 41:         AND T03.table_name   = T02.table_name
 42:         AND T03.column_name  = T02.column_name
 43:    WHERE
 44:           T01.table_schema    = 'core'
 45:       AND T01.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
 46:    GROUP BY
 47:        T01.table_name
 48:       ,T01.constraint_name
 49:    HAVING
 50:       bool_and(T03.is_nullable = 'NO')
 51: )
 52: -- NOT-NULL aus is_nullable
 53: SELECT
 54:     'constraint'                            AS check_type
 55:    ,'staging'                               AS schema_name
 56:    ,T01.table_name
 57:    ,T02.pk_column[1]                        AS id1_column
 58:    ,T02.pk_column[2]                        AS id2_column
 59:    ,T02.pk_column[3]                        AS id3_column
 60:    ,T01.column_name                         AS check_column
 61:    ,format('%I IS NULL', T01.column_name)   AS where_clause
 62:    ,1                                       AS max_occurrence
 63:    ,'E'                                     AS severity
 64:    ,format('%s ist Pflichtfeld im Zielschema', T01.column_name) AS message
 65: FROM
 66:    information_schema.columns T01
 67:    INNER JOIN CTE_pk_column T02
 68:    ON
 69:      T02.table_name = T01.table_name
 70: WHERE
 71:        T01.table_schema = 'core'
 72:    AND T01.is_nullable  = 'NO'
 73: UNION ALL
 74: -- Eindeutigkeit aus PRIMARY KEY / UNIQUE
 75: SELECT
 76:     'unique'
 77:    ,'staging'
 78:    ,T01.table_name
 79:    ,T02.pk_column[1]
 80:    ,T02.pk_column[2]
 81:    ,T02.pk_column[3]
 82:    ,array_to_string(T01.key_column, ',')
 83:    ,NULL
 84:    ,1
 85:    ,'E'
 86:    ,format('Schluessel (%s) nicht eindeutig'
 87:           ,array_to_string(T01.key_column, ', '))
 88: FROM
 89:    CTE_key_column T01
 90:    INNER JOIN CTE_pk_column T02
 91:    ON
 92:      T02.table_name = T01.table_name;

Die CTE in den Zeilen 3 bis 22 holt nebenbei noch etwas aus den Metadaten, das die Config-Tabelle sonst ebenfalls von Hand verlangt: den fachlichen Schlüssel, über den die Fehlertabelle einen Befund später dem Quell-Datensatz zuordnet. Auch der steht im Schema — es ist der Primärschlüssel der Zieltabelle. Als geordnetes Array verteilt er sich auf id1_column bis id3_column, und das HAVING count(*) <= 3 in Zeile 21 zieht die Grenze des Fehlertabellen-Kontrakts: Mehr als drei Zuordnungs-Spalten trägt sie nicht.

Zum Regelwerk wird die Projektion erst durch den Deployment-Schritt: Er liest die View genau einmal und schreibt das Ergebnis in eine eigene Tabelle. Diese Tabelle ist der unterschriebene Vertrag. Sie hält fest, welche abgeleiteten Regeln zum Zeitpunkt des Deployments galten, und der Runner liest ausschließlich sie:

  1: CREATE TABLE dq.deployed_rule
  2: (
  3:     check_type      text        NOT NULL
  4:    ,schema_name     text        NOT NULL
  5:    ,table_name      text        NOT NULL
  6:    ,id1_column      text
  7:    ,id2_column      text
  8:    ,id3_column      text
  9:    ,check_column    text        NOT NULL
 10:    ,where_clause    text
 11:    ,max_occurrence  int         NOT NULL DEFAULT 1
 12:    ,severity        char(1)     NOT NULL
 13:    ,message         text        NOT NULL
 14:    ,deployed_on     timestamptz NOT NULL DEFAULT now()
 15: );
 16: 
 17: -- Deployment-Schritt: den Vertrag unterschreiben. Laeuft beim Deployment
 18: -- gegen das Zielsystem, nicht bei jedem Lauf des Runners.
 19: DELETE FROM dq.deployed_rule;
 20: 
 21: INSERT INTO dq.deployed_rule
 22: (
 23:     check_type
 24:    ,schema_name
 25:    ,table_name
 26:    ,id1_column
 27:    ,id2_column
 28:    ,id3_column
 29:    ,check_column
 30:    ,where_clause
 31:    ,max_occurrence
 32:    ,severity
 33:    ,message
 34: )
 35: SELECT
 36:     check_type
 37:    ,schema_name
 38:    ,table_name
 39:    ,id1_column
 40:    ,id2_column
 41:    ,id3_column
 42:    ,check_column
 43:    ,where_clause
 44:    ,max_occurrence
 45:    ,severity
 46:    ,message
 47: FROM
 48:    dq.derived_rule;

Die Tabelle trägt bewusst kein active-Flag. Das is_active der Config-Tabelle gehört ausschließlich den manuellen Regeln, denn eine abgeleitete Regel wird weder editiert noch deaktiviert. Das Feld deployed_on dokumentiert nebenbei, wann der Vertrag unterschrieben wurde.

Der Vertragsgedanke endet nicht bei den abgeleiteten Regeln. Auch die manuellen Regeln der Config-Tabelle ändern sich nur im Zuge eines Deployments — eingefügt, bearbeitet oder über is_active geschaltet wird versioniert, nicht live. Der Grund ist derselbe wie beim Schema: Eine Regelliste, die zwischen zwei Deployments von Hand umgebaut werden kann, verrät beim Draufschauen nicht mehr, was im letzten Lauf galt. Das Ausführungsprotokoll hält das zwar fest, aber die Liste soll vorhersagen, was läuft, nicht nur dokumentieren, was lief. is_active ist damit ein versionierter Schalter, kein Live-Regler.

Der Prüf-Umfang einer Tabelle ist jetzt — innerhalb der Grenzen, die die Projektion selbst zieht — die Vereinigung aus beiden Welten: die deployten abgeleiteten Regeln plus die aktiven handgeschriebenen aus der Config-Tabelle. Dazu eine einzige benutzerdefinierte Regel als Beispiel — eine Altersgrenze, die in keinem Constraint steht:

  1: INSERT INTO dq.check_rule
  2:     (check_type,   schema_name, table_name, id1_column,    check_column, where_clause, severity, message)
  3: VALUES
  4:     ('constraint', 'staging',   'customer', 'customer_id', 'age',        'age < 18',   'W', 'Kunde unter 18 - fachlich pruefen');
  5: 
  6: SELECT
  7:     'abgeleitet'          AS origin
  8:    ,check_type
  9:    ,check_column
 10:    ,where_clause
 11:    ,severity
 12:    ,message
 13: FROM
 14:    dq.deployed_rule
 15: UNION ALL
 16: SELECT
 17:     'benutzerdefiniert'
 18:    ,check_type
 19:    ,check_column
 20:    ,where_clause
 21:    ,severity
 22:    ,message
 23: FROM
 24:    dq.check_rule
 25: WHERE
 26:    active
 27: ORDER BY
 28:     origin
 29:    ,check_type
 30:    ,check_column;

origincheck_typecheck_columnwhere_clauseseverity
abgeleitetconstraintcountry_codecountry_code IS NULLE
abgeleitetconstraintcustomer_idcustomer_id IS NULLE
abgeleitetconstraintsource_idsource_id IS NULLE
abgeleitetconstraintsource_systemsource_system IS NULLE
abgeleitetuniquecustomer_idE
abgeleitetuniquesource_systemE
benutzerdefiniertconstraintageage < 18W

Sechs von sieben Regeln dieser Tabelle kommen aus dem Schema. Getippt wurde genau eine — die, die tatsächlich Fachwissen enthält. Wie groß dieser Anteil ausfällt, hängt allerdings an der Tabelle: Eine Zieltabelle ohne Pflichtfelder und ohne Schlüssel-Constraints liefert der Projektion schlicht nichts.

Die Projektion als Drift-Detektor

Was passiert, wenn sich das Zielschema nach dem Deployment ändert? Am laufenden Prozess zunächst: nichts. Der Runner liest den deployten Stand, und genau das ist der Sinn des Modells. Unsichtbar bleibt die Änderung trotzdem nicht, denn die View berechnet weiterhin den Live-Stand — und die Differenz zwischen beiden ist maschinell abfragbar:

  1: ALTER TABLE core.customer ALTER COLUMN email SET NOT NULL;
  2: ALTER TABLE core.customer DROP CONSTRAINT uq_customer_source;
  3: 
  4: SELECT
  5:     'neu'         AS drift
  6:    ,check_type
  7:    ,check_column
  8:    ,message
  9: FROM
 10:    (
 11:       SELECT check_type, schema_name, table_name, id1_column, id2_column,
 12:              id3_column, check_column, where_clause, max_occurrence, severity, message
 13:       FROM
 14:          dq.derived_rule
 15:       EXCEPT
 16:       SELECT check_type, schema_name, table_name, id1_column, id2_column,
 17:              id3_column, check_column, where_clause, max_occurrence, severity, message
 18:       FROM
 19:          dq.deployed_rule
 20:    ) T01
 21: UNION ALL
 22: SELECT
 23:     'weggefallen' AS drift
 24:    ,check_type
 25:    ,check_column
 26:    ,message
 27: FROM
 28:    (
 29:       SELECT check_type, schema_name, table_name, id1_column, id2_column,
 30:              id3_column, check_column, where_clause, max_occurrence, severity, message
 31:       FROM
 32:          dq.deployed_rule
 33:       EXCEPT
 34:       SELECT check_type, schema_name, table_name, id1_column, id2_column,
 35:              id3_column, check_column, where_clause, max_occurrence, severity, message
 36:       FROM
 37:          dq.derived_rule
 38:    ) T02
 39: ORDER BY
 40:     drift
 41:    ,check_column;

driftcheck_typecheck_columnmessage
neuconstraintemailemail ist Pflichtfeld im Zielschema
weggefallenuniquesource_system,source_idSchluessel (source_system, source_id) nicht eindeutig

Beide Richtungen des Diffs erzählen verschiedene Geschichten. Eine neue Zeile heißt: Das Ziel ist strenger geworden. Ohne Redeploy kennt das Vorsieb die neue Garantie nicht, und der Load würde am Ziel-Constraint scheitern statt am Vorsieb — der Detektor zeigt das, bevor es passiert. Eine weggefallene Zeile heißt: Das Ziel ist laxer geworden. Das Vorsieb prüft strenger als nötig, was keinen Lauf gefährdet, aber eine Verhandlung wert ist. In beiden Fällen ist die Antwort dieselbe: den Vertrag prüfen und bewusst neu unterschreiben, also den Deployment-Schritt erneut laufen lassen. Der Diff eignet sich als Check im Deployment-Gate oder als Warnung vor jedem Lauf. Er ist ein Signal, nie eine automatische Übernahme.

Warum read-only keine Bequemlichkeit ist

Für die deployten abgeleiteten Regeln gilt eine harte Konvention: kein UPDATE, kein Deaktivieren, kein Löschen. Die Tabelle trägt deshalb auch kein is_active — dieses Flag gehört ausschließlich den manuellen Regeln in der Config-Tabelle. Was wie eine Einschränkung wirkt, ist der Kern des Musters, nicht sein Nebeneffekt.

Wäre die abgeleitete Regel editierbar, wäre sie eine Kopie mit Verfallsdatum. Jede Änderung an ihr würde sie von ihrer Quelle entkoppeln, und ab diesem Moment gäbe es wieder zwei Wahrheiten: die im Schema und die in der Regel. Schlimmer noch: Ein gut gemeintes „diese eine Prüfung schalten wir kurz ab“ würde eine Garantie aushebeln, die das Zielschema trotzdem weiter erzwingt. Der Load bräche dann an einem Constraint ab, für den es scheinbar keine aktive Regel gab. Die Prüfung wäre nicht mehr das ehrliche Vorsieb des Ziels, sondern eine eigene, driftende Meinung darüber.

Aus demselben Grund folgt auch die Kritikalität abgeleiteter Regeln der Quell-Semantik statt der Einschätzung eines Bearbeiters. Ein NOT NULL und ein eindeutiger Schlüssel sind am Ziel harte Constraints. Ihre abgeleiteten Regeln tragen deshalb E wie Error, denn ein Verstoß würde den Load dort scheitern lassen. Das ist keine Meinung, die man pro Regel diskutieren müsste, sondern eine Eigenschaft des Schemas.

Wer eine abgeleitete Regel wirklich loswerden will, hat genau einen Weg: das Schema ändern und neu deployen. Das klingt unbequem und ist gewollt. Die Diskussion „muss diese Spalte wirklich Pflicht sein?“ gehört an die Tabelle geführt, nicht an eine Config-Zeile, die die Tabelle nur wiedergibt.

Die Abgrenzung, die man nicht baut

Sobald zwei Regel-Quellen existieren, liegt eine Frage nahe: Was passiert, wenn beide dasselbe prüfen? Wenn jemand von Hand eine Eindeutigkeits-Regel auf customer_id anlegt, die es abgeleitet längst gibt — braucht es dann eine Konflikt-Erkennung, ein Vorrang-Regelwerk, eine Zusammenführung?

Die bessere Antwort ist: Man lässt die Situation gar nicht erst entstehen. Statt Konflikte zwischen abgeleiteten und benutzerdefinierten Regeln zu erkennen und aufzulösen, wird der benutzerdefinierte Bereich so zugeschnitten, dass er den abgeleiteten nicht betritt. Konkret heißt das: Die Schlüssel, die das Schema bereits absichert, werden im Regel-Editor nicht noch einmal angeboten — keine Schnellauswahl „Primärschlüssel prüfen“, kein vorbefülltes Eindeutigkeits-Formular für die Constraint-Spalten. Diese Prüfungen existieren schon, automatisch und nicht abschaltbar. Der freie Eindeutigkeits-Typ ist stattdessen für die Fälle da, die das Schema nicht kennt: einen zusammengesetzten fachlichen Schlüssel samt Gültigkeits-Datum oder ein „höchstens dreimal je Region“, also andere Spalten-Kombinationen und andere Kardinalitäten.

Der Konflikt, den man nicht baut, muss nicht gelöst werden. Es gibt keine Zusammenführungs-Logik, keine Vorrang-Tabelle und keinen Sonderfall im Runner — nicht weil das Problem elegant gelöst wäre, sondern weil es per Zuschnitt nicht existiert. Das ist eine Design-Entscheidung, kein Algorithmus, und sie ist billiger und robuster als jede Konflikt-Erkennung, die man stattdessen schreiben müsste.

Duplikate unter den eigenen Regeln

Ganz ohne Prüfung kommt der benutzerdefinierte Bereich trotzdem nicht aus. Innerhalb der handgeschriebenen Regeln kann dieselbe Prüfung versehentlich zweimal angelegt werden, und dann stünden zwei identische Befunde für denselben Verstoß in der Fehlertabelle. Geblockt wird deshalb das exakte inhaltliche Duplikat: gleicher Regel-Typ, gleiche Spalten, gleiche Bedingung.

Bemerkenswert ist, was dabei bewusst nicht geblockt wird. Pro Spalte sind mehrere Regeln desselben Typs ausdrücklich erlaubt: Zwei verschiedene Wertebereichs-Prüfungen auf derselben Spalte sind zwei getrennte, legitime Prüfungen mit eigenen Meldungen. Ein „eine Regel pro Spalte und Typ“-Zwang würde genau die Fälle verbieten, für die eine frei konfigurierbare Regel-Tabelle gebaut ist.

Die zweite Feinheit: Die Kritikalität gehört nicht zur Identität einer Regel. Zwei Regeln mit identischer Bedingung, aber unterschiedlichem Schweregrad gelten als dasselbe Duplikat. Sonst könnte dieselbe Prüfung einmal als Error und einmal als Warnung existieren, und derselbe Verstoß würde doppelt gemeldet — einmal blockierend, einmal nicht. Wer den Schweregrad einer Regel ändern will, bearbeitet die bestehende Regel, statt eine zweite daneben zu legen.

Was Handarbeit bleibt

Nach allem Ableiten bleibt ein Rest, und der ist der wertvollste Teil der Konfiguration. Im Schema steht nur, was die Datenbank erzwingen kann. Alles andere ist Fachwissen:

  • Feldübergreifende Bedingungen — ein Rabatt setzt einen aktiven Status voraus, ein Enddatum liegt nach dem Startdatum.
  • Plausibilität und zeitliche Logik — ein Bestelldatum liegt nicht in der Zukunft, ein Alter unter 18 ist möglich, aber prüfwürdig (die Beispiel-Regel von oben).
  • Wertelisten aus dem Fachbereich — erlaubte Statuswerte oder Produktcodes, die bewusst nicht als Constraint zementiert sind, weil die Fachseite sie pflegt.

Die abgeleiteten Regeln brauchen keine Review-Zeit, denn sie können nicht falsch sein — sie behaupten nichts, was das Zielschema nicht ohnehin erzwingt. Jede Minute, die ein Review auf das Abnicken von „customer_id ist Pflicht“ verwendet, fehlt bei der Frage, ob die Altersgrenze fachlich stimmt. Die Projektion verschiebt die Aufmerksamkeit dorthin, wo Fehler tatsächlich möglich sind: in die handgeschriebenen Regeln.

Die ehrliche Grenze

Die Projektion ist exakt so gut wie das Schema, aus dem sie liest. Das ist ihre Stärke und ihre Grenze zugleich, und die Grenze verdient denselben klaren Blick wie das Muster selbst.

Wo Pflichtfelder nur in der Anwendung existieren, weil die Spalte NULL erlaubt und erst die Eingabemaske einen Wert erzwingt, sieht information_schema nichts, und die Projektion liefert nichts. Dasselbe gilt für fachliche Schlüssel, die nie als Constraint angelegt wurden, und für Längen-Grenzen auf text-Spalten ohne deklarierte Länge. Die Projektion darf diese Lücken nicht stillschweigend überspielen: Sie liefert genau die Garantien, die das Schema ausspricht, und keine einzige mehr. Wer mehr abgeleitet haben will, muss das Schema ehrlicher machen — welche Keys und Constraints eine Zieltabelle überhaupt tragen sollte, behandelt der Artikel zu den Postgres-Tabellen-Konventionen.

Dieses Muster ist beim Namen zu nennen: Es ist ein Defizit der Anwendungsentwicklung, und es ist kein seltenes. Viele Anwendungen behandeln die Datenbank als reine Ablage. Pflichtfelder, Wertebereiche und Schlüssel prüft der Anwendungs-Code, das Schema erlaubt fast alles. Solange ausschließlich die Anwendung selbst schreibt, fällt das nicht auf. Wer aber Daten an der Eingabemaske vorbei in eine solche Anwendung laden will, etwa bei einer Migration oder über eine Schnittstelle, steht ohne jede der Garantien da, die dieser Artikel projiziert. Die Konsequenz ist unbequem, aber klar: Genau die Regeln, die bei einem ehrlichen Schema ableitbar wären, müssen für das gesicherte Laden als benutzerdefinierte Regeln manuell erzeugt und gepflegt werden — mit dem vollen Drift-Risiko, mit dem dieser Artikel begonnen hat.

Dazu kommt eine Postgres-spezifische Feinheit: information_schema ist portabel, aber nicht vollständig. Ein CREATE UNIQUE INDEX ohne zugehörigen Constraint taucht in table_constraints nicht auf, ebenso wenig partielle Unique-Indexe oder das NULLS NOT DISTINCT-Verhalten aus neueren Postgres-Versionen. Wer solche Konstrukte einsetzt, projiziert stattdessen aus den Systemkatalogen pg_constraint und pg_index — mit mehr Detail-Tiefe, aber ohne Portabilität. Ein partieller Unique-Index wäre als volle Eindeutigkeits-Regel schlicht falsch übersetzt, deshalb gehört bei ihm die Index-Bedingung mit in die Regel oder der Index bewusst nicht in die Projektion.

Die Ableitung ersetzt das Nachdenken über Datenqualität nicht. Sie verschiebt es an die Stelle, an die es gehört: ins Schema.

FAQ

Welche Prüfregeln lassen sich aus dem Schema ableiten?

Alles, was die Zieltabelle als Constraint oder Typ deklariert: Pflichtfeld-Prüfungen aus is_nullable, Eindeutigkeits-Prüfungen aus PRIMARY KEY und UNIQUE (sofern die Schlüsselspalten NOT NULL sind), Referenz-Prüfungen aus Fremdschlüsseln sowie Längen- und Wertebereichs-Grenzen aus character_maximum_length und numeric_precision. Nicht ableitbar ist Fachlogik ohne Schema-Spur — feldübergreifende Bedingungen, zeitliche Plausibilität, Wertelisten des Fachbereichs.

Warum sollten abgeleitete Prüfregeln nicht editierbar sein?

Weil eine editierbare abgeleitete Regel eine Kopie mit Verfallsdatum wäre. Nach der ersten Änderung gäbe es zwei Wahrheiten: die im Schema und die in der Regel. Ein abgeschalteter Check würde zudem eine Garantie verschleiern, die das Ziel trotzdem erzwingt. Der deployte Stand trägt deshalb bewusst kein is_active — das Flag gehört den manuellen Regeln. Wer eine abgeleitete Regel ändern will, ändert das Schema und deployt neu.

Reicht information_schema, oder brauche ich ein eigenes Metadaten-Modell?

Für NOT-NULL, Schlüssel und Typ-Grenzen reicht information_schema, und es ist portabel — dieselben Sichten existieren auch in SQL Server. Die Postgres-Systemkataloge (pg_constraintpg_index) sehen zusätzlich Unique-Indexe ohne Constraint und partielle Indexe. Ein eigenes Metadaten-Modell lohnt erst, wenn fachliche Metadaten dazukommen sollen, die die Datenbank nicht kennt — etwa Business-Keys ohne Datenbank-Constraint oder eine je Spalte gepflegte Null-Semantik.

Was, wenn Schema und Fachanforderung sich widersprechen?

Dann ist eine der beiden Seiten falsch, und das gehört am Schema geklärt statt in der Konfiguration übersteuert. Sagt die Fachseite „E-Mail ist Pflicht“, während die Spalte NULL erlaubt, ist das Schema zu lax: Constraint nachziehen, die abgeleitete Regel folgt automatisch. Bis dahin kann eine benutzerdefinierte Regel mit Schweregrad Warnung die Lücke sichtbar machen, ohne eine harte Garantie vorzutäuschen.

Wie halte ich abgeleitete und benutzerdefinierte Regeln auseinander?

Durch die Herkunft der Daten selbst: Abgeleitete Regeln stehen nur in der Deployment-Tabelle, die ausschließlich der Deployment-Schritt befüllt, benutzerdefinierte nur in der Config-Tabelle. Eine gemeinsame Sicht mit einer origin-Spalte (per UNION ALL) zeigt den vollständigen Prüf-Umfang. Wichtig ist nur eine Disziplin-Regel: Projizierte Regeln niemals von Hand in die Config-Tabelle kopieren — dort wären sie editierbare Kopien, und die Drift beginnt von vorn.

Verwandte Artikel

Framework und Routinen:

  • Datenqualität mit SQL prüfen — der Sub-Hub: das konfigurierbare Framework mit Fehlertabelle, Regel-Config und Runner, dessen Konfiguration dieser Artikel automatisch befüllt.
  • Daten mit SQL validieren — die Routine hinter den projizierten WHERE-Regeln: Wertebereiche, Pflichtfelder und die NULL-Falle.
  • Duplikate finden mit SQL — die Eindeutigkeits-Routine: Kardinalität, zusammengesetzte Schlüssel und die NULL-Semantik von UNIQUE.
  • Verwaiste Datensätze finden mit SQL — die Lookup-Routine, auf der abgeleitete Fremdschlüssel-Regeln aufsetzen.
  • Drei Schweregrade statt pass/fail — der Severity-Spoke der Serie: warum E/W/I mehr können als bestanden/durchgefallen. (erscheint in Kürze)

Theorie:

Ziel-Schema: