#!/usr/bin/env python3 """ D2 — capture_schema.py Liest Schema-Metadaten (read-only) von einer DB und erzeugt das kanonische Modell-JSON. Quellenmodus: Erwartet eine Datei mit den 7 JSON-Aggregaten aus dem Capture-SQL (der read-only psql-Ausgabe), ODER ein bereits geparstes Modell-JSON. Usage: capture_schema.py --from-capture --out capture_schema.py --from-model --out # Normalisierung Der DB-Zugriff selbst erfolgt ausschließlich über das read-only Capture-SQL (BEGIN TRANSACTION READ ONLY). Dieses Tool verarbeitet nur die Ergebnisse. """ import json import sys import argparse AGG_NAMES = ["columns", "primary_keys", "unique_constraints", "foreign_keys", "check_constraints", "indexes", "tables", "sequences"] def strip_psql_noise(text): """Entferne psql-Kontrollzeilen (BEGIN/on/COMMIT/leer/--).""" out = [] for line in text.splitlines(): s = line.strip() if s and s not in ("BEGIN", "on", "COMMIT") and not s.startswith("--"): out.append(s) return out def parse_raw_capture(text): """Extrahiere die N JSON-Aggregate aus der psql-Ausgabe. psql splittet große JSON-Arrays über mehrere Zeilen; daher wird nicht zeilenzählend, sondern per JSON-Streaming (raw_decode) geparst. """ data = "\n".join(strip_psql_noise(text)) decoder = json.JSONDecoder() aggs = [] i = 0 n = len(data) while i < n and len(aggs) < len(AGG_NAMES): while i < n and data[i] in " \t\r\n": i += 1 if i >= n: break if data[i] not in "[{": # non-JSON noise (z.B. Fehlermeldung) -> nächste Position i += 1 continue try: val, end = decoder.raw_decode(data, i) except json.JSONDecodeError as e: raise SystemExit(f"FEHLER: JSON-Parse bei {i}: {e}") aggs.append(val if val is not None else []) i = end if len(aggs) != len(AGG_NAMES): raise SystemExit( f"FEHLER: erwartete {len(AGG_NAMES)} JSON-Aggregate, fand {len(aggs)}") return dict(zip(AGG_NAMES, aggs)) def main(): ap = argparse.ArgumentParser() ap.add_argument("--from-capture", help="Rohe read-only psql-Capture-Datei (7 Aggregate)") ap.add_argument("--from-model", help="Bereits geparstes Modell-JSON") ap.add_argument("--out", required=True, help="Ziel-Modell-JSON") args = ap.parse_args() if args.from_capture: with open(args.from_capture) as f: model = parse_raw_capture(f.read()) elif args.from_model: with open(args.from_model) as f: model = json.load(f) else: raise SystemExit("FEHLER: --from-capture oder --from-model erforderlich") # Konsistenz: columns/tables müssen übereinstimmen col_tables = sorted({c["table"] for c in model["columns"]}) model["tables"] = sorted(model.get("tables") or col_tables) with open(args.out, "w") as f: json.dump(model, f, indent=2, sort_keys=True) print(f"Modell geschrieben: {args.out}") print(f"Tabellen: {len(model['tables'])} | Spalten: {len(model['columns'])}") return 0 if __name__ == "__main__": sys.exit(main())