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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""MySQL / MariaDB through information_schema. Works with any run(sql) callable: a DB-API or SQLAlchemy
|
|
2
|
+
wrapper, or an external read-only command (see fetch.command_runner). Read-only: SELECTs on information_schema
|
|
3
|
+
plus bounded sample SELECTs, all through assert_select."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ..model import Schema
|
|
8
|
+
from .base import Source, guarded
|
|
9
|
+
from .dump import load_dump
|
|
10
|
+
from .fetch import fetch_mysql_all
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MySQLSource(Source):
|
|
14
|
+
kind, dialect, quote = "mysql", "mysql", "`"
|
|
15
|
+
|
|
16
|
+
def __init__(self, run, schema_expr: str = "DATABASE()", schema_name: str | None = None, limit: int = 10000):
|
|
17
|
+
self.run, self.schema_expr, self.schema_name, self.limit = guarded(run), schema_expr, schema_name, limit
|
|
18
|
+
|
|
19
|
+
def schema(self) -> Schema:
|
|
20
|
+
d = fetch_mysql_all(self.run, self.schema_expr, self.limit)
|
|
21
|
+
if self.schema_name:
|
|
22
|
+
for rows in d.values():
|
|
23
|
+
for r in rows:
|
|
24
|
+
r["TABLE_SCHEMA"] = self.schema_name
|
|
25
|
+
sch = load_dump(d["columns"], d["statistics"] or None, d["key_columns"] or None, tables=d["tables"])
|
|
26
|
+
for r in d["checks"]:
|
|
27
|
+
t = sch.tables.get(f"{r.get('TABLE_SCHEMA')}.{r.get('TABLE_NAME')}")
|
|
28
|
+
if t:
|
|
29
|
+
t.info.setdefault("checks", []).append({"name": r.get("CONSTRAINT_NAME"), "expr": r.get("CHECK_CLAUSE")})
|
|
30
|
+
return sch
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""PostgreSQL through information_schema and pg_catalog via a run(sql) callable. Read-only (single SELECTs)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Column, ForeignKey, Index, Schema
|
|
6
|
+
from .base import Source, dicts, guarded
|
|
7
|
+
|
|
8
|
+
SYS = "('pg_catalog','information_schema','pg_toast')"
|
|
9
|
+
COLS = ("SELECT c.table_schema, c.table_name, c.column_name, c.ordinal_position, CASE WHEN c.data_type='USER-DEFINED' THEN c.udt_name "
|
|
10
|
+
"WHEN c.data_type='character varying' THEN 'varchar('||COALESCE(c.character_maximum_length::text,'')||')' ELSE c.data_type END, "
|
|
11
|
+
"c.is_nullable, c.column_default, c.is_generated, c.generation_expression, "
|
|
12
|
+
"pg_catalog.col_description((quote_ident(c.table_schema)||'.'||quote_ident(c.table_name))::regclass::oid, c.ordinal_position) "
|
|
13
|
+
"FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema=c.table_schema AND t.table_name=c.table_name "
|
|
14
|
+
f"WHERE t.table_type='BASE TABLE' AND c.table_schema NOT IN {SYS} ORDER BY 1,2,4 LIMIT 200000")
|
|
15
|
+
COLN = ["schema", "table", "column", "pos", "type", "nullable", "default", "generated", "genexpr", "comment"]
|
|
16
|
+
IDX = ("SELECT n.nspname, c.relname, i.relname, ix.indisunique, ix.indisprimary, array_to_string(ARRAY(SELECT a.attname FROM "
|
|
17
|
+
"unnest(ix.indkey) WITH ORDINALITY k(attnum,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=k.attnum ORDER BY k.ord),',') "
|
|
18
|
+
"FROM pg_index ix JOIN pg_class c ON c.oid=ix.indrelid JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_namespace n ON n.oid=c.relnamespace "
|
|
19
|
+
f"WHERE n.nspname NOT IN {SYS} LIMIT 200000")
|
|
20
|
+
IDXN = ["schema", "table", "index", "unique", "primary", "cols"]
|
|
21
|
+
FK = ("SELECT n.nspname, c.relname, con.conname, array_to_string(ARRAY(SELECT a.attname FROM unnest(con.conkey) WITH ORDINALITY k(attnum,ord) "
|
|
22
|
+
"JOIN pg_attribute a ON a.attrelid=con.conrelid AND a.attnum=k.attnum ORDER BY k.ord),','), rn.nspname, rc.relname, "
|
|
23
|
+
"array_to_string(ARRAY(SELECT a.attname FROM unnest(con.confkey) WITH ORDINALITY k(attnum,ord) JOIN pg_attribute a ON a.attrelid=con.confrelid "
|
|
24
|
+
"AND a.attnum=k.attnum ORDER BY k.ord),',') FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace "
|
|
25
|
+
f"JOIN pg_class rc ON rc.oid=con.confrelid JOIN pg_namespace rn ON rn.oid=rc.relnamespace WHERE con.contype='f' AND n.nspname NOT IN {SYS} LIMIT 100000")
|
|
26
|
+
FKN = ["schema", "table", "name", "cols", "ref_schema", "ref_table", "ref_cols"]
|
|
27
|
+
ENUMS = "SELECT t.typname, string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_enum e ON e.enumtypid=t.oid GROUP BY t.typname LIMIT 10000"
|
|
28
|
+
ROWS = ("SELECT n.nspname, c.relname, c.reltuples::bigint, pg_total_relation_size(c.oid), obj_description(c.oid) FROM pg_class c "
|
|
29
|
+
f"JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.relkind IN ('r','p') AND n.nspname NOT IN {SYS} LIMIT 200000")
|
|
30
|
+
CHECKS = ("SELECT n.nspname, c.relname, con.conname, pg_get_constraintdef(con.oid) FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid "
|
|
31
|
+
f"JOIN pg_namespace n ON n.oid=c.relnamespace WHERE con.contype='c' AND n.nspname NOT IN {SYS} LIMIT 100000")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PostgresSource(Source):
|
|
35
|
+
kind, dialect, quote = "postgres", "postgres", '"'
|
|
36
|
+
|
|
37
|
+
def __init__(self, run):
|
|
38
|
+
self.run = guarded(run)
|
|
39
|
+
|
|
40
|
+
def schema(self) -> Schema:
|
|
41
|
+
sch = Schema()
|
|
42
|
+
for r in dicts(self.run(COLS), COLN):
|
|
43
|
+
t = sch.get_or_create(r["schema"], r["table"])
|
|
44
|
+
gen = r["genexpr"] if str(r["generated"]).upper() == "ALWAYS" else ""
|
|
45
|
+
t.columns.append(Column(r["column"], str(r["type"]), str(r["nullable"]).upper() != "NO", int(r["pos"]),
|
|
46
|
+
default=None if r["default"] is None else str(r["default"]), comment=r["comment"] or "",
|
|
47
|
+
generated=gen or ""))
|
|
48
|
+
enums = {n: v.split(",") for n, v in (tuple(x.values()) if isinstance(x, dict) else x for x in self.run(ENUMS))}
|
|
49
|
+
for t in sch.tables.values():
|
|
50
|
+
for c in t.columns:
|
|
51
|
+
c.values = enums.get(c.type, [])
|
|
52
|
+
for r in dicts(self.run(IDX), IDXN):
|
|
53
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
54
|
+
if t:
|
|
55
|
+
t.indexes.append(Index(r["index"], r["cols"].split(","), bool(r["unique"]), bool(r["primary"])))
|
|
56
|
+
for r in dicts(self.run(FK), FKN):
|
|
57
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
58
|
+
if t:
|
|
59
|
+
t.foreign_keys.append(ForeignKey(r["name"], r["cols"].split(","), r["ref_schema"], r["ref_table"], r["ref_cols"].split(",")))
|
|
60
|
+
for r in dicts(self.run(ROWS), ["schema", "table", "rows", "bytes", "comment"]):
|
|
61
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
62
|
+
if t:
|
|
63
|
+
t.info.update({k: v for k, v in (("rows_estimate", r["rows"]), ("data_bytes", r["bytes"]), ("comment", r["comment"])) if v not in (None, "")})
|
|
64
|
+
for r in dicts(self.run(CHECKS), ["schema", "table", "name", "expr"]):
|
|
65
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
66
|
+
if t:
|
|
67
|
+
t.info.setdefault("checks", []).append({"name": r["name"], "expr": r["expr"]})
|
|
68
|
+
return sch
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""SQLite (and any other SQLAlchemy dialect) through the inspector. Metadata only; samples are bounded SELECTs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..probe import SelectOnlyRunner
|
|
6
|
+
from .base import Source
|
|
7
|
+
from .live import scan_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SQLAlchemySource(Source):
|
|
11
|
+
kind = "sqlalchemy"
|
|
12
|
+
|
|
13
|
+
def __init__(self, url: str, schemas=None, all_schemas: bool = False):
|
|
14
|
+
self.url, self.schemas, self.all_schemas = url, schemas, all_schemas
|
|
15
|
+
self.dialect = "mysql" if url.startswith("mysql") else "postgres" if url.startswith("postgres") else "sqlite"
|
|
16
|
+
self.quote = "`" if self.dialect == "mysql" else '"'
|
|
17
|
+
self._runner = SelectOnlyRunner.from_url(url)
|
|
18
|
+
self.run = self._runner.run
|
|
19
|
+
|
|
20
|
+
def schema(self):
|
|
21
|
+
return scan_url(self.url, self.schemas, self.all_schemas)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SQLiteSource = SQLAlchemySource
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SQL Server through INFORMATION_SCHEMA and sys.* via a run(sql) callable. Read-only (single SELECTs).
|
|
2
|
+
Samples use SELECT TOP n."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ..model import Column, ForeignKey, Index, Schema
|
|
7
|
+
from .base import Source, dicts, guarded
|
|
8
|
+
|
|
9
|
+
COLS = ("SELECT TOP 200000 c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, c.ORDINAL_POSITION, c.DATA_TYPE + CASE WHEN c.CHARACTER_MAXIMUM_LENGTH IS NOT NULL "
|
|
10
|
+
"THEN '(' + CAST(c.CHARACTER_MAXIMUM_LENGTH AS varchar(10)) + ')' ELSE '' END, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.COLLATION_NAME, c.CHARACTER_SET_NAME "
|
|
11
|
+
"FROM INFORMATION_SCHEMA.COLUMNS c JOIN INFORMATION_SCHEMA.TABLES t ON t.TABLE_SCHEMA=c.TABLE_SCHEMA AND t.TABLE_NAME=c.TABLE_NAME "
|
|
12
|
+
"WHERE t.TABLE_TYPE='BASE TABLE' ORDER BY 1,2,4")
|
|
13
|
+
COLN = ["schema", "table", "column", "pos", "type", "nullable", "default", "collation", "charset"]
|
|
14
|
+
IDX = ("SELECT TOP 200000 s.name, t.name, i.name, i.is_unique, i.is_primary_key, c.name, ic.key_ordinal FROM sys.indexes i "
|
|
15
|
+
"JOIN sys.tables t ON t.object_id=i.object_id JOIN sys.schemas s ON s.schema_id=t.schema_id "
|
|
16
|
+
"JOIN sys.index_columns ic ON ic.object_id=i.object_id AND ic.index_id=i.index_id AND ic.is_included_column=0 "
|
|
17
|
+
"JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id WHERE i.name IS NOT NULL ORDER BY 1,2,3,7")
|
|
18
|
+
IDXN = ["schema", "table", "index", "unique", "primary", "col", "ord"]
|
|
19
|
+
FK = ("SELECT TOP 100000 s.name, t.name, fk.name, c.name, rs.name, rt.name, rc.name, fkc.constraint_column_id FROM sys.foreign_key_columns fkc "
|
|
20
|
+
"JOIN sys.foreign_keys fk ON fk.object_id=fkc.constraint_object_id JOIN sys.tables t ON t.object_id=fkc.parent_object_id "
|
|
21
|
+
"JOIN sys.schemas s ON s.schema_id=t.schema_id JOIN sys.columns c ON c.object_id=fkc.parent_object_id AND c.column_id=fkc.parent_column_id "
|
|
22
|
+
"JOIN sys.tables rt ON rt.object_id=fkc.referenced_object_id JOIN sys.schemas rs ON rs.schema_id=rt.schema_id "
|
|
23
|
+
"JOIN sys.columns rc ON rc.object_id=fkc.referenced_object_id AND rc.column_id=fkc.referenced_column_id ORDER BY 1,2,3,8")
|
|
24
|
+
FKN = ["schema", "table", "name", "col", "ref_schema", "ref_table", "ref_col", "ord"]
|
|
25
|
+
ROWS = ("SELECT TOP 200000 s.name, t.name, SUM(p.rows) FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id "
|
|
26
|
+
"JOIN sys.partitions p ON p.object_id=t.object_id AND p.index_id IN (0,1) GROUP BY s.name, t.name")
|
|
27
|
+
CHECKS = "SELECT TOP 100000 s.name, t.name, k.name, k.definition FROM sys.check_constraints k JOIN sys.tables t ON t.object_id=k.parent_object_id JOIN sys.schemas s ON s.schema_id=t.schema_id"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SQLServerSource(Source):
|
|
31
|
+
kind, dialect, quote = "sqlserver", "sqlserver", '"'
|
|
32
|
+
|
|
33
|
+
def __init__(self, run):
|
|
34
|
+
self.run = guarded(run)
|
|
35
|
+
|
|
36
|
+
def schema(self) -> Schema:
|
|
37
|
+
sch = Schema()
|
|
38
|
+
for r in dicts(self.run(COLS), COLN):
|
|
39
|
+
t = sch.get_or_create(r["schema"], r["table"])
|
|
40
|
+
t.columns.append(Column(r["column"], str(r["type"]), str(r["nullable"]).upper() != "NO", int(r["pos"]),
|
|
41
|
+
default=None if r["default"] is None else str(r["default"]),
|
|
42
|
+
charset=r["charset"] or "", collation=r["collation"] or ""))
|
|
43
|
+
groups: dict = {}
|
|
44
|
+
for r in dicts(self.run(IDX), IDXN):
|
|
45
|
+
groups.setdefault((r["schema"], r["table"], r["index"]), {"u": bool(r["unique"]), "p": bool(r["primary"]), "c": []})["c"].append(r["col"])
|
|
46
|
+
for (s, tn, name), g in groups.items():
|
|
47
|
+
if f"{s}.{tn}" in sch.tables:
|
|
48
|
+
sch.tables[f"{s}.{tn}"].indexes.append(Index(name, g["c"], g["u"], g["p"]))
|
|
49
|
+
fks: dict = {}
|
|
50
|
+
for r in dicts(self.run(FK), FKN):
|
|
51
|
+
g = fks.setdefault((r["schema"], r["table"], r["name"], r["ref_schema"], r["ref_table"]), ([], []))
|
|
52
|
+
g[0].append(r["col"])
|
|
53
|
+
g[1].append(r["ref_col"])
|
|
54
|
+
for (s, tn, name, rs, rt), (cols, rcols) in fks.items():
|
|
55
|
+
if f"{s}.{tn}" in sch.tables:
|
|
56
|
+
sch.tables[f"{s}.{tn}"].foreign_keys.append(ForeignKey(name, cols, rs, rt, rcols))
|
|
57
|
+
for r in dicts(self.run(ROWS), ["schema", "table", "rows"]):
|
|
58
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
59
|
+
if t and r["rows"] is not None:
|
|
60
|
+
t.info["rows_estimate"] = int(r["rows"])
|
|
61
|
+
for r in dicts(self.run(CHECKS), ["schema", "table", "name", "expr"]):
|
|
62
|
+
t = sch.tables.get(f"{r['schema']}.{r['table']}")
|
|
63
|
+
if t:
|
|
64
|
+
t.info.setdefault("checks", []).append({"name": r["name"], "expr": r["expr"]})
|
|
65
|
+
return sch
|
keygraph/areas.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Business areas: deterministic clusters of tables, ignoring hub and role edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import random
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
from keygraph.graph import Graph
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _weights(graph: Graph, skip: set[str] = frozenset()) -> dict[str, dict[str, float]]:
|
|
13
|
+
w: dict[str, dict[str, float]] = {t: {} for t in graph.nodes if t not in skip}
|
|
14
|
+
for e in graph.edges:
|
|
15
|
+
if e.get("hub") or e.get("role") or e["from"] == e["to"] or e["from"] in skip or e["to"] in skip:
|
|
16
|
+
continue
|
|
17
|
+
a, b, c = e["from"], e["to"], float(e["confidence"])
|
|
18
|
+
w[a][b] = w[a].get(b, 0) + c
|
|
19
|
+
w[b][a] = w[b].get(a, 0) + c
|
|
20
|
+
return w
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _propagate(w, seed: int, rounds: int) -> dict[str, str]:
|
|
24
|
+
rng = random.Random(seed)
|
|
25
|
+
label = {n: n for n in w}
|
|
26
|
+
order = sorted(w)
|
|
27
|
+
for _ in range(rounds):
|
|
28
|
+
rng.shuffle(order)
|
|
29
|
+
changed = 0
|
|
30
|
+
for n in order:
|
|
31
|
+
score: dict[str, float] = {}
|
|
32
|
+
for m, x in w[n].items():
|
|
33
|
+
score[label[m]] = score.get(label[m], 0) + x
|
|
34
|
+
if not score:
|
|
35
|
+
continue
|
|
36
|
+
best = max(sorted(score), key=lambda l: score[l])
|
|
37
|
+
if best != label[n]:
|
|
38
|
+
label[n], changed = best, changed + 1
|
|
39
|
+
if not changed:
|
|
40
|
+
break
|
|
41
|
+
return label
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _stem(word: str) -> str:
|
|
45
|
+
"""Drop a plural 's', but keep words such as status, address and analysis whole."""
|
|
46
|
+
return word if word.endswith(("ss", "us", "is")) else word.removesuffix("s")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PREFIX_SHARE = 0.6
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _name(tables: list[str], graph: Graph) -> str:
|
|
53
|
+
"""The shared table-name prefix when it covers at least PREFIX_SHARE of the area ("Order" for orders,
|
|
54
|
+
order_items, order_notes), else the most connected table that is not a hub (tables arrive ordered by
|
|
55
|
+
connections). A minority prefix or a hub's name would mislead."""
|
|
56
|
+
names = [graph.nodes[t]["table"] for t in tables]
|
|
57
|
+
heads = Counter(_stem(n.split("_")[0]) for n in names)
|
|
58
|
+
head, n = heads.most_common(1)[0]
|
|
59
|
+
if n >= 2 and n / len(names) >= PREFIX_SHARE:
|
|
60
|
+
return head.capitalize()
|
|
61
|
+
hubs = set(graph.data.get("hubs") or [])
|
|
62
|
+
return next((graph.nodes[t]["table"] for t in tables if t not in hubs), names[0])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _id(top: list[str]) -> str:
|
|
66
|
+
return "a_" + hashlib.sha1("|".join(sorted(top)).encode()).hexdigest()[:8]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cluster_areas(graph: Graph, min_size: int = 3, seed: int = 7, rounds: int = 30) -> list[dict]:
|
|
70
|
+
"""Areas over the non-mirror tables, plus one area per mirror schema (flagged `mirror`), and one
|
|
71
|
+
"Unconnected (<schema>)" area per schema for tables with no remaining edges (flagged `unconnected`)."""
|
|
72
|
+
mirrors = {t for t, n in graph.nodes.items() if n.get("mirror_of")}
|
|
73
|
+
w = _weights(graph, mirrors)
|
|
74
|
+
degree = {t: len(v) for t, v in w.items()}
|
|
75
|
+
label = _propagate(w, seed, rounds)
|
|
76
|
+
groups: dict[str, set[str]] = {}
|
|
77
|
+
for t, l in label.items():
|
|
78
|
+
groups.setdefault(l, set()).add(t)
|
|
79
|
+
merged = True
|
|
80
|
+
while merged: # repeat passes: a target that grew may now be small enough to merge again
|
|
81
|
+
merged = False
|
|
82
|
+
for l in sorted(groups, key=lambda l: (len(groups[l]), l)): # merge small groups into their heaviest neighbour
|
|
83
|
+
g = groups.get(l)
|
|
84
|
+
if g is None or len(g) >= min_size:
|
|
85
|
+
continue
|
|
86
|
+
pull: dict[str, float] = {}
|
|
87
|
+
for t in sorted(g):
|
|
88
|
+
for m, x in sorted(w[t].items()):
|
|
89
|
+
if label[m] != l:
|
|
90
|
+
pull[label[m]] = pull.get(label[m], 0) + x
|
|
91
|
+
if pull:
|
|
92
|
+
target = max(sorted(pull), key=lambda k: pull[k])
|
|
93
|
+
groups[target] |= g
|
|
94
|
+
for t in sorted(g):
|
|
95
|
+
label[t] = target
|
|
96
|
+
del groups[l]
|
|
97
|
+
merged = True
|
|
98
|
+
areas, loose = [], {}
|
|
99
|
+
for g in groups.values():
|
|
100
|
+
if len(g) < min_size and all(not w[t] for t in g):
|
|
101
|
+
for t in g:
|
|
102
|
+
loose.setdefault(graph.nodes[t]["schema"], []).append(t)
|
|
103
|
+
continue
|
|
104
|
+
tables = sorted(g, key=lambda t: (-degree[t], t))
|
|
105
|
+
areas.append({"id": _id(tables[:3]), "name": _name(tables, graph), "tables": tables, "top": tables[:3],
|
|
106
|
+
"size": len(tables), "auto": True})
|
|
107
|
+
for schema, ts in sorted(loose.items()):
|
|
108
|
+
ts = sorted(ts)
|
|
109
|
+
areas.append({"id": _id([f"unconnected:{schema}"]), "name": f"Unconnected ({schema})", "tables": ts,
|
|
110
|
+
"top": ts[:3], "size": len(ts), "auto": True, "unconnected": True})
|
|
111
|
+
seen: Counter = Counter(a["name"] for a in areas)
|
|
112
|
+
for a in areas:
|
|
113
|
+
if seen[a["name"]] > 1 and not a.get("unconnected"):
|
|
114
|
+
top = graph.nodes[a["top"][0]]
|
|
115
|
+
a["name"] = (f"{a['name']} ({top['schema']})" if a["name"] == top["table"]
|
|
116
|
+
else f"{a['name']} ({top['schema']}: {top['table']})")
|
|
117
|
+
by_schema: dict[str, list[str]] = {}
|
|
118
|
+
for t in mirrors:
|
|
119
|
+
by_schema.setdefault(graph.nodes[t]["schema"], []).append(t)
|
|
120
|
+
deg: Counter = Counter()
|
|
121
|
+
for e in graph.edges: # one pass, not one per table
|
|
122
|
+
deg[e["from"]] += 1
|
|
123
|
+
if e["to"] != e["from"]:
|
|
124
|
+
deg[e["to"]] += 1
|
|
125
|
+
for schema, ts in sorted(by_schema.items()):
|
|
126
|
+
ts = sorted(ts, key=lambda t: (-deg[t], t))
|
|
127
|
+
of = graph.nodes[ts[0]]["mirror_of"]
|
|
128
|
+
areas.append({"id": _id([f"mirror:{schema}"]), "name": f"Mirror of {of} ({schema})", "tables": ts, "top": ts[:3],
|
|
129
|
+
"size": len(ts), "auto": True, "mirror": schema, "mirror_of": of})
|
|
130
|
+
return sorted(areas, key=lambda a: (-a["size"], a["name"]))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def area_of(areas: list[dict]) -> dict[str, str]:
|
|
134
|
+
return {t: a["id"] for a in areas for t in a["tables"]}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def area_links(graph: Graph, areas: list[dict]) -> list[dict]:
|
|
138
|
+
of = area_of(areas)
|
|
139
|
+
c: Counter = Counter()
|
|
140
|
+
mirrors = {t for t, n in graph.nodes.items() if n.get("mirror_of")}
|
|
141
|
+
for e in graph.edges:
|
|
142
|
+
if e.get("hub") or e.get("role") or e["from"] in mirrors or e["to"] in mirrors:
|
|
143
|
+
continue
|
|
144
|
+
a, b = of.get(e["from"]), of.get(e["to"])
|
|
145
|
+
if a and b and a != b:
|
|
146
|
+
c[tuple(sorted((a, b)))] += 1
|
|
147
|
+
return [{"a": a, "b": b, "edges": n} for (a, b), n in c.most_common()]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def apply_curation(graph: Graph, areas: list[dict], rules: list[dict]) -> tuple[list[dict], list[str]]:
|
|
151
|
+
areas = [dict(a, tables=list(a["tables"])) for a in areas]
|
|
152
|
+
warns: list[str] = []
|
|
153
|
+
|
|
154
|
+
def find(key: str):
|
|
155
|
+
key = str(key)
|
|
156
|
+
return next((a for a in areas if a["id"] == key or a["name"].lower() == key.lower()
|
|
157
|
+
or key in a["tables"]), None)
|
|
158
|
+
|
|
159
|
+
def move(t: str, dest: dict):
|
|
160
|
+
for a in areas:
|
|
161
|
+
if t in a["tables"]:
|
|
162
|
+
a["tables"].remove(t)
|
|
163
|
+
dest["tables"].append(t)
|
|
164
|
+
|
|
165
|
+
for r in rules:
|
|
166
|
+
if not isinstance(r, dict) or not (r.get("name") or r.get("match") or r.get("merge")):
|
|
167
|
+
warns.append(f"area rule {r!r} ignored: it needs a name, a match or a merge")
|
|
168
|
+
continue
|
|
169
|
+
if r.get("merge"):
|
|
170
|
+
hits = [find(k) for k in r["merge"]]
|
|
171
|
+
if any(h is None for h in hits):
|
|
172
|
+
warns.append(f"merge {r['merge']}: an area was not found")
|
|
173
|
+
continue
|
|
174
|
+
dest = hits[0]
|
|
175
|
+
for h in hits[1:]:
|
|
176
|
+
if h is not dest:
|
|
177
|
+
dest["tables"] += h["tables"]
|
|
178
|
+
h["tables"] = []
|
|
179
|
+
dest["auto"] = False
|
|
180
|
+
continue
|
|
181
|
+
dest = next((find(k) for k in r.get("match", []) if find(k)), None)
|
|
182
|
+
if dest is None:
|
|
183
|
+
if r.get("match"):
|
|
184
|
+
warns.append(f"area rule '{r.get('name')}': no area matches {r['match']}")
|
|
185
|
+
continue
|
|
186
|
+
if not r.get("name"):
|
|
187
|
+
warns.append(f"area rule {r!r} ignored: it needs a name, a match or a merge")
|
|
188
|
+
continue
|
|
189
|
+
dest = {"id": _id([f"curated:{r['name']}"]), "name": r["name"], "tables": [], "auto": False}
|
|
190
|
+
areas.append(dest)
|
|
191
|
+
dest["name"], dest["auto"] = r.get("name", dest["name"]), False
|
|
192
|
+
for t in r.get("pin", []):
|
|
193
|
+
try:
|
|
194
|
+
move(graph.resolve(t), dest)
|
|
195
|
+
except ValueError: # UnknownTable and AmbiguousTable both subclass ValueError
|
|
196
|
+
warns.append(f"area '{dest['name']}': unknown table {t}")
|
|
197
|
+
for t in r.get("exclude", []):
|
|
198
|
+
try:
|
|
199
|
+
tid = graph.resolve(t)
|
|
200
|
+
except ValueError:
|
|
201
|
+
warns.append(f"area '{dest['name']}': unknown table {t}")
|
|
202
|
+
continue
|
|
203
|
+
if tid in dest["tables"]:
|
|
204
|
+
dest["tables"].remove(tid)
|
|
205
|
+
schema = graph.nodes[tid]["schema"]
|
|
206
|
+
loose = find(f"Unconnected ({schema})") or {"id": _id([f"unconnected:{schema}"]),
|
|
207
|
+
"name": f"Unconnected ({schema})", "tables": [], "auto": True,
|
|
208
|
+
"unconnected": True}
|
|
209
|
+
if loose not in areas:
|
|
210
|
+
areas.append(loose)
|
|
211
|
+
loose["tables"].append(tid)
|
|
212
|
+
deg: Counter = Counter()
|
|
213
|
+
for e in graph.edges: # one pass over the edges
|
|
214
|
+
if not e.get("hub"):
|
|
215
|
+
deg[e["from"]] += 1
|
|
216
|
+
if e["to"] != e["from"]:
|
|
217
|
+
deg[e["to"]] += 1
|
|
218
|
+
out = []
|
|
219
|
+
for a in areas:
|
|
220
|
+
if not a["tables"]:
|
|
221
|
+
continue
|
|
222
|
+
a["tables"] = sorted(set(a["tables"]), key=lambda t: (-deg[t], t))
|
|
223
|
+
a["top"], a["size"] = a["tables"][:3], len(a["tables"])
|
|
224
|
+
out.append(a)
|
|
225
|
+
return sorted(out, key=lambda a: (-a["size"], a["name"])), warns
|