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/__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,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]
|