95 lines
3.2 KiB
Python
95 lines
3.2 KiB
Python
#!/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 <capture.json> --out <model.json>
|
|
capture_schema.py --from-model <model.json> --out <model.json> # 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())
|