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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """keygraph: map keys and relations between database tables."""
2
+
3
+ from .graph import Graph, build_graph
4
+ from .model import Column, ForeignKey, Index, Schema, Table
5
+ from .infer import Options
6
+
7
+ __all__ = ["Graph", "build_graph", "Column", "ForeignKey", "Index", "Schema", "Table", "Options"]
8
+ __version__ = "0.2.0"
keygraph/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,33 @@
1
+ """Pluggable sources: mysql, postgres, sqlite, sqlserver, clickhouse, metabase, mongodb, plus the dump and command adapters.
2
+
3
+ from keygraph.adapters import open_source
4
+ src = open_source("clickhouse", run=my_select_runner)
5
+ schema = src.schema()
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def open_source(kind: str, url: str | None = None, run=None, **kw):
12
+ kind = kind.lower()
13
+ if kind in ("sqlite", "sqlalchemy"):
14
+ from .sqlite import SQLAlchemySource
15
+ return SQLAlchemySource(url, **kw)
16
+ if run is None and url:
17
+ from ..probe import SelectOnlyRunner
18
+ r = SelectOnlyRunner.from_url(url)
19
+ run = r.run
20
+ table = {"mysql": ("mysql", "MySQLSource"), "mariadb": ("mysql", "MySQLSource"), "postgres": ("postgres", "PostgresSource"),
21
+ "postgresql": ("postgres", "PostgresSource"), "sqlserver": ("sqlserver", "SQLServerSource"),
22
+ "mssql": ("sqlserver", "SQLServerSource"), "clickhouse": ("clickhouse", "ClickHouseSource")}
23
+ if kind in table:
24
+ import importlib
25
+ mod, cls = table[kind]
26
+ return getattr(importlib.import_module(f"keygraph.adapters.{mod}"), cls)(run, **kw)
27
+ if kind == "metabase":
28
+ from .metabase import MetabaseSource
29
+ return MetabaseSource(**kw)
30
+ if kind in ("mongo", "mongodb"):
31
+ from .mongo import MongoSource
32
+ return MongoSource(**kw)
33
+ raise ValueError(f"unknown source kind '{kind}'")
@@ -0,0 +1,57 @@
1
+ """Pluggable source interface.
2
+
3
+ A source turns one kind of system into a normalised Schema (metadata) and can sample rows for profiling.
4
+ Every source is read-only by construction: SQL goes through `guard` (a single SELECT, no write keywords),
5
+ HTTP sources only GET metadata and POST native SELECTs, Mongo sources only call find/list operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable
11
+
12
+ from ..model import Schema
13
+ from ..probe import assert_select
14
+
15
+
16
+ def guarded(run: Callable[[str], list]) -> Callable[[str], list]:
17
+ def safe(sql: str) -> list:
18
+ return run(assert_select(sql))
19
+ return safe
20
+
21
+
22
+ def dicts(rows: list, names: list[str]) -> list[dict]:
23
+ """Rows may be dicts (command tools) or tuples (DB-API / SQLAlchemy); return dicts keyed by `names`."""
24
+ out = []
25
+ for r in rows:
26
+ if isinstance(r, dict):
27
+ low = {k.lower(): v for k, v in r.items()}
28
+ out.append({n: r[n] if n in r else low.get(n.lower()) for n in names})
29
+ else:
30
+ out.append(dict(zip(names, r)))
31
+ return out
32
+
33
+
34
+ class Source:
35
+ kind = "generic"
36
+ dialect = "generic"
37
+ quote = '"'
38
+ read_only = "all SQL passes keygraph.probe.assert_select (single SELECT only)"
39
+
40
+ def schema(self) -> Schema: # pragma: no cover - interface
41
+ raise NotImplementedError
42
+
43
+ bare = False # use bare table names (graph schema is a label, not a real schema)
44
+
45
+ def table_ref(self, table_id: str) -> str:
46
+ sch, name = table_id.split(".", 1)
47
+ if self.bare:
48
+ sch = ""
49
+ q = self.quote
50
+ return f"{q}{sch}{q}.{q}{name}{q}" if sch not in ("", "main", "default") else f"{q}{name}{q}"
51
+
52
+ def sample(self, table_id: str, cols: list[str], n: int, strategy: str = "limit", pk: str | None = None):
53
+ """Return (cols, rows) for a bounded sample. SQL sources override `run`."""
54
+ from ..profile import build_sample_sql
55
+ sql = build_sample_sql(self.dialect, self.table_ref(table_id), cols, n, strategy, pk, self.quote)
56
+ rows = self.run(sql) # type: ignore[attr-defined]
57
+ return cols, [tuple(r[c] for c in cols) if isinstance(r, dict) else tuple(r) for r in rows]
@@ -0,0 +1,67 @@
1
+ """ClickHouse through system.tables / system.columns / system.data_skipping_indices via a run(sql) callable.
2
+
3
+ "Keys": the primary key columns become the PRIMARY index (ClickHouse primary keys are sorting prefixes, not unique),
4
+ the ORDER BY key becomes an index named 'ORDER BY', data-skipping indices become indexes, and the partition key is
5
+ kept in table info. Enum8/Enum16 members and LowCardinality/Nullable wrappers are understood. Read-only (SELECT only)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from ..model import Column, Index, Schema, parse_enum_members
12
+ from .base import Source, dicts, guarded
13
+
14
+ TABLES = ("SELECT database, name, engine, total_rows, total_bytes, partition_key, sorting_key, primary_key, comment FROM system.tables "
15
+ "WHERE database IN ({dbs}) AND NOT is_temporary AND engine NOT LIKE '%View' LIMIT 100000")
16
+ TABN = ["database", "name", "engine", "total_rows", "total_bytes", "partition_key", "sorting_key", "primary_key", "comment"]
17
+ COLUMNS = ("SELECT database, table, name, position, type, default_kind, default_expression, comment FROM system.columns "
18
+ "WHERE database IN ({dbs}) ORDER BY database, table, position LIMIT 500000")
19
+ COLN = ["database", "table", "name", "position", "type", "default_kind", "default_expression", "comment"]
20
+ SKIP = "SELECT database, table, name, type, expr, granularity FROM system.data_skipping_indices WHERE database IN ({dbs}) LIMIT 100000"
21
+ SKIPN = ["database", "table", "name", "type", "expr", "granularity"]
22
+
23
+
24
+ def key_columns(expr: str | None, known: set[str]) -> list[str]:
25
+ """Columns named in a key expression such as 'tuple(a, toDate(b))' or 'a, b' (functions are looked through)."""
26
+ return [t for t in dict.fromkeys(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expr or "")) if t in known]
27
+
28
+
29
+ class ClickHouseSource(Source):
30
+ kind, dialect, quote = "clickhouse", "clickhouse", '"'
31
+
32
+ def __init__(self, run, databases: list[str] | None = None):
33
+ self.run = guarded(run)
34
+ self.dbs = ", ".join("'" + d.replace("'", "") + "'" for d in databases) if databases else "currentDatabase()"
35
+
36
+ def schema(self) -> Schema:
37
+ sch = Schema()
38
+ info = {(r["database"], r["name"]): r for r in dicts(self.run(TABLES.format(dbs=self.dbs)), TABN)}
39
+ for r in dicts(self.run(COLUMNS.format(dbs=self.dbs)), COLN):
40
+ if (r["database"], r["table"]) not in info:
41
+ continue
42
+ t = sch.get_or_create(r["database"], r["table"])
43
+ ty = str(r["type"])
44
+ t.columns.append(Column(r["name"], ty, ty.startswith("Nullable(") or "Nullable(" in ty, int(r["position"]),
45
+ default=r["default_expression"] or None, extra=r["default_kind"] or "", comment=r["comment"] or "",
46
+ values=parse_enum_members(ty), generated=r["default_expression"] if r["default_kind"] in ("MATERIALIZED", "ALIAS") else ""))
47
+ for (db, name), r in info.items():
48
+ t = sch.tables.get(f"{db}.{name}")
49
+ if not t:
50
+ continue
51
+ known = {c.name for c in t.columns}
52
+ pk = key_columns(r["primary_key"], known)
53
+ if pk:
54
+ t.indexes.append(Index("PRIMARY", pk, False, True))
55
+ sk = key_columns(r["sorting_key"], known)
56
+ if sk and sk != pk:
57
+ t.indexes.append(Index("ORDER BY", sk))
58
+ t.info.update({k: v for k, v in (("engine", r["engine"]), ("rows_estimate", r["total_rows"]), ("data_bytes", r["total_bytes"]),
59
+ ("partition_key", r["partition_key"]), ("sorting_key", r["sorting_key"]), ("comment", r["comment"]))
60
+ if v not in (None, "")})
61
+ for r in dicts(self.run(SKIP.format(dbs=self.dbs)), SKIPN):
62
+ t = sch.tables.get(f"{r['database']}.{r['table']}")
63
+ if t:
64
+ cols = key_columns(r["expr"], {c.name for c in t.columns})
65
+ if cols:
66
+ t.indexes.append(Index(f"{r['name']} ({r['type']})", cols))
67
+ return sch
@@ -0,0 +1,169 @@
1
+ """Load information_schema-style dumps (JSON, JSONL, CSV) with configurable field mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Callable, Iterable
9
+
10
+ from ..model import Column, ForeignKey, Index, Schema, parse_enum_members
11
+
12
+ # Canonical field names are the MySQL information_schema ones. A mapping renames YOUR fields to these:
13
+ # {"schema": "database", "table": "table", "column": "column", "raw": "raw"} style is expressed as
14
+ # {"TABLE_SCHEMA": "database", "TABLE_NAME": "table", "COLUMN_NAME": "column"}: canonical -> source field.
15
+ # A field mapped to "a.b" reads the nested key b of key a. "raw" flattening: set "_flatten": ["raw"].
16
+
17
+
18
+ def read_rows(path: str | Path) -> list[dict]:
19
+ p = Path(path)
20
+ suffix = p.suffix.lower()
21
+ if suffix == ".csv":
22
+ with p.open(newline="", encoding="utf-8-sig") as fh:
23
+ return list(csv.DictReader(fh))
24
+ text = p.read_text(encoding="utf-8")
25
+ if suffix in (".jsonl", ".ndjson"):
26
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
27
+ data = json.loads(text)
28
+ if isinstance(data, dict):
29
+ for key in ("rows", "items", "data", "results"):
30
+ if isinstance(data.get(key), list):
31
+ return data[key]
32
+ raise ValueError(f"{p}: JSON object without a rows/items/data/results list")
33
+ return data
34
+
35
+
36
+ def _get(row: dict, key: str, mapping: dict, default=None):
37
+ src = mapping.get(key, key)
38
+ for cand in (src, src.lower(), src.upper()):
39
+ if "." in cand:
40
+ cur = row
41
+ for part in cand.split("."):
42
+ cur = cur.get(part) if isinstance(cur, dict) else None
43
+ if cur is not None:
44
+ return cur
45
+ elif cand in row and row[cand] is not None:
46
+ return row[cand]
47
+ return default
48
+
49
+
50
+ def _flat(row: dict, flatten: Iterable[str]) -> dict:
51
+ out = dict(row)
52
+ for k in flatten:
53
+ if isinstance(out.get(k), dict):
54
+ out.update(out[k])
55
+ return out
56
+
57
+
58
+ def _bool_yes(v) -> bool:
59
+ return str(v).strip().lower() in ("yes", "y", "true", "1", "t")
60
+
61
+
62
+ def load_dump(columns: str | Path | list | None = None, statistics: str | Path | list | None = None,
63
+ key_columns: str | Path | list | None = None, default_schema: str = "main",
64
+ columns_map: dict | None = None, statistics_map: dict | None = None,
65
+ kcu_map: dict | None = None, row_filter: Callable[[dict], bool] | None = None,
66
+ schema_map: Callable[[dict], str] | None = None, tables: str | Path | list | None = None,
67
+ tables_map: dict | None = None) -> Schema:
68
+ """Build a Schema. Each argument is a path or an already-loaded list of row dicts.
69
+
70
+ Missing STATISTICS: indexes fall back to COLUMN_KEY (PRI/UNI/MUL), marked approximate.
71
+ Missing KEY_COLUMN_USAGE: no declared foreign keys (everything is inferred).
72
+ """
73
+ cm, sm, km = columns_map or {}, statistics_map or {}, kcu_map or {}
74
+ sch = Schema()
75
+
76
+ def rows_of(src, mp):
77
+ if src is None:
78
+ return None
79
+ rows = src if isinstance(src, list) else read_rows(src)
80
+ return [_flat(r, mp.get("_flatten", [])) for r in rows]
81
+
82
+ def tschema(row, mp):
83
+ if schema_map:
84
+ return schema_map(row)
85
+ v = _get(row, "TABLE_SCHEMA", mp)
86
+ return str(v) if v not in (None, "") else default_schema
87
+
88
+ col_rows = rows_of(columns, cm) or []
89
+ for r in col_rows:
90
+ if row_filter and not row_filter(r):
91
+ continue
92
+ name = _get(r, "TABLE_NAME", cm)
93
+ cname = _get(r, "COLUMN_NAME", cm)
94
+ if not name or not cname:
95
+ continue
96
+ t = sch.get_or_create(tschema(r, cm), str(name))
97
+ ctype = _get(r, "COLUMN_TYPE", cm) or _get(r, "DATA_TYPE", cm) or ""
98
+ t.columns.append(Column(str(cname), str(ctype), not (str(_get(r, "IS_NULLABLE", cm, "YES")).upper() == "NO"),
99
+ int(_get(r, "ORDINAL_POSITION", cm, len(t.columns) + 1) or 0),
100
+ str(_get(r, "COLUMN_KEY", cm, "") or "")))
101
+ c = t.columns[-1]
102
+ dv = _get(r, "COLUMN_DEFAULT", cm)
103
+ c.default = None if dv is None else str(dv)
104
+ c.extra = str(_get(r, "EXTRA", cm, "") or "")
105
+ c.charset = str(_get(r, "CHARACTER_SET_NAME", cm, "") or "")
106
+ c.collation = str(_get(r, "COLLATION_NAME", cm, "") or "")
107
+ c.comment = str(_get(r, "COLUMN_COMMENT", cm, "") or "")
108
+ c.generated = str(_get(r, "GENERATION_EXPRESSION", cm, "") or "")
109
+ c.values = parse_enum_members(str(ctype))
110
+ stat_rows = rows_of(statistics, sm)
111
+ if stat_rows is not None:
112
+ groups: dict[tuple, dict] = {}
113
+ for r in stat_rows:
114
+ key = (tschema(r, sm), str(_get(r, "TABLE_NAME", sm)), str(_get(r, "INDEX_NAME", sm)))
115
+ g = groups.setdefault(key, {"unique": str(_get(r, "NON_UNIQUE", sm, 1)) in ("0", "False", "false"), "cols": {}})
116
+ col = _get(r, "COLUMN_NAME", sm)
117
+ if col is not None:
118
+ g["cols"][int(_get(r, "SEQ_IN_INDEX", sm, len(g["cols"]) + 1))] = str(col)
119
+ for (s, tn, ixn), g in sorted(groups.items()):
120
+ tid = f"{s}.{tn}"
121
+ if tid not in sch.tables:
122
+ continue
123
+ cols = [g["cols"][k] for k in sorted(g["cols"])]
124
+ sch.tables[tid].indexes.append(Index(ixn, cols, g["unique"], ixn.upper() == "PRIMARY"))
125
+ else:
126
+ for t in sch.tables.values():
127
+ cs = sorted(t.columns, key=lambda c: c.position)
128
+ pk = [c.name for c in cs if c.key == "PRI"]
129
+ if pk:
130
+ t.indexes.append(Index("PRIMARY", pk, True, True))
131
+ for c in cs:
132
+ if c.key == "UNI":
133
+ t.indexes.append(Index(f"(unique:{c.name})", [c.name], True, False, True))
134
+ elif c.key == "MUL":
135
+ t.indexes.append(Index(f"(index:{c.name})", [c.name], False, False, True))
136
+ kcu = rows_of(key_columns, km)
137
+ if kcu is not None:
138
+ fks: dict[tuple, dict] = {}
139
+ for r in kcu:
140
+ rt = _get(r, "REFERENCED_TABLE_NAME", km)
141
+ tn = _get(r, "TABLE_NAME", km)
142
+ if not rt:
143
+ if stat_rows is None and str(_get(r, "CONSTRAINT_NAME", km, "")).upper() == "PRIMARY" and tn:
144
+ t = sch.tables.get(f"{tschema(r, km)}.{tn}")
145
+ if t and not t.primary_key:
146
+ t.indexes.append(Index("PRIMARY", [str(_get(r, "COLUMN_NAME", km))], True, True))
147
+ continue
148
+ s = tschema(r, km)
149
+ g = fks.setdefault((s, str(tn), str(_get(r, "CONSTRAINT_NAME", km, "")),
150
+ str(_get(r, "REFERENCED_TABLE_SCHEMA", km) or s), str(rt)), {})
151
+ g[int(_get(r, "ORDINAL_POSITION", km, len(g) + 1))] = (str(_get(r, "COLUMN_NAME", km)),
152
+ str(_get(r, "REFERENCED_COLUMN_NAME", km)))
153
+ for (s, tn, cn, rs, rt), g in sorted(fks.items()):
154
+ t = sch.tables.get(f"{s}.{tn}")
155
+ if t is not None:
156
+ pairs = [g[k] for k in sorted(g)]
157
+ t.foreign_keys.append(ForeignKey(cn, [p[0] for p in pairs], rs, rt, [p[1] for p in pairs]))
158
+ trows = rows_of(tables, tables_map or {})
159
+ for r in trows or []:
160
+ t = sch.tables.get(f"{tschema(r, tables_map or {})}.{_get(r, 'TABLE_NAME', tables_map or {})}")
161
+ if t is None:
162
+ continue
163
+ g = lambda k: _get(r, k, tables_map or {}) # noqa: E731
164
+ info = {"engine": g("ENGINE"), "rows_estimate": g("TABLE_ROWS"), "data_bytes": g("DATA_LENGTH"),
165
+ "index_bytes": g("INDEX_LENGTH"), "comment": g("TABLE_COMMENT"), "partitioning": g("PARTITIONING"),
166
+ "create_options": g("CREATE_OPTIONS")}
167
+ t.info.update({k: (int(v) if k.endswith(("estimate", "bytes")) and str(v).isdigit() else v)
168
+ for k, v in info.items() if v not in (None, "")})
169
+ return sch
@@ -0,0 +1,93 @@
1
+ """Fetch information_schema STATISTICS and KEY_COLUMN_USAGE through any command or callable.
2
+
3
+ Read-only: only SELECT statements on information_schema are issued. `run_sql(sql) -> list[dict]` can be a
4
+ DB-API wrapper, a SQLAlchemy wrapper, or `command_runner("some-cli --sql {sql}")` for an external tool that
5
+ prints JSON (a list of rows, or an object with a "rows" list; leading log lines are skipped).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shlex
12
+ import string
13
+ import subprocess
14
+ from typing import Callable
15
+
16
+ STATS_SQL = ("SELECT TABLE_SCHEMA,TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME "
17
+ "FROM information_schema.STATISTICS WHERE TABLE_SCHEMA={schema} {extra} "
18
+ "ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX LIMIT {limit}")
19
+ KCU_SQL = ("SELECT TABLE_SCHEMA,TABLE_NAME,CONSTRAINT_NAME,ORDINAL_POSITION,COLUMN_NAME,"
20
+ "REFERENCED_TABLE_SCHEMA,REFERENCED_TABLE_NAME,REFERENCED_COLUMN_NAME "
21
+ "FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA={schema} "
22
+ "AND REFERENCED_TABLE_NAME IS NOT NULL {extra} ORDER BY TABLE_NAME,CONSTRAINT_NAME,ORDINAL_POSITION LIMIT {limit}")
23
+
24
+ Rows = list[dict]
25
+
26
+
27
+ def parse_rows(stdout: str) -> Rows:
28
+ text = stdout.strip()
29
+ starts = [i for i in (text.find("{"), text.find("[")) if i >= 0]
30
+ if not starts:
31
+ raise ValueError("no JSON in command output: " + text[:200])
32
+ data = json.loads(text[min(starts):])
33
+ if isinstance(data, dict):
34
+ data = data.get("rows", [])
35
+ return data
36
+
37
+
38
+ def command_runner(template: str, timeout: int = 120) -> Callable[[str], Rows]:
39
+ """template like 'mytool --engine prod query --sql {sql}'; {sql} is passed as one argument."""
40
+ parts = shlex.split(template)
41
+
42
+ def run(sql: str) -> Rows:
43
+ argv = [p.replace("{sql}", sql) if "{sql}" in p else p for p in parts]
44
+ done = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
45
+ if done.returncode != 0:
46
+ raise RuntimeError(f"command failed ({done.returncode}): {done.stderr.strip()[-300:]}")
47
+ return parse_rows(done.stdout)
48
+
49
+ return run
50
+
51
+
52
+ def _chunked(run_sql, template: str, schema_expr: str, limit: int, name_col: str = "TABLE_NAME") -> Rows:
53
+ """Run the query; when a result hits the row limit, split by table-name prefix until each part fits."""
54
+ out: Rows = []
55
+
56
+ def go(prefix: str):
57
+ extra = f"AND {name_col} LIKE '{prefix}%'" if prefix else ""
58
+ rows = run_sql(template.format(schema=schema_expr, extra=extra, limit=limit))
59
+ if len(rows) < limit or len(prefix) >= 6:
60
+ out.extend(rows)
61
+ return
62
+ alphabet = string.ascii_lowercase + string.digits + "_"
63
+ for ch in alphabet:
64
+ go(prefix + ch.replace("_", "\\_"))
65
+ # names starting with an uppercase letter match LIKE case-insensitively on most collations
66
+
67
+ go("")
68
+ return out
69
+
70
+
71
+ def fetch_metadata(run_sql: Callable[[str], Rows], schema_expr: str = "DATABASE()", limit: int = 10000) -> tuple[Rows, Rows]:
72
+ return (_chunked(run_sql, STATS_SQL, schema_expr, limit), _chunked(run_sql, KCU_SQL, schema_expr, limit))
73
+
74
+
75
+ COLUMNS_SQL = ("SELECT TABLE_SCHEMA,TABLE_NAME,COLUMN_NAME,ORDINAL_POSITION,COLUMN_TYPE,DATA_TYPE,IS_NULLABLE,COLUMN_KEY,"
76
+ "COLUMN_DEFAULT,EXTRA,CHARACTER_SET_NAME,COLLATION_NAME,COLUMN_COMMENT,GENERATION_EXPRESSION "
77
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA={schema} {extra} ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT {limit}")
78
+ TABLES_SQL = ("SELECT TABLE_SCHEMA,TABLE_NAME,ENGINE,TABLE_ROWS,DATA_LENGTH,INDEX_LENGTH,TABLE_COMMENT,CREATE_OPTIONS "
79
+ "FROM information_schema.TABLES WHERE TABLE_SCHEMA={schema} AND TABLE_TYPE='BASE TABLE' {extra} ORDER BY TABLE_NAME LIMIT {limit}")
80
+ CHECKS_SQL = ("SELECT tc.TABLE_SCHEMA,tc.TABLE_NAME,cc.CONSTRAINT_NAME,cc.CHECK_CLAUSE FROM information_schema.CHECK_CONSTRAINTS cc "
81
+ "JOIN information_schema.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_SCHEMA=cc.CONSTRAINT_SCHEMA AND "
82
+ "tc.CONSTRAINT_NAME=cc.CONSTRAINT_NAME WHERE tc.TABLE_SCHEMA={schema} AND tc.CONSTRAINT_TYPE='CHECK' LIMIT {limit}")
83
+
84
+
85
+ def fetch_mysql_all(run_sql, schema_expr: str = "DATABASE()", limit: int = 10000) -> dict:
86
+ """COLUMNS, TABLES, STATISTICS, KEY_COLUMN_USAGE (chunked) and CHECK constraints (best effort)."""
87
+ out = {"columns": _chunked(run_sql, COLUMNS_SQL, schema_expr, limit), "tables": _chunked(run_sql, TABLES_SQL, schema_expr, limit)}
88
+ out["statistics"], out["key_columns"] = fetch_metadata(run_sql, schema_expr, limit)
89
+ try:
90
+ out["checks"] = run_sql(CHECKS_SQL.format(schema=schema_expr, limit=limit))
91
+ except Exception: # noqa: BLE001 (MariaDB / old MySQL / tool restrictions)
92
+ out["checks"] = []
93
+ return out
@@ -0,0 +1,50 @@
1
+ """Live scan through the SQLAlchemy inspector. Metadata only; never reads table rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model import Column, ForeignKey, Index, Schema, Table
6
+
7
+ SYSTEM_SCHEMAS = {"information_schema", "mysql", "performance_schema", "sys", "pg_catalog", "pg_toast"}
8
+
9
+
10
+ def scan_url(url: str, schemas: list[str] | None = None, all_schemas: bool = False) -> Schema:
11
+ try:
12
+ from sqlalchemy import create_engine, inspect
13
+ except ImportError as exc: # pragma: no cover
14
+ raise SystemExit("live scanning needs SQLAlchemy: pip install 'keygraph[live]'") from exc
15
+ engine = create_engine(url)
16
+ insp = inspect(engine)
17
+ default = insp.default_schema_name or "main"
18
+ if all_schemas:
19
+ names = [s for s in insp.get_schema_names() if s.lower() not in SYSTEM_SCHEMAS and not s.startswith("pg_")]
20
+ else:
21
+ names = schemas or [default]
22
+ sch = Schema()
23
+ for s in names:
24
+ qual = None if s == default else s
25
+ for tn in insp.get_table_names(schema=qual):
26
+ t = Table(s, tn)
27
+ pk = insp.get_pk_constraint(tn, schema=qual) or {}
28
+ pkcols = list(pk.get("constrained_columns") or [])
29
+ for i, c in enumerate(insp.get_columns(tn, schema=qual), 1):
30
+ t.columns.append(Column(c["name"], str(c["type"]), bool(c.get("nullable", True)), i,
31
+ "PRI" if c["name"] in pkcols else ""))
32
+ if pkcols:
33
+ t.indexes.append(Index("PRIMARY", pkcols, True, True))
34
+ seen = {tuple(pkcols)} if pkcols else set()
35
+ for u in insp.get_unique_constraints(tn, schema=qual):
36
+ cols = [c for c in u.get("column_names", []) if c]
37
+ if cols and tuple(cols) not in seen:
38
+ seen.add(tuple(cols))
39
+ t.indexes.append(Index(u.get("name") or f"uq_{'_'.join(cols)}", cols, True))
40
+ for ix in insp.get_indexes(tn, schema=qual):
41
+ cols = [c for c in ix.get("column_names", []) if c]
42
+ if not cols or (ix.get("unique") and tuple(cols) in seen):
43
+ continue
44
+ t.indexes.append(Index(ix.get("name") or f"ix_{'_'.join(cols)}", cols, bool(ix.get("unique"))))
45
+ for fk in insp.get_foreign_keys(tn, schema=qual):
46
+ t.foreign_keys.append(ForeignKey(fk.get("name") or "", list(fk["constrained_columns"]),
47
+ fk.get("referred_schema") or s, fk["referred_table"],
48
+ list(fk["referred_columns"])))
49
+ sch.add(t)
50
+ return sch
@@ -0,0 +1,78 @@
1
+ """Metabase REST API: metadata (tables, fields, semantic types, foreign-key targets Metabase knows), free profile
2
+ statistics from field fingerprints, and native read-only queries.
3
+
4
+ `http(method, path, body=None) -> parsed JSON` is injected, so authentication (session token or API key) stays
5
+ outside this module. Read-only: only GET requests and POST /api/dataset with a native SELECT (assert_select)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Callable
11
+
12
+ from ..model import Column, ForeignKey, Index, Schema
13
+ from .base import Source, guarded
14
+
15
+
16
+ class MetabaseSource(Source):
17
+ kind, dialect, quote = "metabase", "generic", '"'
18
+
19
+ def __init__(self, http: Callable, database_id: int, schema_name: str | None = None):
20
+ self.http, self.db_id, self.schema_name = http, database_id, schema_name
21
+ self._fp: dict = {}
22
+ self.run = guarded(self._native)
23
+
24
+ def _native(self, sql: str) -> list:
25
+ res = self.http("POST", "/api/dataset", {"database": self.db_id, "type": "native", "native": {"query": sql}})
26
+ data = res.get("data", res)
27
+ names = [c["name"] for c in data.get("cols", [])]
28
+ return [dict(zip(names, row)) for row in data.get("rows", [])]
29
+
30
+ def schema(self) -> Schema:
31
+ meta = self.http("GET", f"/api/database/{self.db_id}/metadata?include_hidden=false")
32
+ sch = Schema()
33
+ fields_by_id: dict = {}
34
+ for tb in meta.get("tables", []):
35
+ sub = tb.get("schema") if tb.get("schema") not in (None, "", "public", "default") else ""
36
+ s = (f"{self.schema_name}_{sub}" if sub else self.schema_name) if self.schema_name else (tb.get("schema") or "default")
37
+ t = sch.get_or_create(s, tb["name"])
38
+ if tb.get("description"):
39
+ t.info["comment"] = tb["description"]
40
+ for f in sorted(tb.get("fields", []), key=lambda f: f.get("database_position", 0)):
41
+ sem = f.get("semantic_type") or ""
42
+ c = Column(f["name"], f.get("database_type") or f.get("base_type", ""), not f.get("database_required", False),
43
+ f.get("database_position", 0) + 1, key="PRI" if sem == "type/PK" else "",
44
+ comment=(f.get("description") or "") + (f" [semantic: {sem}]" if sem and sem != "type/PK" else ""))
45
+ t.columns.append(c)
46
+ fields_by_id[f["id"]] = (t, c)
47
+ if f.get("fingerprint"):
48
+ self._fp[f"{t.id}.{c.name}"] = (f["fingerprint"], c.type)
49
+ pk = [c.name for c in t.columns if c.key == "PRI"]
50
+ if pk:
51
+ t.indexes.append(Index("PRIMARY", pk, True, True))
52
+ for tb in meta.get("tables", []):
53
+ for f in tb.get("fields", []):
54
+ tgt = fields_by_id.get(f.get("fk_target_field_id"))
55
+ src = fields_by_id.get(f["id"])
56
+ if tgt and src:
57
+ src[0].foreign_keys.append(ForeignKey(f"metabase_fk_{f['name']}", [f["name"]], tgt[0].schema, tgt[0].name, [tgt[1].name]))
58
+ return sch
59
+
60
+ def fingerprints(self) -> dict:
61
+ """Profiles derived from Metabase field fingerprints (approximate, no query needed)."""
62
+ out = {}
63
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
64
+ for key, (fp, _ty) in self._fp.items():
65
+ g = fp.get("global", {})
66
+ p = {"method": "metabase fingerprint (sampled by Metabase)", "profiled_at": now, "sample_size": None,
67
+ "null_rate": g.get("nil%"), "distinct_in_sample": g.get("distinct-count")}
68
+ ty = fp.get("type", {})
69
+ if "type/Number" in ty:
70
+ n = ty["type/Number"]
71
+ p["numeric"] = {"min": n.get("min"), "max": n.get("max"), "p25": n.get("q1"), "p75": n.get("q3"), "mean": n.get("avg")}
72
+ if "type/Text" in ty:
73
+ p["length"] = {"avg": ty["type/Text"].get("average-length")}
74
+ if "type/DateTime" in ty:
75
+ d = ty["type/DateTime"]
76
+ p["dates"] = {"min": d.get("earliest"), "max": d.get("latest"), "recency_days": None}
77
+ out[key] = p
78
+ return out
@@ -0,0 +1,93 @@
1
+ """MongoDB: schema inferred from sampled documents (nested paths become columns), indexes from the collection's
2
+ index information, `_id` as primary key, DBRef values as declared references. The database object is injected
3
+ (pymongo style) and wrapped so only read operations can be called."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from ..model import Column, ForeignKey, Index, Schema, Table
8
+ from .base import Source
9
+
10
+ READ_DB = {"list_collection_names", "__getitem__", "name"}
11
+ READ_COLL = {"find", "index_information", "estimated_document_count", "name"}
12
+
13
+
14
+ class ReadOnlyProxy:
15
+ def __init__(self, obj, allowed):
16
+ self._o, self._a = obj, allowed
17
+
18
+ def __getattr__(self, k):
19
+ if k not in self._a:
20
+ raise PermissionError(f"keygraph is read-only: '{k}' is not allowed")
21
+ return getattr(self._o, k)
22
+
23
+ def __getitem__(self, k):
24
+ if "__getitem__" not in self._a:
25
+ raise PermissionError("not allowed")
26
+ return ReadOnlyProxy(self._o[k], READ_COLL)
27
+
28
+
29
+ def _tname(v) -> str:
30
+ n = type(v).__name__
31
+ return {"str": "string", "int": "int", "float": "double", "bool": "bool", "NoneType": "null", "datetime": "date", "dict": "object",
32
+ "list": "array", "Int64": "long", "Decimal128": "decimal"}.get(n, n)
33
+
34
+
35
+ def flatten(doc: dict, prefix: str = "") -> dict:
36
+ out = {}
37
+ for k, v in doc.items():
38
+ path = f"{prefix}{k}"
39
+ if isinstance(v, dict) and not ("$ref" in v and "$id" in v) and v:
40
+ out.update(flatten(v, path + "."))
41
+ else:
42
+ out[path] = v
43
+ return out
44
+
45
+
46
+ class MongoSource(Source):
47
+ kind, dialect = "mongodb", "mongo"
48
+ read_only = "only list_collection_names, find, index_information and estimated_document_count are reachable"
49
+
50
+ def __init__(self, db, schema_name: str = "default", sample: int = 200):
51
+ self.db, self.schema_name, self.n = ReadOnlyProxy(db, READ_DB), schema_name, sample
52
+
53
+ def schema(self) -> Schema:
54
+ sch = Schema()
55
+ for name in sorted(self.db.list_collection_names()):
56
+ coll = self.db[name]
57
+ t = Table(self.schema_name, name)
58
+ types: dict[str, dict] = {}
59
+ refs: dict[str, str] = {}
60
+ docs = list(coll.find({}).limit(self.n))
61
+ for d in docs:
62
+ for path, v in flatten(d).items():
63
+ types.setdefault(path, {}).setdefault(_tname(v), 0)
64
+ types[path][_tname(v)] += 1
65
+ if isinstance(v, dict) and "$ref" in v and "$id" in v:
66
+ refs[path] = v["$ref"]
67
+ for i, (path, tc) in enumerate(sorted(types.items(), key=lambda x: (x[0] != "_id", x[0])), 1):
68
+ main = max(tc, key=tc.get)
69
+ present = sum(tc.values())
70
+ t.columns.append(Column(path, main if len(tc) == 1 else "/".join(sorted(tc)), present < len(docs), i,
71
+ key="PRI" if path == "_id" else "", comment="inferred from sampled documents"))
72
+ if "_id" in types:
73
+ t.indexes.append(Index("_id_", ["_id"], True, True))
74
+ try:
75
+ for iname, spec in coll.index_information().items():
76
+ cols = [k for k, _ in spec.get("key", [])]
77
+ if iname != "_id_" and cols:
78
+ t.indexes.append(Index(iname, cols, bool(spec.get("unique"))))
79
+ except Exception: # noqa: BLE001
80
+ pass
81
+ for path, ref in refs.items():
82
+ t.foreign_keys.append(ForeignKey(f"dbref_{path}", [path], self.schema_name, ref, ["_id"]))
83
+ try:
84
+ t.info["rows_estimate"] = coll.estimated_document_count()
85
+ except Exception: # noqa: BLE001
86
+ pass
87
+ sch.add(t)
88
+ return sch
89
+
90
+ def sample(self, table_id: str, cols: list[str], n: int, strategy: str = "limit", pk=None):
91
+ name = table_id.split(".", 1)[1]
92
+ docs = [flatten(d) for d in self.db[name].find({}).limit(n)]
93
+ return cols, [tuple(d.get(c) for c in cols) for d in docs]