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_schemaprojizieren — 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
- Welche Prüfregeln sich ableiten lassen
- Beim Deployment projizieren, nicht bei jedem Lauf
- Die Projektion als Drift-Detektor
- Warum read-only keine Bequemlichkeit ist
- Die Abgrenzung, die man nicht baut
- Duplikate unter den eigenen Regeln
- Was Handarbeit bleibt
- Die ehrliche Grenze
- FAQ
- Verwandte Artikel
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-Quelle | Abgeleitete Prüfung | Regel-Typ im Framework | Dimension |
|---|---|---|---|
is_nullable = 'NO' | Pflichtfeld-Prüfung | constraint (WHERE-Klausel) | Vollständigkeit |
PRIMARY KEY / UNIQUE | Eindeutigkeit, Kardinalität 1 (Schlüsselspalten sämtlich NOT NULL) | unique | Eindeutigkeit |
FOREIGN KEY | Referenz-Prüfung gegen die Master-Tabelle (siehe Ausblick unten) | lookup | Konsistenz / Integrität |
character_maximum_length, numeric_precision/_scale | Längen- und Wertebereichs-Grenze | constraint (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_column | where_clause | severity | message |
|---|---|---|---|
customer_id | customer_id IS NULL | E | customer_id ist Pflichtfeld im Zielschema |
source_system | source_system IS NULL | E | source_system ist Pflichtfeld im Zielschema |
source_id | source_id IS NULL | E | source_id ist Pflichtfeld im Zielschema |
country_code | country_code IS NULL | E | country_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_column | max_occurrence | message |
|---|---|---|
customer_id | 1 | Schluessel (customer_id) nicht eindeutig |
source_system,source_id | 1 | Schluessel (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_column | where_clause | message |
|---|---|---|
source_system | length(source_system) > 10 | source_system laenger als 10 Zeichen |
source_id | length(source_id) > 20 | source_id laenger als 20 Zeichen |
country_code | length(country_code) > 2 | country_code laenger als 2 Zeichen |
email | length(email) > 100 | email laenger als 100 Zeichen |
age | abs(age) >= 1000 | age 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;
| origin | check_type | check_column | where_clause | severity |
|---|---|---|---|---|
| abgeleitet | constraint | country_code | country_code IS NULL | E |
| abgeleitet | constraint | customer_id | customer_id IS NULL | E |
| abgeleitet | constraint | source_id | source_id IS NULL | E |
| abgeleitet | constraint | source_system | source_system IS NULL | E |
| abgeleitet | unique | customer_id | E | |
| abgeleitet | unique | source_system | E | |
| benutzerdefiniert | constraint | age | age < 18 | W |
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;
| drift | check_type | check_column | message |
|---|---|---|---|
| neu | constraint | email | email ist Pflichtfeld im Zielschema |
| weggefallen | unique | source_system,source_id | Schluessel (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
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.
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.
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_constraint, pg_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.
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.
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:
- Datenqualität: Dimensionen und Fehlerklassen — der Konzept-Rahmen: auf welche Dimensionen die abgeleiteten Regeln einzahlen.
Ziel-Schema:
- Postgres-Tabellen-Konventionen — welche Keys und Constraints eine Zieltabelle tragen sollte, damit die Projektion etwas zu lesen hat.