#!/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)