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/explorer.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Build the self-contained keygraph.html explorer.
|
|
2
|
+
|
|
3
|
+
The page is one file: the keygraph design system CSS (fonts embedded), dagre, the theme helpers, the diagram
|
|
4
|
+
renderer and the app script are inlined from static/ (copied there from design/ by `design/build.py --export-static`),
|
|
5
|
+
and the graph plus context is embedded as compact JSON (gzip + base64 when large). Nothing is fetched at runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import gzip
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .context import safe_source
|
|
17
|
+
from .graph import Graph
|
|
18
|
+
from .query import _is_log, table_info_line
|
|
19
|
+
|
|
20
|
+
STATIC = Path(__file__).parent / "static"
|
|
21
|
+
STATUS = {"declared": 0, "inferred": 1, "confirmed": 2, "added": 3, "probed": 4}
|
|
22
|
+
CARD = {"many-to-one": 0, "one-to-one": 1, "many-to-many": 2, "one-to-many": 3}
|
|
23
|
+
NOTE_KEYS = ("extra", "generated", "charset", "collation", "comment")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _col(c: dict) -> list:
|
|
27
|
+
"""[name, type, nullable, default|None, enum/set members|None, other details]."""
|
|
28
|
+
note = "; ".join(f"{k}: {c[k]}" for k in NOTE_KEYS if c.get(k))
|
|
29
|
+
return [c["name"], c["type"], int(c.get("nullable", True)), c.get("default"), c.get("values") or None, note]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compact(graph: Graph, ctx_dump: dict | None = None) -> dict:
|
|
33
|
+
schemas = sorted({n["schema"] for n in graph.nodes.values()})
|
|
34
|
+
sidx = {s: i for i, s in enumerate(schemas)}
|
|
35
|
+
ids = sorted(graph.nodes)
|
|
36
|
+
tidx = {t: i for i, t in enumerate(ids)}
|
|
37
|
+
hubs = set(graph.data.get("hubs") or [])
|
|
38
|
+
tables = []
|
|
39
|
+
for t in ids:
|
|
40
|
+
n = graph.nodes[t]
|
|
41
|
+
ci = {c["name"]: i for i, c in enumerate(n["columns"])}
|
|
42
|
+
info = n.get("info") or {}
|
|
43
|
+
tables.append([
|
|
44
|
+
n["table"], sidx[n["schema"]], [_col(c) for c in n["columns"]],
|
|
45
|
+
[ci[c] for c in n["primary_key"] if c in ci],
|
|
46
|
+
[[i["name"], int(i["unique"]), int(i["primary"]), [ci[c] for c in i["columns"] if c in ci]] for i in n["indexes"]],
|
|
47
|
+
[[ci.get(p["id_column"], -1), ci.get(p["type_column"], -1)] for p in n["polymorphic"]],
|
|
48
|
+
table_info_line(n),
|
|
49
|
+
int(_is_log(graph, t)) | (2 if t in hubs else 0) | (4 if n.get("mirror_of") else 0),
|
|
50
|
+
info.get("rows_estimate"),
|
|
51
|
+
])
|
|
52
|
+
edges = []
|
|
53
|
+
for e in graph.edges:
|
|
54
|
+
fi, ti = tidx[e["from"]], tidx[e["to"]]
|
|
55
|
+
fci = {c["name"]: i for i, c in enumerate(graph.nodes[e["from"]]["columns"])}
|
|
56
|
+
tci = {c["name"]: i for i, c in enumerate(graph.nodes[e["to"]]["columns"])}
|
|
57
|
+
edges.append([fi, ti, [fci[c] for c in e["from_columns"] if c in fci], [tci[c] for c in e["to_columns"] if c in tci],
|
|
58
|
+
e["confidence"], STATUS.get(e["status"], 1), CARD.get(e["cardinality"], 0),
|
|
59
|
+
int(e["ambiguous"]), e["evidence"], (1 if e.get("hub") else 0) | (2 if e.get("role") else 0)])
|
|
60
|
+
# area flags: 1 unconnected (no relations left after hubs), 2 mirror schema
|
|
61
|
+
areas = [[a["id"], a["name"], [tidx[t] for t in a["tables"] if t in tidx], int(bool(a.get("auto", True))),
|
|
62
|
+
(1 if a.get("unconnected") else 0) | (2 if a.get("mirror") else 0)]
|
|
63
|
+
for a in graph.data.get("areas") or []]
|
|
64
|
+
aidx = {a[0]: i for i, a in enumerate(areas)}
|
|
65
|
+
links = [[aidx[l["a"]], aidx[l["b"]], l["edges"]] for l in graph.data.get("area_links") or []
|
|
66
|
+
if l["a"] in aidx and l["b"] in aidx]
|
|
67
|
+
c = {"concepts": [], "rels": [], "cols": [], "metrics": [], "tn": []}
|
|
68
|
+
if ctx_dump:
|
|
69
|
+
for x in ctx_dump["concepts"]:
|
|
70
|
+
c["concepts"].append({"n": x["name"], "d": x["definition"], "dm": x["domain"], "sy": x["synonyms"], "v": int(x["verified"]),
|
|
71
|
+
"l": [[tidx[l["table"]], l["column"], l["role"], l["note"]] for l in x["links"] if l["table"] in tidx]})
|
|
72
|
+
c["rels"] = [[r["from"], r["rel"], r["to"], r["note"], int(r["verified"])] for r in ctx_dump["relations"]]
|
|
73
|
+
c["cols"] = [{"t": tidx[x["table"]], "c": x["column"], "m": x["meaning"], "u": x["unit"], "k": x["codes"], "nm": x["null_meaning"],
|
|
74
|
+
"pii": int(x["pii"]), "f": x["typical_filters"], "ih": x["index_hint"], "v": int(x["verified"]), "s": safe_source(x["source"])}
|
|
75
|
+
for x in ctx_dump["columns"] if x["table"] in tidx]
|
|
76
|
+
c["metrics"] = [{"n": x["name"], "d": x["definition"], "e": x["expression"], "g": x["grain"], "t": [tidx[t] for t in x["tables"] if t in tidx],
|
|
77
|
+
"f": x["filters"], "cv": x["caveats"], "v": int(x["verified"])} for x in ctx_dump["metrics"]]
|
|
78
|
+
c["tn"] = [{"t": tidx[x["table"]], "g": x["grain"], "fr": x["freshness"], "sd": x["soft_delete"], "dc": x["date_column"],
|
|
79
|
+
"ri": x["recommended_indexes"], "p": x["pitfalls"], "sm": x["summary"], "v": int(x["verified"]), "s": safe_source(x["source"])}
|
|
80
|
+
for x in ctx_dump["tables"] if x["table"] in tidx]
|
|
81
|
+
prof = {}
|
|
82
|
+
for key, p in (graph.data.get("profiles") or {}).items():
|
|
83
|
+
tid, col = key.rsplit(".", 1)
|
|
84
|
+
if tid in tidx:
|
|
85
|
+
names = [c["name"] for c in graph.nodes[tid]["columns"]]
|
|
86
|
+
if col in names:
|
|
87
|
+
if p.get("pii"): # PII: never embed raw values, whatever an older profile stored
|
|
88
|
+
p = {k: v for k, v in p.items() if k not in ("top_values", "numeric", "dates", "length")}
|
|
89
|
+
prof[f"{tidx[tid]}:{names.index(col)}"] = p
|
|
90
|
+
c["p"] = prof
|
|
91
|
+
return {"v": 2, "s": schemas, "t": tables, "e": edges, "a": areas, "al": links, "mi": graph.data.get("mirrors") or {},
|
|
92
|
+
"c": c, "st": graph.data["stats"]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _svg(name: str) -> str:
|
|
96
|
+
s = (STATIC / name).read_text(encoding="utf-8")
|
|
97
|
+
s = re.sub(r"<title>.*?</title>", "", s, flags=re.S)
|
|
98
|
+
return s.replace("#17181A", "var(--kg-text)").replace("#E2A317", "var(--kg-accent)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _js(name: str) -> str:
|
|
102
|
+
return (STATIC / name).read_text(encoding="utf-8").replace("</script", "<\\/script")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_html(graph: Graph, ctx_dump: dict | None = None, gzip_over: int = 300_000) -> str:
|
|
106
|
+
payload = json.dumps(compact(graph, ctx_dump), ensure_ascii=False, separators=(",", ":"))
|
|
107
|
+
if len(payload) > gzip_over:
|
|
108
|
+
data = "GZ:" + base64.b64encode(gzip.compress(payload.encode("utf-8"), 9)).decode("ascii")
|
|
109
|
+
else:
|
|
110
|
+
data = payload.replace("<", "\\u003c") # JSON-safe; no "</script" or "<!--" can end or alter the block
|
|
111
|
+
lic = "\n\n".join((STATIC / f).read_text(encoding="utf-8") for f in ("DAGRE_LICENSE", "OFL-Inter.txt", "OFL-JetBrainsMono.txt"))
|
|
112
|
+
fav = (STATIC / "favicon.svg").read_text(encoding="utf-8").replace("#", "%23").replace('"', "'").replace("\n", "")
|
|
113
|
+
logo = _svg("keygraph-logo.svg").replace("<svg ", '<svg style="fill:var(--kg-text)" ', 1)
|
|
114
|
+
mark = _svg("keygraph-mark.svg").replace("<svg ", '<svg width="24" height="24" ', 1)
|
|
115
|
+
parts = {
|
|
116
|
+
"LICENSES": lic.replace("--", "- -"),
|
|
117
|
+
"FAVICON": fav,
|
|
118
|
+
"CSS": (STATIC / "keygraph.inline.css").read_text(encoding="utf-8").replace("</style", "<\\/style"),
|
|
119
|
+
"LOGOS": json.dumps({"logo": logo, "mark": mark}).replace("</", "<\\/"),
|
|
120
|
+
"DAGRE": _js("dagre-1.1.4.min.js"),
|
|
121
|
+
"THEME": _js("kg-theme.js"),
|
|
122
|
+
"DIAGRAM": _js("kg-diagram.js"),
|
|
123
|
+
"APP": _js("explorer.js"),
|
|
124
|
+
"DATA": data,
|
|
125
|
+
}
|
|
126
|
+
tpl = (STATIC / "explorer.html").read_text(encoding="utf-8")
|
|
127
|
+
# one pass over the template only, so text inside the data or the scripts is never treated as a marker
|
|
128
|
+
return re.sub(r"/\*__([A-Z]+)__\*/", lambda m: parts[m.group(1)], tpl)
|
keygraph/graph.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Graph assembly, persistence and table-name resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .infer import Inferer, Options, declared_edges, redundant_indexes
|
|
10
|
+
from .model import Schema, type_family
|
|
11
|
+
from .overrides import Overrides, apply_overrides
|
|
12
|
+
|
|
13
|
+
VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AmbiguousTable(ValueError):
|
|
17
|
+
def __init__(self, name: str, candidates: list[str]):
|
|
18
|
+
super().__init__(f"'{name}' is ambiguous; use schema.table: " + ", ".join(candidates))
|
|
19
|
+
self.candidates = candidates
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnknownTable(ValueError):
|
|
23
|
+
def __init__(self, name: str, suggestions: list[str]):
|
|
24
|
+
msg = f"unknown table '{name}'" + (f"; did you mean: {', '.join(suggestions)}" if suggestions else "")
|
|
25
|
+
super().__init__(msg)
|
|
26
|
+
self.suggestions = suggestions
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def edge_id(e: dict) -> str:
|
|
30
|
+
return f"{e['from']}({','.join(e['from_columns'])})->{e['to']}({','.join(e['to_columns'])})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_graph(schema: Schema, overrides: Overrides | None = None, options: Options | None = None) -> "Graph":
|
|
34
|
+
opts = options or Options()
|
|
35
|
+
if overrides:
|
|
36
|
+
overrides.configure(opts)
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
inf = Inferer(schema, opts)
|
|
39
|
+
declared = declared_edges(schema, warnings)
|
|
40
|
+
inferred = inf.run()
|
|
41
|
+
taken = {(e["from"], tuple(e["from_columns"]), e["to"]) for e in declared}
|
|
42
|
+
edges = declared + [e for e in inferred if (e["from"], tuple(e["from_columns"]), e["to"]) not in taken]
|
|
43
|
+
nodes = []
|
|
44
|
+
for t in sorted(schema.tables.values(), key=lambda x: x.id):
|
|
45
|
+
nodes.append({
|
|
46
|
+
"id": t.id, "schema": t.schema, "table": t.name,
|
|
47
|
+
"info": t.info,
|
|
48
|
+
"columns": [{"name": c.name, "type": c.type, "family": type_family(c.type), "nullable": c.nullable,
|
|
49
|
+
**({"key": c.key} if c.key else {}),
|
|
50
|
+
**{k: v for k, v in (("default", c.default), ("extra", c.extra), ("charset", c.charset),
|
|
51
|
+
("collation", c.collation), ("comment", c.comment), ("values", c.values),
|
|
52
|
+
("generated", c.generated)) if v not in (None, "", [])}}
|
|
53
|
+
for c in sorted(t.columns, key=lambda c: (c.position, c.name))],
|
|
54
|
+
"primary_key": t.primary_key,
|
|
55
|
+
"unique_keys": [{"name": ix.name, "columns": ix.columns, **({"approximate": True} if ix.approximate else {})}
|
|
56
|
+
for ix in t.indexes if ix.unique and not ix.primary],
|
|
57
|
+
"indexes": [{"name": ix.name, "columns": ix.columns, "unique": ix.unique, "primary": ix.primary,
|
|
58
|
+
**({"approximate": True} if ix.approximate else {})}
|
|
59
|
+
for ix in sorted(t.indexes, key=lambda i: (not i.primary, i.name))],
|
|
60
|
+
"polymorphic": inf.polymorphic.get(t.id, []),
|
|
61
|
+
"unresolved_id_columns": sorted(inf.unresolved.get(t.id, [])),
|
|
62
|
+
"redundant_indexes": redundant_indexes(t),
|
|
63
|
+
})
|
|
64
|
+
data = {"version": VERSION, "nodes": nodes, "edges": edges, "warnings": warnings, "overrides": {}}
|
|
65
|
+
if overrides:
|
|
66
|
+
apply_overrides(data, overrides)
|
|
67
|
+
for e in data["edges"]:
|
|
68
|
+
e["id"] = edge_id(e)
|
|
69
|
+
data["edges"].sort(key=lambda e: e["id"])
|
|
70
|
+
data["warnings"] = sorted(set(data["warnings"]))
|
|
71
|
+
g = Graph(data)
|
|
72
|
+
g.refresh_stats()
|
|
73
|
+
return g
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Graph:
|
|
77
|
+
def __init__(self, data: dict):
|
|
78
|
+
self.data = data
|
|
79
|
+
self.nodes: dict[str, dict] = {n["id"]: n for n in data["nodes"]}
|
|
80
|
+
self.edges: list[dict] = data["edges"]
|
|
81
|
+
self._by_name: dict[str, list[str]] = {}
|
|
82
|
+
for nid, n in self.nodes.items():
|
|
83
|
+
self._by_name.setdefault(n["table"].lower(), []).append(nid)
|
|
84
|
+
self._adj: dict[str, list[dict]] | None = None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def load(cls, path: str | Path) -> "Graph":
|
|
88
|
+
return cls(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
89
|
+
|
|
90
|
+
def save(self, path: str | Path) -> None:
|
|
91
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
Path(path).write_text(json.dumps(self.data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
def refresh_stats(self) -> None:
|
|
95
|
+
by_kind: dict[str, int] = {}
|
|
96
|
+
bands = {"1.0": 0, "0.8-0.99": 0, "0.6-0.79": 0, "0.4-0.59": 0, "<0.4": 0}
|
|
97
|
+
for e in self.edges:
|
|
98
|
+
by_kind[e["kind"]] = by_kind.get(e["kind"], 0) + 1
|
|
99
|
+
c = e["confidence"]
|
|
100
|
+
bands["1.0" if c >= 1 else "0.8-0.99" if c >= .8 else "0.6-0.79" if c >= .6 else "0.4-0.59" if c >= .4 else "<0.4"] += 1
|
|
101
|
+
self.data["stats"] = {
|
|
102
|
+
"tables": len(self.nodes), "columns": sum(len(n["columns"]) for n in self.nodes.values()),
|
|
103
|
+
"schemas": len({n["schema"] for n in self.nodes.values()}),
|
|
104
|
+
"indexes": sum(len(n["indexes"]) for n in self.nodes.values()),
|
|
105
|
+
"edges": len(self.edges), "edges_by_kind": by_kind, "edges_by_confidence": bands,
|
|
106
|
+
"polymorphic_columns": sum(len(n["polymorphic"]) for n in self.nodes.values()),
|
|
107
|
+
}
|
|
108
|
+
self._adj = None
|
|
109
|
+
|
|
110
|
+
# --- name resolution -------------------------------------------------------
|
|
111
|
+
def resolve(self, name: str) -> str:
|
|
112
|
+
if name in self.nodes:
|
|
113
|
+
return name
|
|
114
|
+
low = name.lower()
|
|
115
|
+
for nid in self.nodes:
|
|
116
|
+
if nid.lower() == low:
|
|
117
|
+
return nid
|
|
118
|
+
if "." not in name:
|
|
119
|
+
hits = self._by_name.get(low, [])
|
|
120
|
+
if len(hits) == 1:
|
|
121
|
+
return hits[0]
|
|
122
|
+
if len(hits) > 1:
|
|
123
|
+
raise AmbiguousTable(name, sorted(hits))
|
|
124
|
+
sug = difflib.get_close_matches(name, list(self.nodes), n=4, cutoff=0.6)
|
|
125
|
+
sug += [h for k in difflib.get_close_matches(low, list(self._by_name), n=3, cutoff=0.7) for h in self._by_name[k]]
|
|
126
|
+
raise UnknownTable(name, sorted(set(sug)))
|
|
127
|
+
|
|
128
|
+
def split_column(self, ref: str) -> tuple[str, str | None]:
|
|
129
|
+
"""Parse 'table', 'table.col' or 'schema.table.col' into (table_id, column or None)."""
|
|
130
|
+
try:
|
|
131
|
+
return self.resolve(ref), None
|
|
132
|
+
except (UnknownTable, AmbiguousTable) as first:
|
|
133
|
+
if "." not in ref:
|
|
134
|
+
raise
|
|
135
|
+
head, col = ref.rsplit(".", 1)
|
|
136
|
+
tid = self.resolve(head)
|
|
137
|
+
if col not in {c["name"] for c in self.nodes[tid]["columns"]}:
|
|
138
|
+
raise ValueError(f"table {tid} has no column '{col}'") from first
|
|
139
|
+
return tid, col
|
|
140
|
+
|
|
141
|
+
# --- adjacency -------------------------------------------------------------
|
|
142
|
+
def adjacency(self) -> dict[str, list[dict]]:
|
|
143
|
+
if self._adj is None:
|
|
144
|
+
adj: dict[str, list[dict]] = {n: [] for n in self.nodes}
|
|
145
|
+
for e in self.edges:
|
|
146
|
+
adj.setdefault(e["from"], []).append(oriented(e, False))
|
|
147
|
+
if e["to"] != e["from"]:
|
|
148
|
+
adj.setdefault(e["to"], []).append(oriented(e, True))
|
|
149
|
+
self._adj = adj
|
|
150
|
+
return self._adj
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def flip_card(c: str) -> str:
|
|
154
|
+
return {"many-to-one": "one-to-many", "one-to-many": "many-to-one"}.get(c, c)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def oriented(e: dict, reverse: bool) -> dict:
|
|
158
|
+
"""View an edge from one end: near/far table and columns as walked."""
|
|
159
|
+
if not reverse:
|
|
160
|
+
return {"edge": e, "near": e["from"], "near_cols": e["from_columns"], "far": e["to"],
|
|
161
|
+
"far_cols": e["to_columns"], "cardinality": e["cardinality"], "reverse": False}
|
|
162
|
+
return {"edge": e, "near": e["to"], "near_cols": e["to_columns"], "far": e["from"],
|
|
163
|
+
"far_cols": e["from_columns"], "cardinality": flip_card(e["cardinality"]), "reverse": True}
|
keygraph/hubs.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Hub tables: tables so many others point to that drawing their edges would swamp every view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from keygraph.graph import Graph
|
|
8
|
+
|
|
9
|
+
ROLE_PATTERNS = [r"(^|_)(created|updated|deleted|modified|approved|rejected|changed|edited)_by(_id)?$",
|
|
10
|
+
r"^(owner|author|creator|editor|operator|admin)_id$", r"_by_id$", r"_by$"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_role_column(name: str, patterns=ROLE_PATTERNS) -> bool:
|
|
14
|
+
low = name.lower()
|
|
15
|
+
return any(re.search(p, low) for p in patterns)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _edge_is_role(e: dict, patterns) -> bool:
|
|
19
|
+
return any(is_role_column(c, patterns) for c in e["from_columns"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect_hubs(graph: Graph, percentile: float = 99, role_share: float = 0.6, include=(), exclude=(),
|
|
23
|
+
patterns=ROLE_PATTERNS) -> set[str]:
|
|
24
|
+
incoming: dict[str, int] = {}
|
|
25
|
+
role_in: dict[str, int] = {}
|
|
26
|
+
mirrors = {t for t, n in graph.nodes.items() if n.get("mirror_of")} # a copy's edges would double the counts
|
|
27
|
+
for e in graph.edges:
|
|
28
|
+
if e["from"] == e["to"] or e["from"] in mirrors or e["to"] in mirrors:
|
|
29
|
+
continue
|
|
30
|
+
incoming[e["to"]] = incoming.get(e["to"], 0) + 1
|
|
31
|
+
if _edge_is_role(e, patterns):
|
|
32
|
+
role_in[e["to"]] = role_in.get(e["to"], 0) + 1
|
|
33
|
+
counts = sorted(incoming.values())
|
|
34
|
+
hubs: set[str] = set()
|
|
35
|
+
if counts:
|
|
36
|
+
cut = counts[min(len(counts) - 1, int(len(counts) * percentile / 100))]
|
|
37
|
+
hubs |= {t for t, n in incoming.items() if n >= max(cut, 5)}
|
|
38
|
+
hubs |= {t for t, n in incoming.items() if n >= 5 and role_in.get(t, 0) / n >= role_share}
|
|
39
|
+
hubs |= {graph.resolve(t) for t in include}
|
|
40
|
+
hubs -= {graph.resolve(t) for t in exclude}
|
|
41
|
+
return hubs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mark_hubs(graph: Graph, hubs: set[str], patterns=ROLE_PATTERNS) -> None:
|
|
45
|
+
for e in graph.edges:
|
|
46
|
+
e["role"] = _edge_is_role(e, patterns)
|
|
47
|
+
e["hub"] = e["to"] in hubs and e["from"] != e["to"]
|
|
48
|
+
graph.data["hubs"] = sorted(hubs)
|