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.
- keygraph/__init__.py +8 -0
- keygraph/__main__.py +3 -0
- keygraph/adapters/__init__.py +33 -0
- keygraph/adapters/base.py +57 -0
- keygraph/adapters/clickhouse.py +67 -0
- keygraph/adapters/dump.py +169 -0
- keygraph/adapters/fetch.py +93 -0
- keygraph/adapters/live.py +50 -0
- keygraph/adapters/metabase.py +78 -0
- keygraph/adapters/mongo.py +93 -0
- keygraph/adapters/mysql.py +30 -0
- keygraph/adapters/postgres.py +68 -0
- keygraph/adapters/sqlite.py +24 -0
- keygraph/adapters/sqlserver.py +65 -0
- keygraph/areas.py +225 -0
- keygraph/cli.py +497 -0
- keygraph/context.py +387 -0
- keygraph/docs_out.py +199 -0
- keygraph/explorer.py +128 -0
- keygraph/graph.py +163 -0
- keygraph/hubs.py +48 -0
- keygraph/infer.py +344 -0
- keygraph/mcp_server.py +147 -0
- keygraph/mirrors.py +109 -0
- keygraph/model.py +149 -0
- keygraph/notes.py +114 -0
- keygraph/overrides.py +112 -0
- keygraph/probe.py +119 -0
- keygraph/profile.py +262 -0
- keygraph/query.py +376 -0
- keygraph/report.py +92 -0
- keygraph/search.py +175 -0
- keygraph/static/DAGRE_LICENSE +19 -0
- keygraph/static/OFL-Inter.txt +93 -0
- keygraph/static/OFL-JetBrainsMono.txt +93 -0
- keygraph/static/dagre-1.1.4.min.js +801 -0
- keygraph/static/explorer.html +69 -0
- keygraph/static/explorer.js +1076 -0
- keygraph/static/favicon.svg +1 -0
- keygraph/static/keygraph-logo.svg +1 -0
- keygraph/static/keygraph-mark.svg +1 -0
- keygraph/static/keygraph.inline.css +843 -0
- keygraph/static/kg-diagram.js +267 -0
- keygraph/static/kg-theme.js +269 -0
- keygraph/summary.py +60 -0
- keygraph-0.2.0.dist-info/METADATA +334 -0
- keygraph-0.2.0.dist-info/RECORD +50 -0
- keygraph-0.2.0.dist-info/WHEEL +4 -0
- keygraph-0.2.0.dist-info/entry_points.txt +2 -0
- 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")}
|