Die Konventionen standen fest: Prozedur-Skelette, DECLARE-Banner, Datei-Naming, verbotene Konstrukte — alles versioniert in Regel-Dateien, jede Regel mit Begründung. Offen war nur noch die Frage, mit welchem Werkzeug sich die SQL-Konventionen prüfen lassen. Und der erste Reflex hieß, wie vermutlich in jedem Postgres-Team mit Konventions-Ambition: „Nimm doch sqlfluff, das ist doch genau dafür da.“
Drei reale Testläufe später war die Antwort klar: sqlfluff ist für diese Prüfaufgabe nicht das passende Werkzeug. Vier der acht Regeln erreicht es strukturell gar nicht, die übrigen vier nur um den Preis einer fremden Toolchain. Das liegt nicht an mangelnder Qualität des Tools, sondern an der Aufgabenstellung. Ein generischer SQL-Linter prüft Standard-SQL-Style in parsebarem SQL. Die projektspezifischen Konventionen aus diesem Fall leben aber in psql-Deploy-Skripten mit PL/pgSQL-Bodies, also genau dort, wo ein SQL-Parser strukturell nicht hinsieht.
Dieser Artikel dokumentiert die Werkzeug-Entscheidung am realen Fall: der Abgleich der acht Prüfregeln des Projekts gegen sqlfluff 4.3.0, die drei strukturellen Blindstellen dahinter und das 241-Zeilen-Node-Script, das die SQL-Konventionen stattdessen prüft — seit Juli 2026 als blockierender CI-Step.
Das Wichtigste vorab:
- Formatter ist nicht gleich Konventions-Guard: sqlfluff prüft und erzeugt Standard-SQL-Layout — als Auto-Formatter empfiehlt es auch dieser Blog. Projektspezifische Konventionen in psql-Skripten durchzusetzen ist eine andere Aufgabe.
- Drei Blindstellen: Projekt-Konventionen haben keine Built-in-Rules, psql-Meta-Syntax ist kein SQL, und PL/pgSQL-Bodies im Dollar-Quoting sind für den Parser ein einziges String-Literal.
- Der Abgleich: Von 8 Prüfregeln bekommt keine ein ✅ — 4 ein klares ❌️, 4 ein ⚠️ nur als selbst geschriebenes Python-Plugin.
- Der Gegenentwurf: 241 Zeilen Node ohne Dependencies, 8 Regel-Klassen, genau 1 kodierte Ausnahme, deutlich unter 10 Sekunden über 152 Dateien.
- Der Autor des Guards: Alle 241 Zeilen stammen aus Claude-Code-Sessions, der Maintainer ist kein Node-Entwickler. Seine Vorgabe waren die Regel-Dateien, von Hand korrigierte SQL-Referenzen und ein Abnahme-Kriterium, drei Arbeits-Prompts zeigen den Weg.
- Die Arbeitsteilung: Das Standard-Layout prüft ein Linter (optional), die Projekt-Konventionen prüft ein kleines eigenes Script, die Korrektheit prüft ein echter Apply gegen eine Wegwerf-Datenbank.
Voraussetzung: Der Fall ist PostgreSQL mit psql-Deploy-Skripten. sqlfluff-Vorwissen ist nicht nötig, die geprüfte Version ist 4.3.0. Für den Nachbau des eigenen Checks genügt Node ab Version 20, ohne npm-Install.
Inhalt
- Der Reflex: „Nimm doch sqlfluff“
- Was sqlfluff gut kann
- Blindstelle 1: Projekt-Konventionen haben keine Built-in-Rules
- Blindstelle 2: psql-Skripte sind kein SQL
- Blindstelle 3: Dollar-Quoting ist für den Parser ein String
- Regel für Regel: Kann sqlfluff diese SQL-Konventionen prüfen?
- Der eigene Guard: 241 Zeilen, 0 Dependencies
- Wie der Guard entstanden ist: Vorgabe, Prompt, Nachschärfung
- Was der Guard nicht kann — und wer es stattdessen tut
- FAQ
- Verwandte Artikel
Der Reflex: „Nimm doch sqlfluff“
Der Schauplatz ist DI², ein ETL-Generator auf Next.js und PostgreSQL, dessen Datenbank-Schicht aus mehr als 150 DDL-Dateien unter db/schemas/ besteht. Deployt wird ohne Migrations-Tool, über nummerierte psql-Skripte mit \ir-Includes und Schema-Variablen. Die Verzeichnis-Konvention dahinter beschreibt der Artikel SQL-Schema deployen ohne Migrations-Tool. Für diese Dateien gilt ein Konventions-Regelwerk, das mit Claude Code aus dem Bestand abgeleitet wurde und in Regel-Dateien für Tabellen, Prozeduren und Funktionen versioniert ist.
Warum die Einhaltung maschinell geprüft werden muss, hat der Schwester-Artikel 799 hartkodierte Schriftgrößen am Frontend-Fall vermessen: Eine dokumentierte Konvention verbessert die Trefferquote eines Code generierenden Agenten, garantiert sie aber nicht. Bei hohem Generierungs-Volumen wird jede Restquote zu messbarer Drift. Dort endete die Geschichte mit einer eigenen ESLint-Regel auf error-Level. Für die SQL-Seite stellte sich dieselbe Durchsetzungs-Frage, nur mit offener Werkzeug-Wahl.
Zu prüfen sind Regeln dieser Art:
- Dateinamen folgen dem Muster
NNN.sp_<verb>_<entity>.sql. - Verbotene Konstrukte tauchen nicht auf (
serialstattIDENTITY,_at-Suffixe an Timestamp-Spalten,CURRENT_USERin App-Logik). - DECLARE-Blöcke tragen Banner in der Sequenz
Common→Error Handling→Workload. - Zwischen
DROPundCREATEsteht ein-- Parameter-Doku-Block. - Banner-Trennlinien sind exakt
--plus 80 Bindestriche. - Jeder
\echo-Kopf hat seinen passenden- DONE-Abschluss. - Auf jedes
CREATEfolgt einALTER … OWNER TO. - Zeilenenden sind LF.
Und so sieht eine DI²-typische DDL-Datei aus — gekürzt, aber mit allem, worauf es gleich ankommt: dem \echo-Kopf, den :schema_variablen, dem -- Parameter-Block und dem $procedure$-Body mit DECLARE-Bannern. Sie ist das Anschauungsobjekt für alles Folgende:
1: \echo "## CREATE PROCEDURE :schema_name.sp_check_customer"
2:
3: DROP PROCEDURE IF EXISTS :schema_name.sp_check_customer(text, int);
4:
5: -- --------------------------------------------------------------------------------
6: -- Parameter
7: -- --------------------------------------------------------------------------------
8: -- p_table_name text
9: -- Name der zu prüfenden Tabelle
10: -- p_min_rows int
11: -- Mindest-Zeilenzahl, unter der die Prüfung einen Fehler meldet
12: -- --------------------------------------------------------------------------------
13: CREATE OR REPLACE PROCEDURE :schema_name.sp_check_customer
14: (
15: IN p_table_name text
16: ,IN p_min_rows int
17: )
18: LANGUAGE plpgsql
19: AS $procedure$
20: DECLARE
21: -- --------------------------------------------------------------------------------
22: -- Common
23: -- --------------------------------------------------------------------------------
24: l_component text;
25:
26: -- --------------------------------------------------------------------------------
27: -- Error Handling
28: -- --------------------------------------------------------------------------------
29: l_error_message text;
30:
31: -- --------------------------------------------------------------------------------
32: -- Workload
33: -- --------------------------------------------------------------------------------
34: l_row_count bigint;
35: l_sql text;
36: BEGIN
37:
38: l_component := 'sp_check_customer';
39:
40: l_sql := format($sql$SELECT
41: count(*)
42: FROM
43: %1$I.%2$I
44: $sql$
45: ,'staging'
46: ,p_table_name
47: );
48:
49: EXECUTE l_sql INTO l_row_count;
50:
51: IF l_row_count < p_min_rows THEN
52: l_error_message := format($$%1$s: table %2$s has %3$s rows (expected >= %4$s)$$
53: ,l_component, p_table_name, l_row_count, p_min_rows);
54: RAISE EXCEPTION USING MESSAGE = l_error_message;
55: END IF;
56:
57: END;
58: $procedure$;
59:
60: ALTER PROCEDURE :schema_name.sp_check_customer(text, int) OWNER TO :schema_owner;
61:
62: \echo "## CREATE PROCEDURE :schema_name.sp_check_customer - DONE"
Was sqlfluff gut kann
Vorweg die faire Würdigung, denn dieser Artikel ist keine Abrechnung. sqlfluff ist der etablierte SQL-Linter: Es beherrscht viele Dialekte, bringt einen umfangreichen Katalog an Layout- und Style-Regeln mit (die Rules-Referenz listet sie vollständig) und lässt sich detailliert konfigurieren. Leading Commas, Einrückungs-Tiefe, Keyword-Großschreibung — für Standard-Layout-Fragen ist es Linter und Auto-Fixer in einem Werkzeug.
In genau dieser Rolle taucht sqlfluff auch in diesem Blog auf. Die Artikel zur funktionalen Ästhetik von SQL, zu den Editor-Optionen in SSMS und zur Formatierung von SQL-Statements empfehlen es als Auto-Formatter, und daran ändert dieser Artikel nichts. Ein Formatter erzeugt und prüft Standard-Layout in parsebarem SQL. Ein Konventions-Guard ist ein Prüf-Script, das projektspezifische Konventionen erzwingt: Regeln, die im Regelwerk des Projekts stehen und sonst nirgends. Das sind zwei verschiedene Aufgaben, und die Werkzeug-Frage dieses Artikels betrifft nur die zweite.
Blindstelle 1: Projekt-Konventionen haben keine Built-in-Rules
Der Katalog der Built-in-Regeln deckt ab, was projekt-übergreifend gilt: Layout, Groß-/Kleinschreibung, Aliasing, Struktur-Muster wie SELECT *. Eine Regel „DECLARE-Blöcke tragen Banner in der Sequenz Common → Error Handling → Workload“ steht dort naturgemäß nicht. Ebenso wenig „Dateinamen folgen NNN.sp_<verb>_<entity>.sql“ oder „auf jedes CREATE folgt ein ALTER … OWNER TO„. Projekt-Konventionen sind per Definition nicht im Lieferumfang eines generischen Tools.
sqlfluff sieht diesen Fall durchaus vor. Es gibt eine dokumentierte Plugin-API für eigene Regeln, in Version 4.3.0 real vorhanden und funktionsfähig. Der Weg dorthin ist allerdings ein Python-Package mit Entry-Point-Registrierung, eigener Rule-Klasse und Zugriff auf den Syntax-Baum (in der sqlfluff-Doku: Parse-Tree). Für ein Projekt, dessen Toolchain aus Node und psql besteht, bedeutet das eine zweite Sprach-Welt im Build, samt Python-Version und Paket-Management in jedem CI-Lauf und auf jedem Entwickler-Rechner.
Das wäre vertretbar, wenn der Syntax-Baum für die Projekt-Konventionen etwas beitragen würde. Er ist das, was eine Linter-Regel von einem Regex-Werkzeug unterscheidet: sqlfluff zerlegt jedes Statement in einen solchen Baum, und eine Custom-Rule kann darin gezielt navigieren. Sie kann etwa jede Spalten-Referenz oder jedes SELECT ohne Alias erfragen und kennt dabei immer den syntaktischen Kontext der Fundstelle. Diese Stärke zahlt sich aber nur aus, wenn die Prüf-Frage eine Baum-Frage ist.
Der ESLint-Fall aus dem Schriftgrößen-Artikel zeigt, wann das zutrifft. Dort fiel für dieselbe Durchsetzungs-Frage die Entscheidung zugunsten einer Custom-Rule im Linter, und das aus drei Gründen. Erstens lebt die ESLint-Regel im selben Ökosystem wie der geprüfte Code: Sie ist in JavaScript geschrieben, prüft TypeScript und läuft in der Toolchain, die das Projekt ohnehin hat. Zweitens existierte die ESLint-Infrastruktur mit Konfiguration, Plugins und CI-Step schon vor der neuen Regel. Die Regel war ein Eintrag mehr in einem bestehenden System, die Grenzkosten lagen nahe null. Drittens beantwortet der Syntax-Baum dort die eigentliche Prüf-Frage: Eine hartkodierte Schriftgröße kann als String-Literal, in einem Template-Literal oder in einem Objekt-Property stehen. Ein Zeilen-Regex würde entweder zu viel finden oder zu wenig, der Syntax-Baum fragt gezielt nach Literal-Knoten und Template-Bausteinen und weiß an jeder Fundstelle, in welchem Kontext sie steht.
Für die SQL-Konventionen oben gilt keiner der drei Punkte. Das Plugin wäre Python-Code in einem Node-Projekt, die zweite Sprach-Welt aus dem vorigen Absatz käme also ohne Gegenwert dazu. Vor allem aber sind die Regeln fast alle an Zeilen verankert: Ein Banner ist eine Kommentar-Zeile, ein Parameter-Block ist eine Kommentar-Sequenz, ein Dateiname ist gar kein SQL. Auf solche Muster gibt ein Syntax-Baum keine bessere Antwort als ein Regex. Ein sqlfluff-Plugin für diese Regeln enthielte intern dieselben Regexe wie ein eigenes Script, nur verpackt in eine fremde Toolchain. Und für die beiden folgenden Blindstellen hilft auch die Plugin-API nicht, weil die zu prüfenden Zeilen die Regel-Ebene gar nicht erst erreichen.
Blindstelle 2: psql-Skripte sind kein SQL
Die Deploy-Dateien sind keine reinen SQL-Dateien, sondern psql-Skripte. \echo schreibt eine Fortschritts-Zeile ins Deploy-Log, \ir bindet Dateien relativ zum aktuellen Skript ein, und :schema_name ist eine Variable, die erst der psql-Aufruf per -v mit dem echten Schema-Namen füllt. Für den Deploy-Workflow ist das der Kern der Sache. Für einen SQL-Parser ist es Fremd-Syntax.
Der reale Lauf gegen die Beispiel-Datei bricht entsprechend ab:
$ sqlfluff --version
sqlfluff, version 4.3.0
$ sqlfluff lint --dialect postgres 117001.sql
== [117001.sql] FAIL
L: 3 | P: 1 | PRS | Line 3, Position 1: Found unparsable section: 'DROP
| PROCEDURE IF EXISTS :schema_name.sp...'
WARNING: Parsing errors found and dialect is set to 'postgres'. Have you
configured your dialect correctly?
All Finished!
Zwei Details daran sind präziser, als die übliche Fehlerbeschreibung vermuten lässt. Erstens: Die \echo– und \ir-Zeilen selbst sind in Version 4.3.0 kein Parse-Fehler mehr, der Parser toleriert sie als opakes Meta-Kommando-Token. Wer im Netz auf ältere Berichte mit unparsable section direkt am \echo stößt, bekommt von einem aktuellen sqlfluff also ein milderes Bild. Prüfbar werden die Zeilen dadurch trotzdem nicht, denn eine Regel hat auf den Inhalt dieses Tokens keinen Zugriff. Die Prüfregel echo-pair bleibt damit auch per Custom-Rule unerreichbar. Zweitens: Der eigentliche Parse-Breaker sind die psql-Variablen. Ein :schema_name mitten im Statement ist für die Postgres-Grammatik nicht auflösbar, und genau an der ersten solchen Zeile steigt der Parser aus.
Für die Variablen gibt es einen dokumentierten Workaround: Der Placeholder-Templater mit param_style = colon substituiert :schema_name vor dem Parsen. Die Datei wird damit tatsächlich parsebar. Der zweite Lauf zeigt, was dann passiert (Ausgabe um wiederholte LT01-Treffer gekürzt):
$ sqlfluff lint 117001.sql # mit .sqlfluff: templater = placeholder, param_style = colon
== [117001.sql] FAIL
L: 5 | P: 1 | LT05 | Line is too long (83 > 80). [layout.long_lines]
L: 7 | P: 1 | LT05 | Line is too long (83 > 80). [layout.long_lines]
L: 12 | P: 1 | LT05 | Line is too long (83 > 80). [layout.long_lines]
L: 13 | P: 59 | LT01 | Unexpected line break. [layout.spacing]
L: 15 | P: 7 | LT01 | Expected only single space before parameter. Found '
| '. [layout.spacing]
L: 16 | P: 1 | LT02 | Expected indent of 4 spaces. [layout.indent]
L: 16 | P: 4 | LT04 | Found leading comma ','. Expected only trailing near
| line breaks. [layout.commas]
L: 60 | P: 1 | LT05 | Line is too long (81 > 80). [layout.long_lines]
All Finished!
Jetzt flaggen die Default-Regeln genau die Projekt-Konventionen als Verstöße: LT04 meldet die Leading-Commas, LT02 die 3-Space-Einrückung, LT01 das tabellarische Alignment der Parameter, und LT05 stolpert über die 83 Zeichen breiten Banner-Trennlinien. Nichts davon ist ein Fehler des Tools, alle vier Punkte sind konfigurier- oder abschaltbar. Aber die Richtung ist bemerkenswert. Nach dem Workaround müsste man zuerst einen Teil des Regel-Katalogs an die Projekt-Konventionen anpassen, damit der Linter nicht gegen das eigene Regelwerk lintet. Und die Meta-Zeilen, um die es den Prüfregeln eigentlich geht, bleiben weiterhin unsichtbar.
T-SQL-Leser kennen dieselbe Hürde übrigens aus SQLCMD-Skripten: :setvar und :r sind dieselbe Klasse von Meta-Syntax, die ein SQL-Parser nicht versteht.
Blindstelle 3: Dollar-Quoting ist für den Parser ein String
Die wichtigste Prüfregel des Guards prüft die DECLARE-Banner in den Prozedur-Bodies. Und genau diese Bodies liegen für sqlfluff hinter einer geschlossenen Tür. sqlfluff parse zeigt den kompletten $procedure$ … $procedure$-Block der Beispiel-Datei als ein einziges Token vom Typ quoted_literal. Das ist grammatikalisch völlig korrekt: Dollar-Quoting ist in Postgres ein String-Literal-Mechanismus. Der Body einer Prozedur ist aus Sicht des SQL-Statements ein Text-Argument, das erst der PL/pgSQL-Interpreter beim CREATE liest.
Die Konsequenz lässt sich drastisch testen. Ein absichtlich vermurkster Body, etwa mit SELCT statt SELECT, produziert im Lint-Lauf null Findings, solange die Dollar-Quotes geschlossen sind. Alles zwischen den beiden $procedure$-Markern ist für den Linter der Inhalt eines Strings. Damit liegen die DECLARE-Banner-Sequenz, der format()-Block und die komplette Body-Struktur außerhalb jeder möglichen sqlfluff-Regel — Custom-Plugin hin oder her.
Regel für Regel: Kann sqlfluff diese SQL-Konventionen prüfen?
Damit sind die drei strukturellen Befunde beisammen. Auf die acht Regel-Klassen des Guards angewendet, ergibt sich diese Bilanz. ❌️ heißt „geht strukturell nicht“, ⚠️ heißt „ginge nur als selbst geschriebenes Python-Plugin“:
| Prüfregel | prüft | sqlfluff? | Begründung |
|---|---|---|---|
naming | Dateiname folgt NNN.sp_<verb>_<entity>.sql | ❌️ | Dateinamen sind kein Gegenstand des Lintings, geprüft wird Datei-Inhalt. |
forbidden | verbotene Konstrukte (serial, _at-Suffix, CURRENT_USER …) | ⚠️ | Als Custom-Rule machbar, der einzige Punkt mit echtem Syntax-Baum-Vorteil — und auch der nur außerhalb der Dollar-Bodies. |
declare | DECLARE-Banner-Sequenz Common → Error Handling → Workload | ❌️ | Liegt im $procedure$-Body, den der Parser als String-Literal sieht (Blindstelle 3). |
param-block | -- Parameter-Doku-Block zwischen DROP und CREATE | ⚠️ | Kommentar-Inhalt bleibt auch in einer Custom-Rule ein Regex-Abgleich. |
banner-format | Trennlinien exakt -- + 80 Bindestriche | ⚠️ | Ebenfalls Regex in einer Custom-Rule, ohne Syntax-Baum-Gewinn. |
echo-pair | \echo-Kopf mit passendem - DONE-Abschluss | ❌️ | psql-Meta ist kein SQL, Regeln haben keinen Zugriff auf den Token-Inhalt (Blindstelle 2). |
owner | je CREATE ein ALTER … OWNER TO | ⚠️ | Datei-Level-Zählung quer über Statements, für Linter-Regeln untypisch, aber baubar. |
crlf | LF-Zeilenenden | ❌️ | Real geprüft: Eine CRLF-Datei läuft mit --rules all ohne Befund durch. |
Die Bilanz: kein einziges ✅. Vier Regeln scheitern strukturell, und die übrigen vier wären Custom-Plugin-Arbeit in einer fremden Toolchain, deren Kern bis auf die forbidden-Prüfung trotzdem aus Regexen bestünde. An diesem Punkt fiel die Werkzeug-Entscheidung.
Der eigene Guard: 241 Zeilen, 0 Dependencies
Der Guard ist ein einzelnes Node-Script, 241 Zeilen lang, mit Imports ausschließlich aus Node-Builtins (node:fs, node:path, node:url). Es sammelt alle .sql-Dateien unter db/schemas/ ein, wendet die acht Regel-Klassen zeilenbasiert an und meldet Findings im Format datei:zeile [regel] message (siehe regel-doku). Der Exit-Code 1 bei Findings macht es zum blockierenden CI-Step.
Hier der Kern in gekürzter, lauffähig gedachter Form. Gezeigt sind das Ausnahmen-Muster und die declare-Regel — also ausgerechnet die Regel, die für einen SQL-Parser unsichtbar wäre:
1: // Gekürzter Auszug aus scripts/check-sql-conventions.mjs (241 Zeilen, nur
2: // Node-Builtins). Gezeigt: das Ausnahmen-Muster und die zustandsbehaftete
3: // DECLARE-Banner-Regel — die Regel, die tief im $procedure$-Body liegt und
4: // für einen SQL-Parser unsichtbar ist.
5:
6: import { readdirSync, readFileSync, statSync } from "node:fs"
7: import { join, relative, sep } from "node:path"
8:
9: // -----------------------------------------------------------------------------
10: // Ausnahmen: Set aus "<repo-relativer-pfad>|<regel>" — jede Ausnahme ist eine
11: // kommentierte Zeile mit Grund. Keine generelle Regel-Abschaltung.
12: // -----------------------------------------------------------------------------
13: const EXCEPTIONS = new Set([
14: // applied_by im Deploy-Audit-Log ist bewusst CURRENT_USER: es protokolliert
15: // den DB-Deploy-Runner (psql-Rolle), nicht den App-User.
16: "db/schemas/app/db/data/999.schema_apply_log.sql|forbidden",
17: ])
18:
19: const findings = []
20:
21: function finding(file, line, rule, message, ruleDoc) {
22: const rel = relative(REPO_ROOT, file).split(sep).join("/")
23: if (EXCEPTIONS.has(`${rel}|${rule}`)) return
24: findings.push({ file: rel, line, rule, message, ruleDoc })
25: }
26:
27: // -----------------------------------------------------------------------------
28: // [declare] — DECLARE-Banner-Sequenz Common -> Error Handling -> Workload.
29: // Zustandsbehaftet: Block eingrenzen, Deklarationen zählen, Schwelle prüfen,
30: // Banner-Reihenfolge gegen eine dynamische Soll-Sequenz vergleichen.
31: // -----------------------------------------------------------------------------
32: const DECL_LINE = /^\s{3}(l_[a-z0-9_]+)\s/
33:
34: function checkDeclareBanners(file, lines) {
35: const declStart = lines.indexOf("DECLARE")
36: if (declStart < 0) return
37: const beginIdx = lines.findIndex((l, i) => i > declStart && l === "BEGIN")
38: if (beginIdx < 0) return
39:
40: const block = lines.slice(declStart + 1, beginIdx)
41: const names = block.map((l) => DECL_LINE.exec(l)?.[1]).filter(Boolean)
42: const hasCommon = names.includes("l_context") || names.includes("l_component")
43: const hasError = names.includes("l_error_message")
44: // Scope-Regel: Banner erst ab 6 Deklarationen mit Common- UND
45: // Error-Variablen; kleinere Blöcke bleiben bewusst flach.
46: if (names.length < 6 || !hasCommon || !hasError) return
47:
48: const seq = []
49: for (const l of block) {
50: const banner = /^\s*-- (Common|Error Handling|Workload)$/.exec(l)
51: if (banner) seq.push(banner[1])
52: else if (DECL_LINE.test(l)) {
53: const name = DECL_LINE.exec(l)[1]
54: if (name === "l_error_message" || name === "l_error_code") seq.push(name)
55: }
56: }
57: // Soll-Sequenz dynamisch: l_error_code ist optional — eine starre Erwartung
58: // würde konforme Objekte ohne ERRCODE false-positiv flaggen.
59: const expected = ["Common", "Error Handling", "l_error_message"]
60: if (names.includes("l_error_code")) expected.push("l_error_code")
61: expected.push("Workload")
62:
63: if (JSON.stringify(seq) !== JSON.stringify(expected)) {
64: finding(
65: file,
66: declStart + 1,
67: "declare",
68: `DECLARE-Banner-Sequenz erwartet \`${expected.join(" | ")}\`, gefunden: \`${seq.join(" | ") || "(keine Banner)"}\``,
69: "sql.md Gruppierung im DECLARE-Block",
70: )
71: }
72: }
73:
74: // Lauf (gekürzt): alle *.sql unter db/schemas/ einsammeln, Regeln je Datei
75: // anwenden, Findings als `datei:zeile [regel] message (siehe regel-doku)`
76: // ausgeben. Exit 0 = 0 Findings, Exit 1 = Findings — blockierender CI-Step.
Drei Entscheidungen daran tragen die Architektur:
- Ausnahmen sind Einträge, keine Abschaltungen. Das
EXCEPTIONS-Set enthält Paare aus Datei-Pfad und Regel, jeder Eintrag mit Kommentar und Grund. Aktuell ist es genau einer: Die Audit-Tabelle des Deploy-Logs darfCURRENT_USERverwenden, weil sie dort bewusst den DB-Deploy-Runner protokolliert. Für alle anderen Dateien bleibt die Regel scharf. Die Pfad-Normalisierung in Zeile 22 sorgt dabei dafür, dass derselbe Ausnahme-Schlüssel auf der Windows-Entwicklermaschine und im Ubuntu-CI greift: Windows liefert Backslash-Pfade, verglichen wird immer die/-Form. - Die declare-Regel ist zustandsbehaftet. Sie grenzt den DECLARE-Block ein, zählt die Deklarationen, prüft eine Scope-Schwelle und vergleicht die Banner-Reihenfolge gegen eine dynamisch aufgebaute Soll-Sequenz, in der
l_error_codeoptional ist. Genau diese Zustands-Logik ist der Grund, warum der Check ein Script ist und kein grep-Einzeiler (mehr dazu in der FAQ). - Findings nennen die Regel-Doku. Jede Meldung endet mit dem Verweis auf die Regel-Datei, in der die Konvention begründet ist. Wer den Fehler sieht, sieht auch den Ort der Regel. Das gilt für menschliche Leser genauso wie für den Agenten, der auf den CI-Fehler reagiert.
So sehen die Läufe aus — zuerst gegen den regelkonformen Bestand, dann gegen zwei absichtlich verletzte Beispiel-Dateien:
$ npm run check:sql
SQL-Konventions-Check: 0 Findings (152 Dateien geprueft)
SQL-Konventions-Check: 8 Finding(s) in 2 Dateien
db/schemas/app/db/procedure/001.check_customer.sql:1 [naming] Dateiname '001.check_customer.sql' passt nicht zum Muster NNN.sp_<verb>_<entity>.sql (siehe .claude/rules/sql.md)
db/schemas/app/db/procedure/001.check_customer.sql:13 [banner-format] Trennlinie ist nicht exakt `-- ` + 80 Bindestriche (siehe .claude/rules/sql.md)
db/schemas/app/db/procedure/001.check_customer.sql:15 [banner-format] Trennlinie ist nicht exakt `-- ` + 80 Bindestriche (siehe .claude/rules/sql.md)
db/schemas/app/db/procedure/001.check_customer.sql:1 [owner] 1x CREATE, aber nur 0x `ALTER ... OWNER TO :schema_app_owner` (siehe .claude/rules/sql.md)
db/schemas/app/db/procedure/001.check_customer.sql:5 [param-block] `-- Parameter`-Dokublock fehlt (gehoert zwischen DROP und CREATE) (siehe .claude/rules/procedures.md)
db/schemas/app/db/table/001.customer.sql:5 [forbidden] serial/bigserial/smallserial - PK ist `bigint GENERATED ALWAYS AS IDENTITY` (siehe .claude/rules/sql.md)
db/schemas/app/db/table/001.customer.sql:7 [forbidden] Timestamp-Spalte mit `_at`-Suffix - Konvention ist `_on` (created_on, modified_on, ...) (siehe .claude/rules/sql.md)
db/schemas/app/db/table/001.customer.sql:11 [echo-pair] Abschluss-`\echo "## ... - DONE"` fehlt (siehe .claude/rules/sql.md)
Verdrahtet ist der Check als npm run check:sql im CI-Workflow, als eigener Step zwischen ESLint und den Unit-Tests. Das Laufzeit-Budget von unter 10 Sekunden hält er über die 152 Dateien mühelos ein, denn zeilenbasierte Regexe über ein paar hundert Kilobyte SQL kosten praktisch nichts. Seit Juli 2026 läuft er blockierend: Jeder Push mit Konventions-Verstoß bricht den Build.
Die Betriebsbilanz seit dem ersten Lauf ist kurz. Der Guard hat vor seinem CI-Einsatz einen echten Verstoß im Bestand gefunden und bisher einen einzigen False Positive produziert, den eine QA-Session gezielt mit einem konstruierten Grenzfall provoziert hat. Mehr als zwei Korrektur-Runden am Script waren seitdem nicht nötig. Beide Fälle beschreibt die nächste Sektion.
Eine Transparenz-Notiz gehört dazu: Das DI²-Repo ist privat, das vollständige Script ist deshalb nicht öffentlich verlinkbar. Die Regel-Dateien, die es prüft, sind dagegen in generalisierter Form öffentlich: Das di2-starter-kit enthält die SQL-Regelwerke für Postgres und MSSQL (sql.md, procedures.md, tables.md und weitere) zum Nachlesen und Forken. Der Auszug oben, das Findings-Format und diese Regel-Dateien genügen als Nachbau-Grundlage, denn die übrigen Regel-Klassen sind schlichter gebaut als die gezeigte. Die Zahlen und Läufe dieses Artikels stammen allerdings aus dem privaten DI²-Projekt selbst, nicht aus dem Starter-Kit.
Wie der Guard entstanden ist: Vorgabe, Prompt, Nachschärfung
Eine Transparenz-Angabe, die in diesem Cluster nicht fehlen darf: Das Script hat kein Mensch getippt, und der Maintainer hätte es auch nicht schreiben können. Seine Expertise liegt in SQL, Datenmodellen und den Konventionen dahinter, nicht in JavaScript. Alle 241 Zeilen stammen aus Claude-Code-Sessions, auch die beiden Korrekturen nach QA und Review. Selbst die Entscheidung für Node statt Bash hat der Agent vorgeschlagen und begründet, der Maintainer hat sie nur abgenommen. Seine Rolle war eine andere: Er kannte die Konventionen, konnte regelkonformes SQL von Hand herstellen, konnte ein Finding als richtig oder falsch beurteilen und hat die Rahmen-Entscheidungen getroffen (keine neuen Dependencies, kein sqlfluff, welche Dateien bewusst flach bleiben). Dass diese Arbeitsteilung funktioniert, liegt weniger am Werkzeug als an der Vorbereitung. Die Vorgabe lag in zwei Formen vor, und erst beide zusammen ergeben eine Spezifikation, aus der ein Agent einen Check ableiten kann.
Form 1: Regeln als Dateien. Die Konventionen standen als versionierte Regel-Dateien im Repo (sql.md, procedures.md, tables.md), jede Regel mit Begründung. Wie ein solches Regelwerk entsteht, beschreibt der Artikel SQL-Konventionen mit Claude Code ableiten. Für den Guard sind diese Dateien der Referenz-Text: Das Script prüft nichts, was dort nicht steht, und jedes Finding verweist auf die Datei, in der die Regel begründet ist.
Form 2: Ein regelkonformer Bestand als Vorlage. Regeln in Prosa lassen Grenzfälle offen. Ab wie vielen Deklarationen gehört ein Banner in den DECLARE-Block? Gilt die Sequenz auch für Trigger-Functions? Was ist mit Objekten, die ohne ERRCODE raisen? Die Antworten standen nicht im Regelwerk, sondern im Code. Deshalb wurde der Bestand vor dem Guard an die Regeln angeglichen: Den Anfang machten von Hand auf die Kanonik gezogene Dateien (die Tabelle project_member samt Rollen- und History-Tabelle, dazu die Foundation-Tabellen), danach zogen Subagents die übrigen Dateien nach, mit den korrigierten Dateien als Vorlage. Der Prompt dieser Stufe sieht so aus:
Die Datei db/schemas/app/db/table/004.project_member.sql habe ich von Hand
auf die Konventionen aus .claude/rules/sql.md und tables.md gezogen. Sie ist
ab jetzt die Referenz. Gleiche die übrigen Tabellen-Dateien unter
db/schemas/app/db/table/ daran an: IDENTITY statt bigserial, FK und UNIQUE
als idempotente ALTER TABLE, Trennlinien exakt "-- " plus 80 Bindestriche.
Spaltenlisten und Logik bleiben unverändert. Danach den Apply-Smoke gegen
die Wegwerf-DB fahren und das Ergebnis nennen.
Das ist derselbe Loop wie beim Ableiten der Regeln: generieren, von Hand korrigieren, die korrigierte Datei zur Vorlage machen. Der Unterschied zur ersten Form ist, dass die Vorlage keine Prosa ist, sondern ein Beispiel, gegen das sich jede Abweichung per Diff zeigen lässt.
Der Guard-Prompt. Erst auf dieser Grundlage wurde der Check selbst beauftragt. Auffällig ist, was der Prompt nicht enthält: keine Sprache, keine Architektur, keine Parser-Frage. Er nennt die Regel-Dateien als Quelle, die zu prüfenden Konventionen, die Rahmenbedingungen aus Sicht des Projekts und das Abnahme-Kriterium. Alles Technische darunter war Sache des Agenten:
Ich möchte, dass die SQL-Konventionen aus .claude/rules/sql.md und
procedures.md automatisch geprüft werden, bei jedem Push im CI und lokal
per npm-Befehl. Prüfen sollen: Datei-Naming, die verbotenen Konstrukte
(serial, _at-Suffix, CURRENT_USER), die DECLARE-Banner-Reihenfolge, der
"-- Parameter"-Block, das Banner-Format, die \echo-Paare, OWNER TO nach
jedem CREATE und LF-Zeilenenden. Die Dateien unter db/schemas/ sind seit
der Angleichung regelkonform, nimm sie als Maßstab für Grenzfälle. Jede
Meldung soll mir Datei, Zeile und die verletzte Regel nennen, damit ich
sie beurteilen kann. Keine neuen Pakete, kein sqlfluff. Schlag mir vor,
womit du das umsetzen würdest, und begründe es. Abnahme: 0 Meldungen auf
dem aktuellen Bestand, und ein absichtlich kaputtes Beispiel wird gefunden.
Drei Sätze darin tragen die Last. „Nimm sie als Maßstab für Grenzfälle“ verlagert die Entscheidung über Schwellen und Ausnahmen dorthin, wo sie bereits getroffen wurde: in die korrigierten Dateien. „Schlag mir vor, womit du das umsetzen würdest“ überlässt die Werkzeug-Wahl dem Agenten, der daraufhin Node mit Builtins vorgeschlagen hat, mit der Begründung aus der FAQ unten (zustandsbehaftete DECLARE-Regel, identisches Verhalten auf Windows und im CI). Und „0 Meldungen auf dem aktuellen Bestand“ macht den Bestand zum Testfall für den Check, ohne dass der Auftraggeber den Code lesen muss. Genau dieses Kriterium hat beim ersten Lauf auch sofort gegriffen, allerdings anders als erwartet: Eine einzige Trigger-Datei trug zwei nackte 80-Bindestrich-Zeilen ohne das führende -- . Der Guard hatte seinen ersten Fang, bevor er im CI war.
Nachschärfen statt aufweichen. Die beiden Korrekturen nach dem Review zeigen, wie ein solcher Check im Betrieb geführt wird, auch ohne den Code zu verstehen. Die QA (ebenfalls eine Agenten-Session) hatte einen Grenzfall konstruiert: ein regelkonformes Objekt, das nur l_error_message deklariert, weil es ohne ERRCODE raist. Der Check hätte es geflaggt. Der Maintainer musste dafür nicht wissen, wo im Script die Soll-Sequenz steht, sondern nur, dass die Regel in procedures.md dieses Objekt erlaubt:
Die QA meldet: Eine Prozedur, die ohne ERRCODE raist und deshalb kein
l_error_code deklariert, wird vom DECLARE-Check geflaggt. Laut
procedures.md ist das Objekt aber regelkonform, der Check liegt falsch.
Bitte korrigieren. Wichtig: Die Regel selbst bleibt scharf, eine Prozedur
mit vertauschten Bannern muss weiterhin gemeldet werden. Zeig mir beide
Fälle nach dem Fix im Lauf.
Der Satz zur scharfen Regel ist der wichtige. Ohne ihn wäre die naheliegende Lösung gewesen, die Sequenz-Prüfung zu lockern. Mit ihm bleibt die Regel, und nur die Erwartung wird präziser: Im Code oben ist das die Stelle, an der expected dynamisch aufgebaut wird. Der Maintainer hat diese Lösung nicht entworfen, sondern an den beiden Läufen abgenommen, die er verlangt hatte.
Was der Guard nicht kann — und wer es stattdessen tut
Zur Ehrlichkeit gehören die Grenzen. Der Guard hat zwei bewusste und eine Voraussetzung.
Er prüft kein Layout im engeren Sinn. Einrückungs-Tiefe, Komma-Position und Alignment kontrolliert das Script nicht, abgesehen von den Banner-Trennlinien. Diese Aufgabe bleibt bei einem Formatter oder Linter, und für Standard-SQL ohne psql-Meta wäre sqlfluff dafür weiterhin die naheliegende Wahl (siehe FAQ).
Und er prüft keine Korrektheit. Ob das SQL überhaupt läuft, ob ein \ir-Include fehlt oder ob eine Prozedur gegen das reale Schema kompiliert, kann kein Zeilen-Regex beantworten — und übrigens auch kein Parser, denn Blindstelle 3 gilt für jede statische Prüfung: Was im Dollar-Body steht, bewertet erst die Datenbank beim CREATE. Diese Rolle übernimmt im selben Projekt ein separater Apply-Smoke: Ein eigener GitHub-Actions-Workflow spielt das komplette Schema gegen einen postgres:17-Service-Container ein, ausgelöst per Pfad-Filter auf db/**. Wie dieses Wegwerf-Datenbank-Gate aufgebaut ist, beschreibt GitHub Actions für Postgres-Deploys, die Einordnung ins Gesamtbild liefert der Hub Datenbank-CI/CD mit PostgreSQL.
Die Voraussetzung betrifft den Zeilen-Ansatz selbst. Zeilenbasierte Regexe erkennen Konventions-Verstöße nur zuverlässig, solange der Bestand kanonisch formatiert ist. Genau dafür wurde er vor dem Guard an die Regeln angeglichen (siehe die Entstehungs-Sektion oben), und der Guard hält diese Kanonik seitdem im CI fest. Ein Objekt, das strukturell stark vom Skelett abweicht, könnte einer Regel trotzdem durchrutschen. Solche Ausreißer fangen der Apply-Smoke und das Code-Review ab.
Damit steht die Arbeitsteilung, die dieser Artikel als Take-away anbietet:
| Prüf-Aufgabe | Werkzeug | Warum |
|---|---|---|
| Standard-Layout (Kommas, Einrückung, Keywords) | Linter/Formatter wie sqlfluff — optional | Generische Regeln existieren fertig, parsebares SQL vorausgesetzt. |
| Projekt-Konventionen (Banner, Naming, Verbote, Paar-Regeln) | kleines eigenes Script | An Zeilen verankert, kein Parser nötig, keine fremde Toolchain. |
| Korrektheit (läuft das Schema durch?) | echter Apply gegen eine Wegwerf-DB | Ob SQL ausführbar ist, weiß nur eine Datenbank. |
Wer alle drei Aufgaben in ein Werkzeug zwingen will, landet bei Custom-Rule-Plugins, die intern fast dieselben Regexe enthalten wie das Script, das er vermeiden wollte. Und die Korrektheit prüft er damit trotzdem nicht.
FAQ
Immer dann, wenn die Prüf-Aufgabe Standard-SQL-Style in parsebarem SQL ist: Query-Sammlungen, Views, Reporting-SQL oder dbt-Projekte, für deren Jinja-Templating sqlfluff einen eigenen Templater mitbringt. Auch team-übergreifende Formatierungs-Standards sind sein Terrain, als Auto-Formatter empfehlen es die Formatierungs-Artikel dieses Blogs ausdrücklich. Im DI²-Projekt selbst bleibt sqlfluff für den Standard-Layout-Anteil als dokumentierte Zukunfts-Option notiert.
Projektintern hieß das Vorhaben anfangs „grep-basierter Check“, und für die meisten der acht Regel-Klassen würde grep auch reichen. Die declare-Regel ist der Grund für das Script: Block eingrenzen, Deklarationen zählen, Schwelle prüfen und eine dynamische Soll-Sequenz vergleichen — diese Zustands-Logik ist in grep/awk-Pipelines fragil und auf Windows-Entwicklermaschinen zusätzlich shell-abhängig. Das Node-Script läuft identisch auf Windows und im Ubuntu-CI, und die Toolchain des Projekts ist ohnehin Node. Null neue Dependencies waren die Bedingung, Node-Builtins erfüllen sie.
Die Plugin-API existiert und funktioniert, das wurde in Version 4.3.0 real geprüft. Aber die Hälfte der Regeln bliebe auch damit unerreichbar: Dateinamen, psql-Meta-Zeilen und Dollar-Bodies erreichen die Regel-Ebene nicht, und die Zeilenenden-Prüfung fällt praktisch aus, weil schon der Standard-Katalog CRLF-Dateien ohne Befund passieren lässt. Die übrigen Regeln wären Regexe in einem Python-Package mit Entry-Point-Registrierung — mehr Infrastruktur für weniger Abdeckung, in einer Sprache, die im Projekt sonst nirgends vorkommt. In einem Projekt mit Python-Toolchain und ohnehin laufendem sqlfluff fiele die Abwägung anders aus. Dann greifen dieselben drei Kriterien, die im Frontend-Fall für die ESLint-Custom-Rule gesprochen haben.
Durch Reibung an der richtigen Stelle. Eine Ausnahme ist ein Eintrag aus Datei-Pfad und Regel mit Pflicht-Kommentar und Grund, sie gilt für genau eine Datei und genau eine Regel, und sie läuft durch dasselbe Code-Review wie jede andere Änderung. Eine Regel projektweit abzuschalten ist im Muster nicht vorgesehen. Nach 152 geprüften Dateien steht die Liste bei einem einzigen Eintrag. Die Schwelle „bewusste, begründete Einzel-Entscheidung“ trägt bisher.
Ja, mit denselben Blindstellen. SQLCMD-Deploy-Skripte nutzen mit :setvar und :r dieselbe Klasse von Meta-Syntax, die ein SQL-Parser nicht versteht. T-SQL-Prozedur-Bodies stehen zwar nicht in Dollar-Quotes, die Konventions-Fragen sind aber dieselben: Naming, Doku-Blöcke, Paar-Regeln. Ein zeilenbasiertes Script ist dort genauso tragfähig, nur die Regexe ändern sich.
Verwandte Artikel
Vorgelagert:
- 799 hartkodierte Schriftgrößen — wie KI-gestützte Entwicklung Konsistenz-Drift erzeugt — der Drift-Befund, der die Durchsetzungs-Frage überhaupt erst gestellt hat (Frontend-Zwilling dieses Artikels).
- SQL-Konventionen mit Claude Code ableiten — der Generate-Refine-Derive-Loop — wie das Regelwerk entsteht, dessen Einhaltung der Guard prüft.
Konventions-Spokes:
- PL/pgSQL-Prozeduren-Konventionen — die Regeln hinter DECLARE-Bannern und Parameter-Doku-Block.
- Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten — die Regeln hinter den forbidden-Prüfungen.
- PL/pgSQL-Funktions-Konventionen — Volatilität, RETURNS und die Grenze zur Prozedur.
CI/CD-Brücken:
- Datenbank-CI/CD mit PostgreSQL — der Hub: der komplette Lebenszyklus vom Objekt-File bis zum Deploy.
- GitHub Actions für Postgres-Deploys — mit Wegwerf-Datenbank als Qualitäts-Gate — das Korrektheits-Gate neben dem Konventions-Guard.
- SQL-Schema deployen ohne Migrations-Tool — die Verzeichnis-Konvention, deren Dateien der Guard prüft.
Hub:
- KI-gestützte SQL-Entwicklung mit Claude Code — Rules, Skills und Agenten, die Konventionen durchsetzen — das Durchsetzungs-System im Ganzen.