From 4ac516daa1b3fe711f8fa23853aaa7eb4e926329 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 7 Sep 2026 10:53:57 +0200 Subject: [PATCH] Read the whole ALTER TABLE, not just its first clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema/type drift check has been red in CI. It reported seven discrepancies, and all seven were the checker's own fault. The migrations put several clauses in one statement: alter table employees drop column if exists division_id, drop column if exists team_id, drop column if exists manager_id, drop column if exists org_level, drop column if exists is_lead; The old pattern matched `alter table (\w+)\s+drop column (\w+)` as a single regex, which finds exactly the first clause. So division_id was dropped from the model and the other four stayed in it — the checker insisted four columns existed that the OM cutover removed a year ago. The same cut the other way for `add column`: kuendigungsschutz_bis, teilzeit_bis and aufenthaltstitel_bis are each the second clause of their statement, so the checker never saw them and called them typed-but-absent. Now the statement is collected up to its terminating semicolon — with paren depth tracked, so a semicolon inside a check constraint does not end it early — and every clause inside is applied. Verified by mutation, not by the green result alone: putting an invented column into types.ts is caught, and reverting the parser to read only the first clause brings back exactly those seven messages. That is the diagnosis confirmed, not merely a passing run. Co-Authored-By: Claude Opus 5 --- scripts/check-schema-types.mjs | 53 ++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/scripts/check-schema-types.mjs b/scripts/check-schema-types.mjs index e22f561..6d65099 100644 --- a/scripts/check-schema-types.mjs +++ b/scripts/check-schema-types.mjs @@ -80,16 +80,51 @@ function parseMigrations() { tables.set(name, columns); } - for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+add column (?:if not exists )?(\w+)/gi)) { - tables.get(m[1])?.add(m[2].toLowerCase()); - } - for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+drop column (?:if exists )?(\w+)/gi)) { - tables.get(m[1])?.delete(m[2].toLowerCase()); - } - for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+rename column (\w+) to (\w+)/gi)) { - const t = tables.get(m[1]); - if (t?.delete(m[2].toLowerCase())) t.add(m[3].toLowerCase()); + // ── ALTER TABLE, vollständig ──────────────────────────────────── + // + // Eine Anweisung kann mehrere Klauseln tragen, durch Komma getrennt: + // + // alter table employees + // drop column if exists division_id, + // drop column if exists team_id, + // drop column if exists manager_id; + // + // Wer nur nach `alter table … drop column …` als **einem** Muster sucht, + // findet davon genau die erste. Die übrigen bleiben im Modell stehen, und der + // Abgleich meldet Spalten als vorhanden, die es seit einem Jahr nicht + // mehr gibt — genau die vier aus dem OM-Umbau. Umgekehrt fehlten drei + // später ergänzte Spalten, weil sie jeweils die zweite Klausel eines + // `add column` waren. + // + // Deshalb wird hier erst die ganze Anweisung bis zum Semikolon + // aufgesammelt und dann jede Klausel darin ausgewertet. Klammern zählen + // mit, damit ein Semikolon in einer Prüfbedingung die Anweisung nicht + // vorzeitig beendet. + for (const kopf of sql.matchAll(/alter table (?:if exists )?(\w+)/gi)) { + const tabelle = tables.get(kopf[1]); + if (!tabelle) continue; + + let tiefe = 0; + let ende = kopf.index + kopf[0].length; + for (; ende < sql.length; ende++) { + const c = sql[ende]; + if (c === "(") tiefe++; + else if (c === ")") tiefe--; + else if (c === ";" && tiefe === 0) break; + } + const anweisung = sql.slice(kopf.index + kopf[0].length, ende); + + for (const m of anweisung.matchAll(/\badd column (?:if not exists )?(\w+)/gi)) { + tabelle.add(m[1].toLowerCase()); + } + for (const m of anweisung.matchAll(/\bdrop column (?:if exists )?(\w+)/gi)) { + tabelle.delete(m[1].toLowerCase()); + } + for (const m of anweisung.matchAll(/\brename column (\w+) to (\w+)/gi)) { + if (tabelle.delete(m[1].toLowerCase())) tabelle.add(m[2].toLowerCase()); + } } + for (const m of sql.matchAll(/drop table (?:if exists )?(\w+)/gi)) { tables.delete(m[1]); }