174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
D2 — canonical_schema.py
|
|
Deterministischer Schema-Kanonisierer + Fingerprint für das Historical V2 Subsystem.
|
|
|
|
Liefert EINE autoritative Kanonisierungsfunktion, die aus einem Captured-Schema-Modell
|
|
(JSON, wie von capture_schema.py erzeugt) eine deterministische semantische
|
|
Serialisierung baut und daraus den SHA-256-Fingerprint berechnet.
|
|
|
|
Kanonisierungsregeln (siehe Mission §7):
|
|
- Schemas/Tabellen alphabetisch
|
|
- Spalten nach ordinal_position
|
|
- Constraints deterministisch nach (type, name, content)
|
|
- Indexe deterministisch
|
|
- Whitespace/Identifier-Quoting normalisiert, aber keine semantischen Unterschiede weggewaschen
|
|
|
|
Fingerprint-Coverage (§8): Tabellenexistenz, Spaltenname, ordinal_position,
|
|
Datentyp, Nullability, Default, Primary Key, UNIQUE, FK, CHECK, Index-Definitionen.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
|
|
|
|
def _norm_whitespace(s):
|
|
"""Normalisiere Whitespace; reduziere Wiederholungen, aber erhalte Wörter/Zeichen."""
|
|
if s is None:
|
|
return None
|
|
return " ".join(str(s).split())
|
|
|
|
|
|
def sorted_cols(columns):
|
|
"""Spalten einer Tabelle, sortiert nach ordinal_position."""
|
|
return sorted(columns, key=lambda c: (c.get("ordinal", 0), c.get("column", "")))
|
|
|
|
|
|
def canonical_columns(columns):
|
|
"""Spalten → deterministische Liste von Tuplen."""
|
|
out = []
|
|
for c in sorted_cols(columns):
|
|
out.append((
|
|
c["column"],
|
|
int(c.get("ordinal", 0)),
|
|
_norm_whitespace(c.get("type")),
|
|
_norm_whitespace(c.get("udt")),
|
|
bool(c.get("nullable")),
|
|
_norm_whitespace(c.get("default")),
|
|
))
|
|
return out
|
|
|
|
|
|
def canonical_constraints(constraints, kind):
|
|
"""Constraints eines Typs (PRIMARY KEY, UNIQUE) → sortierte Liste."""
|
|
out = []
|
|
for con in constraints:
|
|
cols = tuple(c for c in con.get("columns") or [])
|
|
out.append((
|
|
con.get("table", ""),
|
|
con.get("constraint", ""),
|
|
cols,
|
|
))
|
|
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
|
|
|
|
|
|
def canonical_fks(fks):
|
|
"""FK → sortierte Liste inkl. ref_table und ref_columns."""
|
|
out = []
|
|
for fk in fks:
|
|
out.append((
|
|
fk.get("table", ""),
|
|
fk.get("constraint", ""),
|
|
tuple(fk.get("columns") or []),
|
|
fk.get("ref_table", ""),
|
|
tuple(fk.get("ref_columns") or []),
|
|
))
|
|
return sorted(out, key=lambda x: (x[0], x[1]))
|
|
|
|
|
|
def canonical_checks(checks):
|
|
"""CHECK-Constraints → sortierte Liste mit normierter Definition.
|
|
|
|
Auto-generierte PostgreSQL 'NOT NULL'-Checks (CONSTRAINT-Name endet auf
|
|
'_not_null', von PostgreSQL automatisch pro NOT-NULL-Spalte erzeugt) werden
|
|
AUSGESCHLOSSEN: sie sind redundant zu 'nullable' und ihre Definition
|
|
unterscheidet sich zwischen DBs (Production 'x IS NOT NULL' vs. Checker
|
|
'((x IS NOT NULL))') → semantisches Rauschen. Zuverlässige Erkennung NUR
|
|
über den Namenssuffix '_not_null'. Explizit benannte User-Checks
|
|
(chk_* etc.) bleiben erhalten.
|
|
"""
|
|
out = []
|
|
for chk in checks:
|
|
name = chk.get("constraint", "")
|
|
if name.endswith("_not_null"):
|
|
continue
|
|
out.append((
|
|
chk.get("table", ""),
|
|
name,
|
|
_norm_whitespace(chk.get("definition")) or "",
|
|
))
|
|
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
|
|
|
|
|
|
def canonical_indexes(indexes):
|
|
"""Index-Definitionen → sortierte Liste mit normierter Definition."""
|
|
out = []
|
|
for idx in indexes:
|
|
out.append((
|
|
idx.get("table", ""),
|
|
idx.get("index", ""),
|
|
_norm_whitespace(idx.get("definition")),
|
|
))
|
|
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
|
|
|
|
|
|
def canonical_sequences(sequences):
|
|
"""Sequenzen → sortierte Liste (nur Existenz/Name)."""
|
|
return sorted(sequences or [])
|
|
|
|
|
|
def canonical_document(model):
|
|
"""Baue die vollständige deterministische semantische Serialisierung."""
|
|
tables = sorted(model.get("tables") or [])
|
|
cols_by_table = {}
|
|
for c in model.get("columns") or []:
|
|
cols_by_table.setdefault(c["table"], []).append(c)
|
|
|
|
pks = canonical_constraints(model.get("primary_keys") or [], "PRIMARY KEY")
|
|
uniques = canonical_constraints(model.get("unique_constraints") or [], "UNIQUE")
|
|
fks = canonical_fks(model.get("foreign_keys") or [])
|
|
checks = canonical_checks(model.get("check_constraints") or [])
|
|
indexes = canonical_indexes(model.get("indexes") or [])
|
|
sequences = canonical_sequences(model.get("sequences") or [])
|
|
|
|
doc = []
|
|
doc.append("schema=public")
|
|
doc.append("sequences=" + "|".join(sequences))
|
|
doc.append("tables=" + "|".join(tables))
|
|
for t in tables:
|
|
doc.append(f"table:{t}:cols=" + ";".join(
|
|
f"{name}|{ordp}|{typ}|{udt}|{'n' if nul else 'y'}|{dflt}"
|
|
for (name, ordp, typ, udt, nul, dflt) in canonical_columns(cols_by_table.get(t, []))
|
|
))
|
|
doc.append("pk=" + ";".join(
|
|
f"{t}|{n}|{','.join(c)}"
|
|
for (t, n, c) in pks
|
|
))
|
|
doc.append("unique=" + ";".join(
|
|
f"{t}|{n}|{','.join(c)}"
|
|
for (t, n, c) in uniques
|
|
))
|
|
doc.append("fk=" + ";".join(
|
|
f"{t}|{n}|{','.join(c)}->{rt}|{','.join(rc)}"
|
|
for (t, n, c, rt, rc) in fks
|
|
))
|
|
doc.append("check=" + ";".join(
|
|
f"{t}|{n}|{d}"
|
|
for (t, n, d) in checks
|
|
))
|
|
doc.append("index=" + ";".join(
|
|
f"{t}|{n}|{d}"
|
|
for (t, n, d) in indexes
|
|
))
|
|
return "\n".join(doc)
|
|
|
|
|
|
def fingerprint(model):
|
|
"""SHA-256 über die kanonische Serialisierung."""
|
|
doc = canonical_document(model)
|
|
return hashlib.sha256(doc.encode("utf-8")).hexdigest(), doc
|
|
|
|
|
|
def load_model(path):
|
|
"""Lade ein Captured-Schema-Modell (JSON)."""
|
|
with open(path) as f:
|
|
return json.load(f)
|