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/infer.py ADDED
@@ -0,0 +1,344 @@
1
+ """Relation inference: declared foreign keys plus naming, role, shared-key-name and polymorphic heuristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from .model import Schema, Table, type_family, type_length
9
+
10
+ DEFAULT_ROLE_WORDS = {
11
+ "created", "updated", "deleted", "modified", "approved", "rejected", "assigned", "owner",
12
+ "parent", "original", "old", "new", "prev", "previous", "next", "source", "target", "from",
13
+ "to", "main", "primary", "default", "related", "linked", "buyer", "requested", "last", "first",
14
+ }
15
+ USER_LIKE = ["user", "users", "account", "accounts", "admin", "admins", "staff", "employee",
16
+ "employees", "member", "members", "person", "people", "operator", "operators"]
17
+ BY_ROLES = {"created_by", "updated_by", "deleted_by", "modified_by", "approved_by", "rejected_by",
18
+ "assigned_by", "author", "creator", "owner", "editor", "assignee", "approver",
19
+ "reviewer", "requester", "updater"}
20
+ SELF_ROLES = {"parent", "prev", "previous", "next", "original", "root", "master", "superseded"}
21
+ POLY_SUFFIXES = ("type", "class", "table", "kind")
22
+
23
+
24
+ @dataclass
25
+ class Options:
26
+ min_confidence: float = 0.3
27
+ top_k: int = 3
28
+ prefixes: list[str] = field(default_factory=list)
29
+ aliases: dict[str, str] = field(default_factory=dict) # stem -> table ref
30
+ role_aliases: dict[str, list[str]] = field(default_factory=dict) # role stem -> table names
31
+ role_words: set[str] = field(default_factory=lambda: set(DEFAULT_ROLE_WORDS))
32
+ auto_prefixes: bool = True
33
+
34
+
35
+ def singular(w: str) -> str:
36
+ if w.endswith("ies") and len(w) > 4:
37
+ return w[:-3] + "y"
38
+ if w.endswith(("sses", "xes", "ches", "shes", "zes")):
39
+ return w[:-2]
40
+ if w.endswith("s") and not w.endswith("ss") and len(w) > 3:
41
+ return w[:-1]
42
+ return w
43
+
44
+
45
+ def plural(w: str) -> str:
46
+ if w.endswith("y") and len(w) > 2 and w[-2] not in "aeiou":
47
+ return w[:-1] + "ies"
48
+ if w.endswith(("s", "x", "z", "ch", "sh")):
49
+ return w + "es"
50
+ return w + "s"
51
+
52
+
53
+ def forms(w: str) -> set[str]:
54
+ s = singular(w)
55
+ return {w, s, plural(s), plural(w) if not w.endswith("s") else w}
56
+
57
+
58
+ def snake(name: str) -> str:
59
+ n = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
60
+ return n.lower()
61
+
62
+
63
+ def extract_stem(col: str) -> str | None:
64
+ n = snake(col)
65
+ if n == "id":
66
+ return None
67
+ if n.endswith("_id") and len(n) > 3:
68
+ return n[:-3]
69
+ if n.endswith("id") and len(n) > 4 and n[-3].isalpha() and "_" not in n:
70
+ return n[:-2]
71
+ return None
72
+
73
+
74
+ def _ref(opts_ref: str, schema: Schema, prefer_schema: str) -> str | None:
75
+ if opts_ref in schema.tables:
76
+ return opts_ref
77
+ hits = [t for t in schema.tables if t.split(".", 1)[1] == opts_ref]
78
+ same = [t for t in hits if t.startswith(prefer_schema + ".")]
79
+ if same:
80
+ return same[0]
81
+ return hits[0] if len(hits) == 1 else None
82
+
83
+
84
+ def _is_indexed_leading(t: Table, cols: list[str]) -> str | None:
85
+ n = len(cols)
86
+ for ix in t.indexes:
87
+ if len(ix.columns) >= n and set(ix.columns[:n]) == set(cols):
88
+ return ix.name
89
+ return None
90
+
91
+
92
+ def cardinality(t: Table, cols: list[str]) -> str:
93
+ s = set(cols)
94
+ if t.primary_key and set(t.primary_key) == s:
95
+ return "one-to-one"
96
+ if any(set(u) == s for u in t.unique_keys):
97
+ return "one-to-one"
98
+ return "many-to-one"
99
+
100
+
101
+ def target_key(t: Table, stem: str, colname: str):
102
+ pk = t.primary_key
103
+ if len(pk) == 1:
104
+ return pk, "primary key", 0.08
105
+ for u in t.unique_keys:
106
+ if len(u) == 1 and (u[0].lower() in (colname.lower(), "id", stem + "_id")):
107
+ return u, "unique key", 0.03
108
+ if not pk and not t.unique_keys:
109
+ return None
110
+ for u in ([pk] if pk else []) + t.unique_keys:
111
+ if len(u) > 1 and u[0].lower() == colname.lower() and t.column(u[0]):
112
+ return [u[0]], "leading column of a composite key (partial; join may fan out)", -0.25
113
+ return None
114
+
115
+
116
+ class Inferer:
117
+ def __init__(self, schema: Schema, opts: Options):
118
+ self.s = schema
119
+ self.o = opts
120
+ self.lookup: dict[str, list[tuple[str, str]]] = {}
121
+ self.suffix_lookup: dict[str, list[str]] = {}
122
+ self.pkname: dict[str, list[str]] = {}
123
+ self._index()
124
+
125
+ def _auto_prefixes(self) -> dict[str, set[str]]:
126
+ out: dict[str, set[str]] = {}
127
+ if not self.o.auto_prefixes:
128
+ return out
129
+ for sch in {t.schema for t in self.s.tables.values()}:
130
+ names = [t.name for t in self.s.tables.values() if t.schema == sch]
131
+ cnt: dict[str, int] = {}
132
+ for n in names:
133
+ if "_" in n:
134
+ cnt[n.split("_", 1)[0]] = cnt.get(n.split("_", 1)[0], 0) + 1
135
+ out[sch] = {p + "_" for p, c in cnt.items() if c >= max(4, int(0.1 * len(names)))}
136
+ return out
137
+
138
+ def _index(self) -> None:
139
+ auto = self._auto_prefixes()
140
+ for t in self.s.tables.values():
141
+ name = t.name.lower()
142
+ variants = {name: ""}
143
+ for p in list(self.o.prefixes) + sorted(auto.get(t.schema, ())):
144
+ if name.startswith(p.lower()) and len(name) > len(p):
145
+ variants.setdefault(name[len(p):], p)
146
+ for v, pref in variants.items():
147
+ for f in forms(v):
148
+ self.lookup.setdefault(f, []).append((t.id, pref))
149
+ parts = name.split("_")
150
+ for i in range(1, len(parts)):
151
+ suf = "_".join(parts[i:])
152
+ if len(suf) >= 3:
153
+ for f in forms(suf):
154
+ self.suffix_lookup.setdefault(f, []).append(t.id)
155
+ pk = t.primary_key
156
+ if len(pk) == 1 and pk[0].lower() != "id":
157
+ self.pkname.setdefault(pk[0].lower(), []).append(t.id)
158
+
159
+ # --- candidate generation -------------------------------------------------
160
+ def _add(self, cands: dict, tid: str, base: float, reason: str) -> None:
161
+ cur = cands.get(tid)
162
+ if cur is None:
163
+ cands[tid] = {"base": base, "reasons": [reason]}
164
+ else:
165
+ cur["base"] = max(cur["base"], base)
166
+ if reason not in cur["reasons"]:
167
+ cur["reasons"].append(reason)
168
+
169
+ def candidates(self, t: Table, colname: str, stem: str) -> dict:
170
+ c: dict = {}
171
+ words = stem.split("_")
172
+ for key in ["_".join(words[i:]) for i in range(len(words))]:
173
+ ref = self.o.aliases.get(key)
174
+ if ref:
175
+ tid = _ref(ref, self.s, t.schema)
176
+ if tid:
177
+ self._add(c, tid, 0.9, f"alias '{key}' -> {tid} (user override)")
178
+ if stem in self.o.role_aliases or stem in BY_ROLES or stem.endswith("_by"):
179
+ names = list(self.o.role_aliases.get(stem, [])) + USER_LIKE
180
+ for nm in names:
181
+ tid = _ref(nm, self.s, t.schema)
182
+ if tid:
183
+ self._add(c, tid, 0.65, f"role column '{colname}' reads as a user reference; mapped to {tid}")
184
+ break
185
+ if words[0] in SELF_ROLES and (len(words) == 1 or any(
186
+ tid == t.id for f in forms("_".join(words[1:])) for tid, _ in self.lookup.get(f, []))):
187
+ self._add(c, t.id, 0.8 if words[0] == "parent" else 0.7,
188
+ f"self-reference: '{colname}' points at this table's own key")
189
+ for f in forms(stem):
190
+ for tid, pref in self.lookup.get(f, []):
191
+ self._add(c, tid, 0.72 if not pref else 0.68,
192
+ f"column name '{colname}' matches table name"
193
+ + (f" (prefix '{pref}' stripped)" if pref else ""))
194
+ for i in range(1, len(words)):
195
+ rest = "_".join(words[i:])
196
+ allrole = all(w in self.o.role_words for w in words[:i])
197
+ for f in forms(rest):
198
+ for tid, pref in self.lookup.get(f, []):
199
+ self._add(c, tid, 0.60 if allrole else 0.50,
200
+ f"role prefix '{'_'.join(words[:i])}' stripped, '{rest}' matches table name")
201
+ for tid in self.pkname.get(colname.lower(), []):
202
+ if tid != t.id:
203
+ self._add(c, tid, 0.70, f"column name '{colname}' equals the primary key name of {tid}")
204
+ if not c:
205
+ tails = {tid for f in forms(stem) for tid in self.suffix_lookup.get(f, []) if tid.startswith(t.schema + ".")}
206
+ if len(tails) == 1: # several tail matches is noise (e.g. every '*_users' table), so none is kept
207
+ tid = next(iter(tails))
208
+ self._add(c, tid, 0.35, f"'{stem}' matches the tail of table name {tid} (weak)")
209
+ c.pop(t.id, None) if not any("self-reference" in r for r in c.get(t.id, {}).get("reasons", [])) else None
210
+ return c
211
+
212
+ # --- scoring --------------------------------------------------------------
213
+ def score(self, t: Table, colname: str, stem: str, tid: str, cand: dict):
214
+ tt = self.s.tables[tid]
215
+ tk = target_key(tt, stem, colname)
216
+ if not tk:
217
+ return None
218
+ tcols, tdesc, tboost = tk
219
+ fc, tc = t.column(colname), tt.column(tcols[0])
220
+ ev = list(cand["reasons"])
221
+ conf = cand["base"]
222
+ ff, tf = type_family(fc.type if fc else ""), type_family(tc.type if tc else "")
223
+ if ff == "unknown" or tf == "unknown":
224
+ pass
225
+ elif ff == tf:
226
+ conf += 0.10
227
+ ev.append(f"compatible types ({ff} ~ {tf})")
228
+ if ff == "string" and type_length(fc.type) and type_length(fc.type) == type_length(tc.type):
229
+ conf += 0.03
230
+ elif {ff, tf} <= {"int", "decimal", "float", "bool"}:
231
+ conf -= 0.15
232
+ ev.append(f"loosely compatible types ({ff} vs {tf})")
233
+ else:
234
+ conf -= 0.35
235
+ ev.append(f"type mismatch ({fc.type} vs {tc.type})")
236
+ ix = _is_indexed_leading(t, [colname])
237
+ if ix:
238
+ conf += 0.07
239
+ ev.append(f"source column is the leading column of index '{ix}'")
240
+ conf += tboost
241
+ ev.append(f"target is the {tdesc} of {tid}: {','.join(tcols)}")
242
+ if t.schema == tt.schema:
243
+ conf += 0.05
244
+ else:
245
+ conf -= 0.10
246
+ ev.append("cross-schema match")
247
+ return {"to": tid, "to_columns": tcols, "confidence": conf, "evidence": ev,
248
+ "partial": tboost < 0}
249
+
250
+ def run(self) -> list[dict]:
251
+ edges: list[dict] = []
252
+ poly_cols: dict[str, list[dict]] = {}
253
+ unresolved: dict[str, list[str]] = {}
254
+ for t in self.s.tables.values():
255
+ declared_from = {c for fk in t.foreign_keys for c in fk.columns}
256
+ colnames = {c.name.lower() for c in t.columns}
257
+ for col in t.columns:
258
+ if col.name in declared_from:
259
+ continue
260
+ stem = extract_stem(col.name)
261
+ if stem is None:
262
+ continue
263
+ base = snake(col.name)[:-3] if snake(col.name).endswith("_id") else stem
264
+ pol = [base + "_" + s for s in POLY_SUFFIXES if (base + "_" + s) in colnames]
265
+ if pol:
266
+ poly_cols.setdefault(t.id, []).append(
267
+ {"id_column": col.name, "type_column": next(c.name for c in t.columns if c.name.lower() == pol[0]),
268
+ "note": "polymorphic: the type column decides the target table; no single target"})
269
+ continue
270
+ scored = []
271
+ for tid, cand in self.candidates(t, col.name, stem).items():
272
+ r = self.score(t, col.name, stem, tid, cand)
273
+ if r:
274
+ scored.append(r)
275
+ if scored and any(r["to"].split(".")[0] == t.schema for r in scored):
276
+ scored = [r for r in scored if r["to"].split(".")[0] == t.schema] or scored
277
+ scored.sort(key=lambda r: (-r["confidence"], r["to"]))
278
+ scored = scored[: self.o.top_k]
279
+ amb = len(scored) >= 2 and scored[0]["confidence"] - scored[1]["confidence"] < 0.12
280
+ kept = 0
281
+ for r in scored:
282
+ if amb:
283
+ r["confidence"] -= 0.10
284
+ r["evidence"].append(f"ambiguous: {len(scored)} candidate targets within a close score")
285
+ r["confidence"] = round(max(0.05, min(0.95, r["confidence"])), 2)
286
+ if r["confidence"] < self.o.min_confidence:
287
+ continue
288
+ kept += 1
289
+ edges.append({
290
+ "kind": "inferred", "status": "inferred", "from": t.id, "from_columns": [col.name],
291
+ "to": r["to"], "to_columns": r["to_columns"], "confidence": r["confidence"],
292
+ "cardinality": "many-to-many" if r["partial"] else cardinality(t, [col.name]),
293
+ "ambiguous": amb, "evidence": r["evidence"],
294
+ })
295
+ if not kept:
296
+ unresolved.setdefault(t.id, []).append(col.name)
297
+ self.polymorphic, self.unresolved = poly_cols, unresolved
298
+ return edges
299
+
300
+
301
+ def declared_edges(schema: Schema, warnings: list[str]) -> list[dict]:
302
+ out = []
303
+ for t in schema.tables.values():
304
+ for fk in t.foreign_keys:
305
+ tid = f"{fk.ref_schema or t.schema}.{fk.ref_table}"
306
+ if tid not in schema.tables:
307
+ warnings.append(f"declared FK {t.id}({','.join(fk.columns)}) references unknown table {tid}")
308
+ continue
309
+ out.append({
310
+ "kind": "declared", "status": "declared", "from": t.id, "from_columns": list(fk.columns),
311
+ "to": tid, "to_columns": list(fk.ref_columns), "confidence": 1.0,
312
+ "cardinality": cardinality(t, list(fk.columns)), "ambiguous": False,
313
+ "evidence": [f"declared foreign key {fk.name or ''}".strip()], "constraint": fk.name or "",
314
+ })
315
+ return out
316
+
317
+
318
+ def redundant_indexes(t: Table) -> list[dict]:
319
+ """Indexes that add nothing: a strict prefix of a longer index, or a duplicate of an equal one."""
320
+ out = []
321
+ ixs = [ix for ix in t.indexes if not ix.approximate]
322
+
323
+ def rank(ix):
324
+ return (0 if ix.primary else 1 if ix.unique else 2, ix.name)
325
+
326
+ for a in ixs:
327
+ if a.primary:
328
+ continue
329
+ la = len(a.columns)
330
+ for b in ixs:
331
+ if a is b or a.columns != b.columns[:la]:
332
+ continue
333
+ if len(b.columns) == la:
334
+ if rank(b) >= rank(a):
335
+ continue
336
+ reason = "duplicate of another index on the same columns"
337
+ else:
338
+ if a.unique:
339
+ continue
340
+ reason = "prefix of a longer index"
341
+ out.append({"index": a.name, "columns": a.columns, "covered_by": b.name,
342
+ "covered_by_columns": b.columns, "reason": reason})
343
+ break
344
+ return out
keygraph/mcp_server.py ADDED
@@ -0,0 +1,147 @@
1
+ """MCP server: keygraph's read-only queries as tools for any MCP client.
2
+
3
+ Each tool returns the same JSON as the matching `keygraph <command> --json`, except `areas`, which wraps the
4
+ CLI's list as {"areas": [...]} (MCP tools return objects). Nothing here runs SQL or
5
+ touches a database: it reads graph.json and keygraph.db from a built output directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp.server.mcpserver.exceptions import ResourceError, ToolError
14
+
15
+ from . import query as Q
16
+ from . import search as S
17
+ from .cli import area_result, describe_result, load_graph, path_result
18
+ from .context import ContextDB
19
+ from .docs_out import table_file
20
+ from .graph import Graph
21
+
22
+ INSTRUCTIONS = """keygraph maps the keys of a database and the relations between its tables. Everything is read-only
23
+ and deterministic: it answers from a prebuilt graph and never queries the database.
24
+ Start with `search` (find tables by business meaning) or `areas`, then `describe` a table, and use `path` before writing
25
+ any multi-table JOIN. Relations are either declared foreign keys (confidence 1.0) or inferred from names and key shapes;
26
+ inferred relations carry a confidence and the evidence behind it, so check anything below about 0.8 (and every fan-out
27
+ hop, where one row becomes many) with a small SELECT. Value profiles are sampled, may be stale, and never contain raw
28
+ values for PII columns. Table names resolve leniently, but when a bare name exists in several schemas, give schema.table."""
29
+
30
+
31
+ MAX_DEPTH = 3 # neighbors beyond 3 hops returns most of a large graph
32
+
33
+
34
+ def build_server(dir: str | Path) -> MCPServer:
35
+ """Create the server for a built output directory (does not start it)."""
36
+ out = Path(dir)
37
+ load_graph(out) # fail early with a clear message when nothing was built
38
+ cache: dict = {}
39
+
40
+ def graph() -> Graph:
41
+ """The graph, reloaded only when graph.json changed (the directory may be rebuilt while we serve)."""
42
+ stamp = (out / "graph.json").stat().st_mtime_ns
43
+ if cache.get("stamp") != stamp:
44
+ cache["stamp"], cache["g"] = stamp, load_graph(out)
45
+ return cache["g"]
46
+
47
+ def run(fn, *a, **kw):
48
+ try:
49
+ return fn(*a, **kw)
50
+ except (ValueError, KeyError) as exc: # unknown/ambiguous table, bad reference: tell the agent how to fix it
51
+ raise ToolError(str(exc.args[0] if isinstance(exc, KeyError) and exc.args else exc)) from None
52
+
53
+ srv = MCPServer("keygraph", instructions=INSTRUCTIONS)
54
+
55
+ @srv.tool()
56
+ def search(query: str, limit: int = 10) -> dict:
57
+ """Find tables by business meaning ("monthly revenue", "in transit", a status label). Use this first when you
58
+ do not know which table holds something, then describe the top hit. Returns ranked tables with the reason each matched (names, business
59
+ concepts, synonyms, metrics, notes, documented code labels), grain, primary key and pitfalls when known.
60
+ Matches on profiled values never expose PII."""
61
+ g = graph()
62
+ with ContextDB(out / "keygraph.db", g, readonly=True) as ctx:
63
+ return run(S.search, g, ctx, query, limit)
64
+
65
+ @srv.tool()
66
+ def describe(table: str) -> dict:
67
+ """Everything known about one table: keys and indexes, columns, business context (grain, pitfalls, code
68
+ meanings) and its relations grouped by role (parent, child, lookup, audit...) with confidence. Use it
69
+ before querying a table you have not seen. Give schema.table when the bare name exists in several schemas."""
70
+ return run(describe_result, out, graph(), table, True)
71
+
72
+ @srv.tool()
73
+ def keys(table: str) -> dict:
74
+ """Primary key, unique keys and every index of a table, in column order. Use it before filtering a big
75
+ table: filter on the leading column of an index so the query stays fast."""
76
+ return run(Q.keys, graph(), table)
77
+
78
+ @srv.tool()
79
+ def explain(column_ref: str) -> dict:
80
+ """What one column (schema.table.column) references and what references it, with the evidence for each
81
+ relation. Inferred relations carry a confidence and evidence lines; declared foreign keys are 1.0."""
82
+ return run(Q.explain, graph(), column_ref)
83
+
84
+ @srv.tool()
85
+ def path(from_table: str, to_table: str, max_hops: int = 4, min_confidence: float = 0.6, k: int = 3,
86
+ include_mirrors: bool = False) -> dict:
87
+ """Ranked join paths between two tables, each hop with its columns, confidence, evidence, fan-out warnings
88
+ and a ready JOIN ... ON snippet. Use it before writing any join across more than one table. Inferred hops
89
+ are guesses (check evidence, verify with a small SELECT when confidence is below 0.8 or a hop fans out).
90
+ Paths avoid hub tables as stepping stones and mirror schemas (copies of another schema) unless
91
+ include_mirrors is true."""
92
+ return run(path_result, graph(), from_table, to_table, max_hops, min_confidence, k, include_mirrors)
93
+
94
+ @srv.tool()
95
+ def neighbors(table: str, depth: int = 1) -> dict:
96
+ """Tables directly (depth 1) or transitively (depth 2 or 3; larger values are capped at 3) related to a table,
97
+ with the relation kind and confidence. It has no confidence filter: weak inferred relations are included, so
98
+ read each confidence. Use it to see what a table connects to when you do not have a target table in mind."""
99
+ return run(Q.neighbors, graph(), table, max(1, min(int(depth), MAX_DEPTH)), 0.0)
100
+
101
+ @srv.tool()
102
+ def areas(include_mirrors: bool = False) -> dict:
103
+ """The business areas: clusters of related tables (for example Ordering, Catalog, Payments), with size and
104
+ most connected tables. Use it to get an overview of an unfamiliar database. Mirror schemas are left out
105
+ unless include_mirrors is true. Returns {"areas": [...]}, the list the CLI prints with --json."""
106
+ return {"areas": run(Q.list_areas, graph(), include_mirrors=include_mirrors)}
107
+
108
+ @srv.tool()
109
+ def area(name_or_table: str, include_mirrors: bool = False) -> dict:
110
+ """One area in detail: its tables with grain, the relations inside it, neighbouring areas and hub labels.
111
+ Give an area id, an area name (any case) or a table that belongs to it."""
112
+ return run(area_result, out, graph(), name_or_table, include_mirrors, True)
113
+
114
+ @srv.tool()
115
+ def profile_summary(table: str) -> dict:
116
+ """Stored value profiles for the columns of a table: null rate, distinct count, ranges, top values, recency and
117
+ pattern hints, each with when and how it was sampled. Profiles are sampled and may be stale. PII columns never
118
+ include raw values, only counts, length and pattern. This reads stored results only and never queries the
119
+ database; columns is empty when the table was not profiled."""
120
+ g = graph()
121
+ t = run(g.resolve, table)
122
+ with ContextDB(out / "keygraph.db", g, readonly=True) as ctx:
123
+ prefix = t + "."
124
+ cols = {k[len(prefix):]: v for k, v in ctx.load_profiles().items() if k.startswith(prefix)}
125
+ return {"table": t, "columns": cols}
126
+
127
+ def _read(rel: str) -> str:
128
+ p = (out / rel).resolve()
129
+ if out.resolve() not in p.parents or not p.is_file():
130
+ raise ResourceError(f"no page {rel}")
131
+ return p.read_text(encoding="utf-8")
132
+
133
+ @srv.resource("keygraph://areas/index", mime_type="text/markdown")
134
+ def areas_index() -> str:
135
+ """Markdown index of all business areas."""
136
+ return _read("areas/index.md")
137
+
138
+ @srv.resource("keygraph://tables/{id}", mime_type="text/markdown")
139
+ def table_page(id: str) -> str:
140
+ """Markdown page for one table (schema.table): area, grain, keys and indexes, columns and relations."""
141
+ try:
142
+ t = graph().resolve(id)
143
+ except ValueError as exc:
144
+ raise ResourceError(str(exc)) from None
145
+ return _read("tables/" + table_file(t))
146
+
147
+ return srv
keygraph/mirrors.py ADDED
@@ -0,0 +1,109 @@
1
+ """Mirror schemas: a schema whose tables duplicate another schema's tables (a BI or reporting copy, a replica
2
+ loaded under another name). Their tables would double every area, hub count and join path, so keygraph marks
3
+ them and keeps them out of areas, hubs and path search unless asked for."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from keygraph.graph import Graph
8
+
9
+ MIRROR_SHARE = 0.8
10
+
11
+
12
+ def _copies(a: frozenset, b: frozenset | None) -> bool:
13
+ """Two tables of the same name are copies when their column-name sets match or one contains the other
14
+ (a reporting copy often adds a load timestamp or drops a wide column)."""
15
+ return b is not None and (a == b or a <= b or b <= a)
16
+
17
+
18
+ def _structure(graph: Graph, schema: str, tables: set[str]) -> int:
19
+ """How much a schema's copy of `tables` looks like a source database: secondary indexes, plus relations among
20
+ those tables (declared count three times; role columns such as created_by_id do not count). A reporting copy
21
+ usually keeps the columns but drops the keys."""
22
+ from keygraph.hubs import is_role_column
23
+ ids = {f"{schema}.{t}" for t in tables}
24
+ ix = sum(1 for t in ids for i in graph.nodes[t]["indexes"] if not i["primary"])
25
+ rel = sum(3 if e["kind"] == "declared" else 1 for e in graph.edges
26
+ if e["from"] in ids and e["to"] in ids and e["from"] != e["to"] and not any(is_role_column(c) for c in e["from_columns"]))
27
+ return ix + rel
28
+
29
+
30
+ def _original(graph: Graph, by: dict, a: str, b: str, common: set[str]) -> tuple[str, str, bool]:
31
+ """Which of two schemas sharing `common` tables is the source: (original, reason, close call?).
32
+ (a) the one with clearly more keys and relations on the shared tables, (b) then the one with fewer tables
33
+ the other lacks (a copy tends to add load or log tables), (c) then the larger, (d) then the name."""
34
+ sa, sb = _structure(graph, a, common), _structure(graph, b, common)
35
+ if max(sa, sb) >= 2 and max(sa, sb) >= 1.25 * min(sa, sb) + 1:
36
+ return (a if sa > sb else b), f"keys and relations ({max(sa, sb)} vs {min(sa, sb)})", False
37
+ ea, eb = len(by[a]) - len(common), len(by[b]) - len(common)
38
+ if ea != eb:
39
+ return (a if ea < eb else b), f"fewer extra tables ({min(ea, eb)} vs {max(ea, eb)}); keys and relations look alike", True
40
+ if len(by[a]) != len(by[b]):
41
+ return (a if len(by[a]) > len(by[b]) else b), "size; keys, relations and extra tables look alike", True
42
+ return min(a, b), "name only; the two schemas look alike", True
43
+
44
+
45
+ def detect_mirrors(graph: Graph, share: float = MIRROR_SHARE, min_tables: int = 2,
46
+ force: dict[str, str] | None = None, never: set[str] | frozenset = frozenset(),
47
+ warnings: list[str] | None = None) -> dict[str, str]:
48
+ """{mirror schema: original schema}. Two schemas are a mirror pair when at least `share` of one schema's tables
49
+ are copies (same name, see _copies) of the other's. Which side is the source is guessed by _original; a schema
50
+ is marked a mirror only when it is the copy and at least `share` of its own tables are copies. A close guess, a
51
+ `force` ({mirror: original}, from a context file) that disagrees with the guess, or a forced pair naming an
52
+ unknown schema adds a line to `warnings`. `never` lists schemas that are never mirrors."""
53
+ warn = warnings.append if warnings is not None else (lambda _m: None)
54
+ by: dict[str, dict[str, frozenset]] = {}
55
+ for n in graph.nodes.values():
56
+ by.setdefault(n["schema"], {})[n["table"]] = frozenset(c["name"] for c in n["columns"])
57
+
58
+ def common(x: str, y: str) -> set[str]:
59
+ return {t for t, cols in by[x].items() if _copies(cols, by[y].get(t))}
60
+
61
+ guess: dict[str, str] = {}
62
+ why: dict[str, str] = {}
63
+ seen: set[frozenset] = set()
64
+ for s in sorted(by, key=lambda x: (-len(by[x]), x)):
65
+ if len(by[s]) < min_tables:
66
+ continue
67
+ cands = [(len(common(s, o)), o) for o in by if o != s]
68
+ hits, o = max(cands, default=(0, None))
69
+ if o is None or hits / len(by[s]) < share or frozenset((s, o)) in seen:
70
+ continue
71
+ seen.add(frozenset((s, o)))
72
+ shared = common(s, o)
73
+ orig, reason, close = _original(graph, by, s, o, shared)
74
+ copy = o if orig == s else s
75
+ if len(common(copy, orig)) / len(by[copy]) < share:
76
+ warn(f"mirror guess: {orig} and {copy} share {len(shared)} tables and {orig} looks like the source "
77
+ f"({reason}), but {copy} is not mostly copies, so neither is marked a mirror; confirm with mirrors.yaml")
78
+ continue
79
+ guess[copy], why[copy] = orig, reason
80
+ if close:
81
+ warn(f"mirror guess: {copy} copies {orig} (decided by {reason}) — confirm with mirrors.yaml")
82
+ out = {m: o for m, o in guess.items() if m not in never}
83
+ for m, o in (force or {}).items():
84
+ if m not in by or o not in by or m == o:
85
+ warn(f"mirrors.yaml: '{m}: {o}' ignored: unknown schema" if (m not in by or o not in by) else f"mirrors.yaml: '{m}: {o}' ignored")
86
+ continue
87
+ if guess.get(o) == m or (m in guess and guess[m] != o):
88
+ g = f"{o} copies {m}" if guess.get(o) == m else f"{m} copies {guess[m]}"
89
+ warn(f"mirror guess: mirrors.yaml says {m} copies {o}, which disagrees with the guess ({g}, by {why.get(o) or why.get(m)}); using mirrors.yaml")
90
+ out.pop(o, None) if out.get(o) == m else None
91
+ out[m] = o
92
+ for m in list(out): # an original is never itself a mirror: follow chains to the root
93
+ root, hops = out[m], 0
94
+ while root in out and hops < len(out):
95
+ root, hops = out[root], hops + 1
96
+ out[m] = root
97
+ return dict(sorted((m, o) for m, o in out.items() if m != o))
98
+
99
+
100
+ def mark_mirrors(graph: Graph, mirrors: dict[str, str]) -> None:
101
+ for n in graph.nodes.values():
102
+ n.pop("mirror_of", None)
103
+ if n["schema"] in mirrors:
104
+ n["mirror_of"] = mirrors[n["schema"]]
105
+ graph.data["mirrors"] = dict(sorted(mirrors.items()))
106
+
107
+
108
+ def mirror_tables(graph: Graph) -> set[str]:
109
+ return {t for t, n in graph.nodes.items() if n.get("mirror_of")}