keygraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. keygraph/__init__.py +8 -0
  2. keygraph/__main__.py +3 -0
  3. keygraph/adapters/__init__.py +33 -0
  4. keygraph/adapters/base.py +57 -0
  5. keygraph/adapters/clickhouse.py +67 -0
  6. keygraph/adapters/dump.py +169 -0
  7. keygraph/adapters/fetch.py +93 -0
  8. keygraph/adapters/live.py +50 -0
  9. keygraph/adapters/metabase.py +78 -0
  10. keygraph/adapters/mongo.py +93 -0
  11. keygraph/adapters/mysql.py +30 -0
  12. keygraph/adapters/postgres.py +68 -0
  13. keygraph/adapters/sqlite.py +24 -0
  14. keygraph/adapters/sqlserver.py +65 -0
  15. keygraph/areas.py +225 -0
  16. keygraph/cli.py +497 -0
  17. keygraph/context.py +387 -0
  18. keygraph/docs_out.py +199 -0
  19. keygraph/explorer.py +128 -0
  20. keygraph/graph.py +163 -0
  21. keygraph/hubs.py +48 -0
  22. keygraph/infer.py +344 -0
  23. keygraph/mcp_server.py +147 -0
  24. keygraph/mirrors.py +109 -0
  25. keygraph/model.py +149 -0
  26. keygraph/notes.py +114 -0
  27. keygraph/overrides.py +112 -0
  28. keygraph/probe.py +119 -0
  29. keygraph/profile.py +262 -0
  30. keygraph/query.py +376 -0
  31. keygraph/report.py +92 -0
  32. keygraph/search.py +175 -0
  33. keygraph/static/DAGRE_LICENSE +19 -0
  34. keygraph/static/OFL-Inter.txt +93 -0
  35. keygraph/static/OFL-JetBrainsMono.txt +93 -0
  36. keygraph/static/dagre-1.1.4.min.js +801 -0
  37. keygraph/static/explorer.html +69 -0
  38. keygraph/static/explorer.js +1076 -0
  39. keygraph/static/favicon.svg +1 -0
  40. keygraph/static/keygraph-logo.svg +1 -0
  41. keygraph/static/keygraph-mark.svg +1 -0
  42. keygraph/static/keygraph.inline.css +843 -0
  43. keygraph/static/kg-diagram.js +267 -0
  44. keygraph/static/kg-theme.js +269 -0
  45. keygraph/summary.py +60 -0
  46. keygraph-0.2.0.dist-info/METADATA +334 -0
  47. keygraph-0.2.0.dist-info/RECORD +50 -0
  48. keygraph-0.2.0.dist-info/WHEEL +4 -0
  49. keygraph-0.2.0.dist-info/entry_points.txt +2 -0
  50. keygraph-0.2.0.dist-info/licenses/LICENSE +21 -0
keygraph/profile.py ADDED
@@ -0,0 +1,262 @@
1
+ """Value profiling from samples: statistics, top values, patterns, PII masking and enum reconciliation.
2
+
3
+ PII rule (hard): a column flagged PII (by name, by detected value pattern, or by a context flag) never has raw
4
+ values stored. Only counts, null rate, length statistics and the detected pattern name are kept; top values,
5
+ minimum and maximum are dropped. Profiles of non-PII columns may hold top values.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import json
12
+ import math
13
+ import re
14
+ import time
15
+ from collections import Counter
16
+ from decimal import Decimal
17
+
18
+ from .model import type_family
19
+
20
+ PII_NAME = re.compile(
21
+ r"(^|_)(e?mail|mobile|phone|tel|cell|msisdn|national_?(id|code)|nid|ssn|passport|first_?name|last_?name|full_?name|"
22
+ r"fname|lname|surname|address|addr|postal_?code|zip|token|password|passwd|secret|iban|sheba|card_?(no|number)|"
23
+ r"account_?(no|number)|birth(day|_?date)?|dob|ip_?address|otp|api_?key)($|_)", re.I)
24
+ PATTERNS = [
25
+ ("email", re.compile(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$")),
26
+ ("uuid", re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")),
27
+ ("url", re.compile(r"^https?://\S+$", re.I)),
28
+ ("phone", re.compile(r"^(\+|00)?[0-9]{10,14}$|^09[0-9]{9}$")),
29
+ ("json", re.compile(r"^\s*[\[{].*[\]}]\s*$", re.S)),
30
+ ("persian_text", re.compile(r"[؀-ۿ]")),
31
+ ]
32
+ PII_PATTERNS = {"email", "phone"}
33
+ MONEY_NAME = re.compile(r"(price|amount|cost|fee|rial|toman|total|balance|commission|discount|revenue)", re.I)
34
+
35
+
36
+ def _num(v):
37
+ if isinstance(v, bool):
38
+ return None
39
+ if isinstance(v, (int, float, Decimal)):
40
+ return float(v)
41
+ return None
42
+
43
+
44
+ def _pct(sorted_vals: list[float], p: float) -> float:
45
+ i = (len(sorted_vals) - 1) * p
46
+ lo, hi = math.floor(i), math.ceil(i)
47
+ return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (i - lo)
48
+
49
+
50
+ def _date(v):
51
+ if isinstance(v, dt.datetime):
52
+ return v
53
+ if isinstance(v, dt.date):
54
+ return dt.datetime(v.year, v.month, v.day)
55
+ if isinstance(v, str) and re.match(r"^\d{4}-\d{2}-\d{2}", v):
56
+ try:
57
+ return dt.datetime.fromisoformat(v[:19].replace("T", " "))
58
+ except ValueError:
59
+ return None
60
+ return None
61
+
62
+
63
+ def is_pii_name(name: str) -> bool:
64
+ return bool(PII_NAME.search(name or ""))
65
+
66
+
67
+ def profile_values(values: list, name: str, type_str: str = "", pii: bool = False, method: str = "sample",
68
+ top_k: int = 30, now: dt.datetime | None = None) -> dict:
69
+ """Profile one column from a list of sampled values (None = NULL). Pure function; no I/O."""
70
+ now = now or dt.datetime.now()
71
+ n = len(values)
72
+ nn = [v for v in values if v is not None]
73
+ prof: dict = {"sample_size": n, "null_rate": round(1 - len(nn) / n, 4) if n else None, "method": method,
74
+ "profiled_at": now.strftime("%Y-%m-%dT%H:%M:%S")}
75
+ if not nn:
76
+ return prof
77
+ texts = [v if isinstance(v, str) else None for v in nn]
78
+ strs = [t for t in texts if t is not None]
79
+ pattern = None
80
+ if strs:
81
+ for pname, rx in PATTERNS:
82
+ hits = sum(1 for t in strs[:500] if rx.search(t))
83
+ if hits >= 0.5 * min(len(strs), 500):
84
+ pattern = pname
85
+ break
86
+ pii = pii or is_pii_name(name) or pattern in PII_PATTERNS
87
+ if pii:
88
+ prof["pii"] = True
89
+ cnt = Counter(v if not isinstance(v, (dict, list, bytes, bytearray)) else json.dumps(v, default=str, sort_keys=True)
90
+ if not isinstance(v, (bytes, bytearray)) else "<bytes>" for v in nn)
91
+ prof["distinct_in_sample"] = len(cnt)
92
+ prof["distinct_ratio"] = round(len(cnt) / len(nn), 4)
93
+ fam = type_family(type_str)
94
+ nums = [x for x in (_num(v) for v in nn) if x is not None]
95
+ if pattern:
96
+ prof["pattern"] = pattern
97
+ elif nums and MONEY_NAME.search(name or "") and fam in ("int", "decimal", "float", ""):
98
+ prof["pattern"] = "monetary"
99
+ if strs:
100
+ lens = [len(t) for t in strs]
101
+ prof["length"] = {"min": min(lens), "avg": round(sum(lens) / len(lens), 1), "max": max(lens)}
102
+ if len(nums) >= max(1, len(nn) // 2) and not pii:
103
+ s = sorted(nums)
104
+ prof["numeric"] = {"min": s[0], "max": s[-1], "p5": _pct(s, .05), "p25": _pct(s, .25), "p50": _pct(s, .5),
105
+ "p75": _pct(s, .75), "p95": _pct(s, .95), "mean": round(sum(s) / len(s), 4)}
106
+ dates = [d for d in (_date(v) for v in nn) if d is not None]
107
+ if len(dates) >= max(1, len(nn) // 2):
108
+ prof["dates"] = {"min": min(dates).isoformat(), "max": max(dates).isoformat(),
109
+ "recency_days": round((now - max(dates)).total_seconds() / 86400, 1)}
110
+ if pii:
111
+ prof["note"] = "PII: raw values withheld; counts and pattern only"
112
+ for k in ("numeric",):
113
+ prof.pop(k, None)
114
+ elif len(cnt) <= 100 or prof["distinct_ratio"] < 0.2:
115
+ prof["top_values"] = [[str(k)[:80], c] for k, c in cnt.most_common(top_k)]
116
+ prof["values_complete"] = len(cnt) <= top_k
117
+ if strs and "length" in prof and prof["length"]["max"] > 80:
118
+ prof["top_values"] = [[k[:80], c] for k, c in prof["top_values"]]
119
+ return prof
120
+
121
+
122
+ def reconcile(prof: dict, declared: list[str] | None, codes: dict | None, observed: set[str] | None = None) -> dict:
123
+ """Compare observed values with declared enum members and context codes. Needs complete observed values."""
124
+ out: dict = {}
125
+ if prof.get("pii") or not prof.get("values_complete") and observed is None:
126
+ return out
127
+ seen = observed if observed is not None else {k for k, _ in prof.get("top_values", [])}
128
+ documented = {str(k) for k in (codes or {})} | {m.split("=")[0] for m in (declared or [])}
129
+ if not documented:
130
+ return out
131
+ out["undocumented_codes"] = sorted(seen - documented)
132
+ out["unseen_codes"] = sorted(documented - seen)
133
+ return {k: v for k, v in out.items() if v}
134
+
135
+
136
+ def summarize(p: dict) -> str:
137
+ bits = [f"{p.get('sample_size', 0)} rows sampled ({p.get('method', '?')}, {p.get('profiled_at', '')[:10]})",
138
+ f"null {p['null_rate']:.0%}" if p.get("null_rate") is not None else ""]
139
+ if "distinct_in_sample" in p:
140
+ bits.append(f"{p['distinct_in_sample']} distinct")
141
+ if p.get("pii"):
142
+ bits.append("PII, values withheld")
143
+ if p.get("pattern"):
144
+ bits.append(f"looks like {p['pattern']}")
145
+ if p.get("numeric"):
146
+ n = p["numeric"]
147
+ bits.append(f"range {n['min']:g}..{n['max']:g}, median {n['p50']:g}")
148
+ if p.get("dates"):
149
+ bits.append(f"dates {p['dates']['min'][:10]}..{p['dates']['max'][:10]} (latest {p['dates']['recency_days']} d ago)")
150
+ if p.get("top_values"):
151
+ bits.append("top: " + ", ".join(f"{k} ({c})" for k, c in p["top_values"][:6]))
152
+ r = p.get("reconcile") or {}
153
+ if r.get("undocumented_codes"):
154
+ bits.append("UNDOCUMENTED in data: " + ", ".join(r["undocumented_codes"][:8]))
155
+ if r.get("unseen_codes"):
156
+ bits.append("documented but not seen: " + ", ".join(r["unseen_codes"][:8]))
157
+ return "; ".join(b for b in bits if b)
158
+
159
+
160
+ def build_sample_sql(dialect: str, table_ref: str, cols: list[str], n: int, strategy: str = "limit", pk: str | None = None,
161
+ quote: str = '"') -> str:
162
+ q = lambda c: f"{quote}{c}{quote}" # noqa: E731
163
+ sel = ", ".join(q(c) for c in cols)
164
+ if strategy == "recent" and pk:
165
+ where = f" WHERE {q(pk)} > (SELECT MAX({q(pk)}) FROM {table_ref}) - {2 * n}"
166
+ elif strategy == "tablesample":
167
+ if dialect == "postgres":
168
+ return f"SELECT {sel} FROM {table_ref} TABLESAMPLE SYSTEM (1) LIMIT {n}"
169
+ if dialect == "clickhouse":
170
+ return f"SELECT {sel} FROM {table_ref} SAMPLE 0.01 LIMIT {n}"
171
+ where = ""
172
+ else:
173
+ where = ""
174
+ if dialect == "sqlserver":
175
+ return f"SELECT TOP {n} {sel} FROM {table_ref}{where}"
176
+ return f"SELECT {sel} FROM {table_ref}{where} LIMIT {n}"
177
+
178
+
179
+ def profile_table(source, table, node: dict, n: int = 10000, strategy: str = "auto", budget_s: float = 20.0,
180
+ skip_over_rows: int | None = 50_000_000, force: bool = False, pii_cols: set[str] | None = None,
181
+ codes_by_col: dict | None = None, max_cols: int = 200) -> dict:
182
+ """Profile one table through source.sample(table, cols, n, strategy) -> (cols, rows). Returns {col: profile}
183
+ plus "_meta". Never scans a whole table: the sample is always LIMIT/TOP bounded."""
184
+ rows_est = (node.get("info") or {}).get("rows_estimate")
185
+ if skip_over_rows and rows_est and rows_est > skip_over_rows and not force:
186
+ return {"_meta": {"skipped": f"about {rows_est} rows exceeds the {skip_over_rows} limit (use force)"}}
187
+ cols = [c["name"] for c in node["columns"]][:max_cols]
188
+ pk = node["primary_key"][0] if len(node["primary_key"]) == 1 else None
189
+ pkfam = next((c["family"] for c in node["columns"] if c["name"] == pk), "")
190
+ strat = ("recent" if pk and pkfam == "int" else "limit") if strategy == "auto" else strategy
191
+ t0 = time.time()
192
+ got_cols, rows = source.sample(table, cols, n, strat, pk)
193
+ elapsed = time.time() - t0
194
+ out: dict = {"_meta": {"elapsed_s": round(elapsed, 2), "rows": len(rows), "strategy": strat,
195
+ "over_budget": elapsed > budget_s}}
196
+ method = f"{strat} n={n}"
197
+ by = {c["name"]: c for c in node["columns"]}
198
+ for i, c in enumerate(got_cols):
199
+ vals = [r[i] for r in rows]
200
+ p = profile_values(vals, c, by[c]["type"], pii=bool(pii_cols and c in pii_cols), method=method)
201
+ if by[c].get("values") or (codes_by_col or {}).get(c):
202
+ rec = reconcile(p, by[c].get("values"), (codes_by_col or {}).get(c))
203
+ if rec:
204
+ p["reconcile"] = rec
205
+ out[c] = p
206
+ return out
207
+
208
+
209
+ def run_profile(graph, source, ctx=None, tables: list[str] | None = None, top_hubs: int = 0, with_context: bool = False,
210
+ total_budget_s: float = 600.0, log=print, **opts) -> dict:
211
+ """Profile a bounded set of tables; merge into graph.data['profiles'] and (when given) the context db.
212
+
213
+ The table set is: explicit `tables`, plus the `top_hubs` most-connected tables, plus every table with context notes."""
214
+ chosen: list[str] = [graph.resolve(t) for t in (tables or [])]
215
+ if top_hubs:
216
+ deg: dict[str, int] = {}
217
+ for e in graph.edges:
218
+ deg[e["from"]] = deg.get(e["from"], 0) + 1
219
+ deg[e["to"]] = deg.get(e["to"], 0) + 1
220
+ chosen += [t for t, _ in sorted(deg.items(), key=lambda x: (-x[1], x[0]))[:top_hubs]]
221
+ pii_cols: dict[str, set] = {}
222
+ codes: dict[str, dict] = {}
223
+ if ctx is not None:
224
+ if with_context:
225
+ chosen += [r["table_id"] for r in ctx.rows("table_notes") if r["table_id"] in graph.nodes]
226
+ for r in ctx.rows("column_semantics"):
227
+ if r["pii"]:
228
+ pii_cols.setdefault(r["table_id"], set()).add(r["col"])
229
+ if r["codes"]:
230
+ codes.setdefault(r["table_id"], {})[r["col"]] = json.loads(r["codes"])
231
+ chosen = list(dict.fromkeys(chosen))
232
+ store = graph.data.setdefault("profiles", {})
233
+ report = {"requested": len(chosen), "profiled": 0, "columns": 0, "skipped": {}, "failed": {}, "findings": [], "seconds": 0}
234
+ t0 = time.time()
235
+ for tid in chosen:
236
+ if time.time() - t0 > total_budget_s:
237
+ report["skipped"][tid] = "total time budget used up"
238
+ continue
239
+ node = graph.nodes[tid]
240
+ try:
241
+ res = profile_table(source, tid, node, pii_cols=pii_cols.get(tid), codes_by_col=codes.get(tid), **opts)
242
+ except Exception as exc: # noqa: BLE001
243
+ report["failed"][tid] = str(exc)[:200]
244
+ continue
245
+ meta = res.pop("_meta")
246
+ if "skipped" in meta:
247
+ report["skipped"][tid] = meta["skipped"]
248
+ continue
249
+ for col, p in res.items():
250
+ store[f"{tid}.{col}"] = p
251
+ report["columns"] += 1
252
+ rec = p.get("reconcile") or {}
253
+ if rec.get("undocumented_codes"):
254
+ report["findings"].append(f"{tid}.{col}: values in data but undocumented: {', '.join(rec['undocumented_codes'][:6])}")
255
+ report["profiled"] += 1
256
+ if meta.get("over_budget"):
257
+ report.setdefault("over_budget", []).append(tid)
258
+ log(f" profiled {tid}: {meta['rows']} rows, {meta['elapsed_s']}s")
259
+ report["seconds"] = round(time.time() - t0, 1)
260
+ if ctx is not None:
261
+ ctx.save_profiles(store)
262
+ return report
keygraph/query.py ADDED
@@ -0,0 +1,376 @@
1
+ """Join-path search, neighbours, column explanation, key listing and text formatting. Reads only the graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import re
7
+
8
+ from .graph import AmbiguousTable, Graph, UnknownTable
9
+ from .mirrors import mirror_tables
10
+
11
+
12
+ def aliases_for(tables: list[str]) -> list[str]:
13
+ used: dict[str, int] = {}
14
+ out = []
15
+ for t in tables:
16
+ name = t.split(".", 1)[1]
17
+ base = "".join(w[0] for w in re.split(r"[_\W]+", name) if w) or "t"
18
+ used[base] = used.get(base, 0) + 1
19
+ out.append(base if used[base] == 1 else f"{base}{used[base]}")
20
+ # a bare single alias that collides with an existing numbered one stays unique by construction
21
+ return out
22
+
23
+
24
+ def join_sql(graph: Graph, path: list[dict]) -> str:
25
+ tables = [path[0]["near"]] + [h["far"] for h in path]
26
+ al = aliases_for(tables)
27
+ lines = [f"FROM {tables[0]} AS {al[0]}"]
28
+ for i, h in enumerate(path):
29
+ on = " AND ".join(f"{al[i]}.{a} = {al[i + 1]}.{b}" for a, b in zip(h["near_cols"], h["far_cols"]))
30
+ lines.append(f"JOIN {tables[i + 1]} AS {al[i + 1]} ON {on}")
31
+ return "\n".join(lines)
32
+
33
+
34
+ HUB_PENALTY, FANOUT_PENALTY, LOG_PENALTY = 0.7, 0.85, 0.8
35
+ # log, archive and scratch words: a table named with one of them (as a whole token) is a poor join step
36
+ LOG_HINTS = ("log", "logs", "archive", "history", "audit", "bak", "backup", "old", "copy", "test", "shadow", "tmp", "temp")
37
+ _LOG_TOKENS = frozenset(LOG_HINTS)
38
+
39
+
40
+ def _is_log(graph: Graph, t: str) -> bool:
41
+ """True when the table or schema name has a log-like or scratch word as a whole underscore-separated token."""
42
+ n = graph.nodes[t]
43
+ toks = set(re.split(r"[_\W]+", n["table"].lower())) | set(re.split(r"[_\W]+", n["schema"].lower()))
44
+ return bool(toks & _LOG_TOKENS)
45
+
46
+
47
+ def _hop_warnings(graph: Graph, h: dict, end: str | None = None, hubs: set | None = None) -> tuple[list[str], float]:
48
+ """Penalties for one step. Passing *through* a hub costs HUB_PENALTY once (on the step that arrives at it);
49
+ a hub the reader asked for as an endpoint is never penalised."""
50
+ w, f = [], 1.0
51
+ hubs = set(graph.data.get("hubs") or []) if hubs is None else hubs
52
+ if h["far"] in hubs and h["far"] != end:
53
+ w.append("hub"); f *= HUB_PENALTY
54
+ if h["cardinality"] == "one-to-many":
55
+ w.append("fan_out"); f *= FANOUT_PENALTY
56
+ if _is_log(graph, h["far"]):
57
+ w.append("log"); f *= LOG_PENALTY
58
+ return w, f
59
+
60
+
61
+ def find_paths(graph: Graph, a: str, b: str, max_hops: int = 4, min_conf: float = 0.6, k: int = 3,
62
+ max_pops: int = 300000, include_mirrors: bool = False) -> list[dict]:
63
+ """Best join routes, ranked by score: the product of step confidences times the hub, fan-out and log
64
+ penalties, then fewer hops. Tables in mirror schemas are skipped unless asked for or an endpoint is one."""
65
+ a, b = graph.resolve(a), graph.resolve(b)
66
+ if a == b:
67
+ return []
68
+ adj = graph.adjacency()
69
+ hubs = set(graph.data.get("hubs") or [])
70
+ mirrors = mirror_tables(graph)
71
+ if include_mirrors or a in mirrors or b in mirrors:
72
+ mirrors = set()
73
+ heap: list = [(-1.0, 0, 0, a, ())]
74
+ seq, found, pops = 0, [], 0
75
+ while heap and len(found) < k and pops < max_pops:
76
+ negp, hops, _, node, path = heapq.heappop(heap)
77
+ pops += 1
78
+ if node == b:
79
+ found.append((path, -negp))
80
+ continue
81
+ if hops >= max_hops:
82
+ continue
83
+ seen = {a} | {h["far"] for h in path}
84
+ per_far: dict[str, int] = {}
85
+ for h in sorted(adj.get(node, []), key=lambda x: -x["edge"]["confidence"]):
86
+ c = h["edge"]["confidence"]
87
+ if c < min_conf or h["far"] in seen or (h["far"] in mirrors and h["far"] != b):
88
+ continue
89
+ per_far[h["far"]] = per_far.get(h["far"], 0) + 1
90
+ if per_far[h["far"]] > 2:
91
+ continue
92
+ seq += 1
93
+ warns, factor = _hop_warnings(graph, h, b, hubs)
94
+ heapq.heappush(heap, (negp * c * factor, hops + 1, seq, h["far"], path + (h,)))
95
+ res = []
96
+ raw: list[float] = []
97
+ for path, p in found:
98
+ raw.append(p)
99
+ path = list(path)
100
+ conf = 1.0
101
+ for h in path:
102
+ conf *= h["edge"]["confidence"]
103
+ res.append({
104
+ "tables": [path[0]["near"]] + [h["far"] for h in path], "confidence": round(conf, 3), "score": round(p, 3),
105
+ "hops": len(path),
106
+ "steps": [{"from": h["near"], "from_columns": h["near_cols"], "to": h["far"], "to_columns": h["far_cols"],
107
+ "kind": h["edge"]["kind"], "status": h["edge"]["status"], "confidence": h["edge"]["confidence"],
108
+ "cardinality": h["cardinality"], "ambiguous": h["edge"]["ambiguous"], "evidence": h["edge"]["evidence"],
109
+ "fan_out": h["cardinality"] == "one-to-many", "warnings": _hop_warnings(graph, h, b, hubs)[0]} for h in path],
110
+ "sql": join_sql(graph, path),
111
+ })
112
+ # sort on the unrounded score (rounding to 3 places would let near-ties fall back to hop count)
113
+ order = sorted(range(len(res)), key=lambda i: (-raw[i], res[i]["hops"], i))
114
+ return [res[i] for i in order]
115
+
116
+
117
+ def format_paths(a: str, b: str, paths: list[dict]) -> str:
118
+ if not paths:
119
+ return f"No join path found between {a} and {b} within the limits (try --max-hops or a lower --min-confidence)."
120
+ out = []
121
+ for i, p in enumerate(paths, 1):
122
+ out.append(f"Path {i}: {' -> '.join(p['tables'])} (score {p['score']}, confidence {p['confidence']}, {p['hops']} hop{'s' if p['hops'] > 1 else ''})")
123
+ for s in p["steps"]:
124
+ ws = s.get("warnings", [])
125
+ warn = (" \u26a0 " + " / ".join(w.replace("_", "-") for w in ws)) if ws else ""
126
+ amb = " [ambiguous]" if s["ambiguous"] else ""
127
+ out.append(f" {s['from']}({','.join(s['from_columns'])}) -> {s['to']}({','.join(s['to_columns'])}) "
128
+ f"[{s['kind']} {s['confidence']:.2f}, {s['cardinality']}]{warn}{amb}")
129
+ for ev in s["evidence"]:
130
+ out.append(f" - {ev}")
131
+ out.append("")
132
+ out.append(" " + p["sql"].replace("\n", "\n "))
133
+ out.append("")
134
+ return "\n".join(out).rstrip()
135
+
136
+
137
+ def neighbors(graph: Graph, t: str, depth: int = 1, min_conf: float = 0.0) -> dict:
138
+ t = graph.resolve(t)
139
+ adj = graph.adjacency()
140
+ seen = {t: 0}
141
+ levels: dict[int, list[dict]] = {}
142
+ frontier = [t]
143
+ for d in range(1, depth + 1):
144
+ nxt = []
145
+ for node in frontier:
146
+ for h in adj[node]:
147
+ if h["edge"]["confidence"] < min_conf:
148
+ continue
149
+ levels.setdefault(d, []).append({
150
+ "from": h["near"], "from_columns": h["near_cols"], "to": h["far"], "to_columns": h["far_cols"],
151
+ "kind": h["edge"]["kind"], "confidence": h["edge"]["confidence"], "cardinality": h["cardinality"],
152
+ "ambiguous": h["edge"]["ambiguous"]})
153
+ if h["far"] not in seen:
154
+ seen[h["far"]] = d
155
+ nxt.append(h["far"])
156
+ frontier = nxt
157
+ for lv in levels.values():
158
+ lv.sort(key=lambda x: (-x["confidence"], x["to"]))
159
+ return {"table": t, "depth": depth, "levels": {str(k): v for k, v in sorted(levels.items())}}
160
+
161
+
162
+ def format_neighbors(r: dict) -> str:
163
+ out = [f"{r['table']} (depth {r['depth']})"]
164
+ for d, items in r["levels"].items():
165
+ out.append(f" hop {d}:")
166
+ for x in items:
167
+ out.append(f" {x['from']}({','.join(x['from_columns'])}) -> {x['to']}({','.join(x['to_columns'])}) "
168
+ f"[{x['kind']} {x['confidence']:.2f}, {x['cardinality']}]{' [ambiguous]' if x['ambiguous'] else ''}")
169
+ if len(out) == 1:
170
+ out.append(" no relations found")
171
+ return "\n".join(out)
172
+
173
+
174
+ def explain(graph: Graph, ref: str) -> dict:
175
+ t, col = graph.split_column(ref)
176
+ if col is None:
177
+ raise ValueError("explain needs table.column")
178
+ n = graph.nodes[t]
179
+ cd = next(c for c in n["columns"] if c["name"] == col)
180
+ out = {"table": t, "column": col, "type": cd["type"], "detail": column_detail(cd), "column_info": cd,
181
+ "profile": (graph.data.get("profiles") or {}).get(f"{t}.{col}"),
182
+ "in_primary_key": col in n["primary_key"],
183
+ "in_unique_keys": [u["name"] for u in n["unique_keys"] if col in u["columns"]],
184
+ "in_indexes": [{"name": i["name"], "position": i["columns"].index(col) + 1, "columns": i["columns"]}
185
+ for i in n["indexes"] if col in i["columns"]],
186
+ "references": [], "referenced_by": [], "polymorphic": [p for p in n["polymorphic"] if col in (p["id_column"], p["type_column"])]}
187
+ for e in graph.edges:
188
+ row = {"edge": e["id"], "kind": e["kind"], "status": e["status"], "confidence": e["confidence"],
189
+ "cardinality": e["cardinality"], "ambiguous": e["ambiguous"], "evidence": e["evidence"]}
190
+ if e["from"] == t and col in e["from_columns"]:
191
+ out["references"].append({**row, "table": e["to"], "columns": e["to_columns"]})
192
+ if e["to"] == t and col in e["to_columns"]:
193
+ out["referenced_by"].append({**row, "table": e["from"], "columns": e["from_columns"]})
194
+ out["unresolved"] = col in n["unresolved_id_columns"]
195
+ return out
196
+
197
+
198
+ def format_explain(x: dict) -> str:
199
+ out = [f"{x['table']}.{x['column']} ({x['detail']})"]
200
+ keys = []
201
+ if x["in_primary_key"]:
202
+ keys.append("primary key")
203
+ keys += [f"unique key {u}" for u in x["in_unique_keys"]]
204
+ keys += [f"index {i['name']} (position {i['position']} of {len(i['columns'])})" for i in x["in_indexes"]]
205
+ out.append(" keys: " + ("; ".join(keys) if keys else "not part of any key or index"))
206
+ if x.get("profile"):
207
+ from .profile import summarize
208
+ out.append(" values: " + summarize(x["profile"]))
209
+ for p in x["polymorphic"]:
210
+ out.append(f" polymorphic: {p['note']} (id={p['id_column']}, type={p['type_column']})")
211
+ for title, rows in (("references", x["references"]), ("referenced by", x["referenced_by"])):
212
+ if rows:
213
+ out.append(f" {title}:")
214
+ for r in rows:
215
+ out.append(f" {r['table']}({','.join(r['columns'])}) [{r['kind']} {r['confidence']:.2f}, {r['cardinality']}]"
216
+ f"{' [ambiguous]' if r['ambiguous'] else ''}")
217
+ out += [f" - {e}" for e in r["evidence"]]
218
+ if not (x["references"] or x["referenced_by"] or x["polymorphic"]):
219
+ out.append(" no relations recorded" + (" (looks like an id column but no target table was found)" if x["unresolved"] else ""))
220
+ return "\n".join(out)
221
+
222
+
223
+ def keys(graph: Graph, ref: str) -> dict:
224
+ t = graph.resolve(ref)
225
+ n = graph.nodes[t]
226
+ return {"table": t, "info_line": table_info_line(n), "primary_key": n["primary_key"], "unique_keys": n["unique_keys"], "indexes": n["indexes"],
227
+ "polymorphic": n["polymorphic"], "redundant_indexes": n["redundant_indexes"]}
228
+
229
+
230
+ def format_keys(x: dict) -> str:
231
+ out = [x["table"]]
232
+ if x.get("info_line"):
233
+ out.append(" " + x["info_line"])
234
+ out += [" primary key: " + (", ".join(x["primary_key"]) or "NONE")]
235
+ out.append(" unique keys: " + ("; ".join(f"{u['name']}({', '.join(u['columns'])})" for u in x["unique_keys"]) or "none"))
236
+ out.append(" indexes (column order matters; filter on the leading column first):")
237
+ for i in x["indexes"]:
238
+ out.append(f" {i['name']}{' [unique]' if i['unique'] and not i['primary'] else ''}{' [primary]' if i['primary'] else ''}"
239
+ f"{' [approximate]' if i.get('approximate') else ''}: " + ", ".join(i["columns"]))
240
+ for r in x["redundant_indexes"]:
241
+ out.append(f" redundant: {r['index']} is covered by {r['covered_by']} ({r['reason']})")
242
+ return "\n".join(out)
243
+
244
+
245
+ def filter_columns(node: dict, ctx_notes: dict | None = None) -> list[str]:
246
+ """Columns that make an efficient WHERE: leading index columns, plus declared date and soft-delete columns."""
247
+ cols: list[str] = []
248
+ for ix in sorted(node["indexes"], key=lambda i: (not i["primary"], i["name"])):
249
+ if ix["columns"] and ix["columns"][0] not in cols:
250
+ cols.append(ix["columns"][0])
251
+ if ctx_notes:
252
+ for k in ("date_column", "soft_delete"):
253
+ v = ctx_notes.get(k)
254
+ if v and v not in cols:
255
+ cols.append(v)
256
+ return cols
257
+
258
+
259
+ def column_detail(c: dict) -> str:
260
+ """One line with everything a table explain shows for a column."""
261
+ bits = [c["type"] + ("" if c.get("nullable", True) else " NOT NULL")]
262
+ if "default" in c:
263
+ bits.append(f"default {c['default']}")
264
+ for k in ("extra", "generated"):
265
+ if c.get(k):
266
+ bits.append(("generated: " if k == "generated" else "") + c[k])
267
+ if c.get("charset") or c.get("collation"):
268
+ bits.append("/".join(x for x in (c.get("charset"), c.get("collation")) if x))
269
+ if c.get("values"):
270
+ bits.append("members: " + ", ".join(c["values"][:30]) + (" ..." if len(c["values"]) > 30 else ""))
271
+ if c.get("comment"):
272
+ bits.append(f"comment: {c['comment']}")
273
+ return "; ".join(bits)
274
+
275
+
276
+ def table_info_line(node: dict) -> str:
277
+ i = node.get("info") or {}
278
+ parts = []
279
+ for k, label in (("engine", "engine"), ("rows_estimate", "rows (estimate)"), ("data_bytes", "data bytes"), ("partitioning", "partitioning"),
280
+ ("partition_key", "partition key"), ("sorting_key", "order by"), ("comment", "comment")):
281
+ if i.get(k) not in (None, ""):
282
+ parts.append(f"{label}: {i[k]}")
283
+ for ck in i.get("checks", []):
284
+ parts.append(f"check {ck.get('name', '')}: {ck.get('expr', '')}")
285
+ return "; ".join(parts)
286
+
287
+
288
+ def _area_name(areas: list[dict]) -> dict[str, str]:
289
+ return {a["id"]: a["name"] for a in areas}
290
+
291
+
292
+ def list_areas(graph: Graph, include_mirrors: bool = False) -> list[dict]:
293
+ areas = [a for a in graph.data.get("areas", []) if include_mirrors or not a.get("mirror")]
294
+ return [{"id": a["id"], "name": a["name"], "size": a["size"], "tables": list(a["tables"]), "top": a.get("top", []),
295
+ "curated": not a.get("auto", True), **({"mirror": a["mirror"], "mirror_of": a.get("mirror_of")} if a.get("mirror") else {}),
296
+ **({"unconnected": True} if a.get("unconnected") else {})} for a in areas]
297
+
298
+
299
+ def format_areas(rows: list[dict]) -> str:
300
+ if not rows:
301
+ return "no areas"
302
+ return "\n".join(f"{r['id']} {r['name']} ({r['size']} tables{', curated' if r['curated'] else ''}): {', '.join(r['top'])}"
303
+ for r in rows)
304
+
305
+
306
+ def area_detail(graph: Graph, key: str, grains: dict[str, str] | None = None, include_mirrors: bool = False) -> dict:
307
+ """Resolve an area by id, case-insensitive name, or a table (-> its area). Mirror areas need include_mirrors."""
308
+ import difflib
309
+
310
+ areas = graph.data.get("areas", [])
311
+ low = key.lower()
312
+ hit = next((a for a in areas if a["id"] == key), None) or next((a for a in areas if a["name"].lower() == low), None)
313
+ if hit is None:
314
+ try:
315
+ t = graph.resolve(key)
316
+ except AmbiguousTable:
317
+ raise
318
+ except UnknownTable:
319
+ names = [a["name"] for a in areas]
320
+ sug = difflib.get_close_matches(key, names, n=3, cutoff=0.5) + difflib.get_close_matches(low, [n.lower() for n in names], n=3, cutoff=0.5)
321
+ sug = [n for n in dict.fromkeys(sug) if n in names] or names[:3]
322
+ raise ValueError(f"unknown area or table '{key}'; areas include: {', '.join(sug)}")
323
+ hit = next((a for a in areas if t in a["tables"]), None)
324
+ if hit is None:
325
+ raise ValueError(f"table {t} is not in any area")
326
+ if hit.get("mirror") and not include_mirrors:
327
+ raise ValueError(f"'{key}' is in schema {hit['mirror']}, a mirror of {hit.get('mirror_of')}; "
328
+ f"use --include-mirrors to show it, or ask for the original schema's table")
329
+ grains = grains or {}
330
+ inside = set(hit["tables"])
331
+ names = _area_name(areas)
332
+ internal = [{"from": e["from"], "to": e["to"], "columns": e["from_columns"], "confidence": e["confidence"]}
333
+ for e in graph.edges if e["from"] in inside and e["to"] in inside and e["from"] != e["to"]
334
+ and not (e.get("hub") or e.get("role"))]
335
+ nb = []
336
+ for l in graph.data.get("area_links", []):
337
+ if hit["id"] in (l["a"], l["b"]):
338
+ o = l["b"] if l["a"] == hit["id"] else l["a"]
339
+ nb.append({"id": o, "name": names.get(o, o), "edges": l["edges"]})
340
+ nb.sort(key=lambda x: (-x["edges"], x["name"]))
341
+ labels: dict[tuple, dict] = {}
342
+ for e in graph.edges:
343
+ if e["from"] in inside and (e.get("hub") or e.get("role")):
344
+ h = labels.setdefault(e["to"], {"to": e["to"], "columns": [], "count": 0})
345
+ h["count"] += 1
346
+ for c in e["from_columns"]:
347
+ if c not in h["columns"]:
348
+ h["columns"].append(c)
349
+ return {"id": hit["id"], "name": hit["name"], "curated": not hit.get("auto", True),
350
+ "tables": list(hit["tables"]), "grains": {t: grains[t] for t in hit["tables"] if grains.get(t)},
351
+ "internal": internal, "neighbours": nb, "hub_labels": sorted(labels.values(), key=lambda h: (-h["count"], h["to"]))}
352
+
353
+
354
+ def format_area(d: dict) -> str:
355
+ out = [f"{d['name']} [{d['id']}]{' (curated)' if d['curated'] else ''}: {len(d['tables'])} tables", "", "tables:"]
356
+ out += [f" {t}" + (f" - {d['grains'][t]}" if t in d["grains"] else "") for t in d["tables"]]
357
+ out += ["", f"internal relations ({len(d['internal'])}):"]
358
+ out += [f" {r['from']}({', '.join(r['columns'])}) -> {r['to']} conf {r['confidence']:.2f}" for r in d["internal"][:40]]
359
+ if len(d["internal"]) > 40:
360
+ out.append(f" ... and {len(d['internal']) - 40} more (use --json)")
361
+ out += ["", "neighbouring areas:"] + ([f" {n['name']} [{n['id']}]: {n['edges']} relation{'s' if n['edges'] != 1 else ''}" for n in d["neighbours"]] or [" (none)"])
362
+ out += ["", "hub references:"] + ([f" {h['to']} via {', '.join(h['columns'])} ({h['count']})" for h in d["hub_labels"]] or [" (none)"])
363
+ return "\n".join(out)
364
+
365
+
366
+ def format_relation_summary(s: dict) -> str:
367
+ out = [" relation summary:"]
368
+ for title, key in (("points to", "out"), ("pointed to by", "in")):
369
+ out.append(f" {title}:")
370
+ for g in s[key]:
371
+ out.append(f" via {g['column']}: {', '.join(g['tables'][:8])}" + (f" (+{g['count'] - 8} more)" if g["count"] > 8 else ""))
372
+ if not s[key]:
373
+ out.append(" (none)")
374
+ if s["hub_labels"]:
375
+ out.append(" hub references: " + ", ".join(f"{h['to']} via {', '.join(h['columns'])}" for h in s["hub_labels"]))
376
+ return "\n".join(out)