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/model.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Normalised schema model shared by every adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Column:
|
|
11
|
+
name: str
|
|
12
|
+
type: str = ""
|
|
13
|
+
nullable: bool = True
|
|
14
|
+
position: int = 0
|
|
15
|
+
key: str = "" # PRI / UNI / MUL as reported by MySQL COLUMNS, or ""
|
|
16
|
+
default: str | None = None
|
|
17
|
+
extra: str = "" # auto_increment, on update ..., VIRTUAL GENERATED
|
|
18
|
+
charset: str = ""
|
|
19
|
+
collation: str = ""
|
|
20
|
+
comment: str = ""
|
|
21
|
+
values: list[str] = field(default_factory=list) # enum / set members
|
|
22
|
+
generated: str = "" # generation expression
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Index:
|
|
27
|
+
name: str
|
|
28
|
+
columns: list[str]
|
|
29
|
+
unique: bool = False
|
|
30
|
+
primary: bool = False
|
|
31
|
+
approximate: bool = False # derived from COLUMN_KEY only; composition unknown
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ForeignKey:
|
|
36
|
+
name: str
|
|
37
|
+
columns: list[str]
|
|
38
|
+
ref_schema: str
|
|
39
|
+
ref_table: str
|
|
40
|
+
ref_columns: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Table:
|
|
45
|
+
schema: str
|
|
46
|
+
name: str
|
|
47
|
+
columns: list[Column] = field(default_factory=list)
|
|
48
|
+
indexes: list[Index] = field(default_factory=list)
|
|
49
|
+
foreign_keys: list[ForeignKey] = field(default_factory=list)
|
|
50
|
+
info: dict = field(default_factory=dict) # engine, rows_estimate, data_bytes, partitioning, comment, checks, sorting_key ...
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def id(self) -> str:
|
|
54
|
+
return f"{self.schema}.{self.name}"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def primary_key(self) -> list[str]:
|
|
58
|
+
for ix in self.indexes:
|
|
59
|
+
if ix.primary:
|
|
60
|
+
return list(ix.columns)
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def unique_keys(self) -> list[list[str]]:
|
|
65
|
+
return [list(ix.columns) for ix in self.indexes if ix.unique and not ix.primary]
|
|
66
|
+
|
|
67
|
+
def column(self, name: str) -> Column | None:
|
|
68
|
+
for c in self.columns:
|
|
69
|
+
if c.name == name:
|
|
70
|
+
return c
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Schema:
|
|
76
|
+
tables: dict[str, Table] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
def add(self, table: Table) -> None:
|
|
79
|
+
self.tables[table.id] = table
|
|
80
|
+
|
|
81
|
+
def get_or_create(self, schema: str, name: str) -> Table:
|
|
82
|
+
tid = f"{schema}.{name}"
|
|
83
|
+
if tid not in self.tables:
|
|
84
|
+
self.tables[tid] = Table(schema=schema, name=name)
|
|
85
|
+
return self.tables[tid]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_INT = {"tinyint", "smallint", "mediumint", "int", "integer", "bigint", "serial", "bigserial",
|
|
89
|
+
"smallserial", "int2", "int4", "int8", "int1", "int16", "int32", "int64", "uint8",
|
|
90
|
+
"uint16", "uint32", "uint64", "number"}
|
|
91
|
+
_DEC = {"decimal", "numeric", "money", "dec", "fixed"}
|
|
92
|
+
_FLT = {"float", "double", "real", "float4", "float8", "float32", "float64"}
|
|
93
|
+
_STR = {"char", "varchar", "text", "tinytext", "mediumtext", "longtext", "character", "nvarchar",
|
|
94
|
+
"nchar", "string", "citext", "enum", "enum8", "enum16", "set", "uuid", "ntext", "fixedstring", "varchar2"}
|
|
95
|
+
_DATE = {"date", "datetime", "timestamp", "timestamptz", "time", "year", "datetime2", "date32"}
|
|
96
|
+
_BIN = {"blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary", "bytea"}
|
|
97
|
+
_BOOL = {"bool", "boolean", "bit"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def type_family(type_str: str) -> str:
|
|
101
|
+
"""Coarse type family: int, decimal, float, string, datetime, json, binary, bool or unknown.
|
|
102
|
+
|
|
103
|
+
Signedness and width are ignored on purpose: a signed key and an unsigned key still join.
|
|
104
|
+
"""
|
|
105
|
+
t = (type_str or "").lower().strip()
|
|
106
|
+
if not t:
|
|
107
|
+
return "unknown"
|
|
108
|
+
while re.match(r"^(nullable|lowcardinality)\(", t):
|
|
109
|
+
t = re.sub(r"^(nullable|lowcardinality)\(", "", t).rstrip(")")
|
|
110
|
+
first = re.split(r"[\s(]", t, maxsplit=1)[0]
|
|
111
|
+
if first == "double" or first == "character" and "varying" in t:
|
|
112
|
+
return "float" if first == "double" else "string"
|
|
113
|
+
if first in _INT or first.startswith("uint") or (first.startswith("int") and first[3:].isdigit()):
|
|
114
|
+
return "int"
|
|
115
|
+
if first in _DEC:
|
|
116
|
+
return "decimal"
|
|
117
|
+
if first in _FLT:
|
|
118
|
+
return "float"
|
|
119
|
+
if first in _STR:
|
|
120
|
+
return "string"
|
|
121
|
+
if first in _DATE or first.startswith("timestamp"):
|
|
122
|
+
return "datetime"
|
|
123
|
+
if first in {"json", "jsonb"}:
|
|
124
|
+
return "json"
|
|
125
|
+
if first in _BIN:
|
|
126
|
+
return "binary"
|
|
127
|
+
if first in _BOOL:
|
|
128
|
+
return "bool"
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def type_length(type_str: str) -> int | None:
|
|
133
|
+
m = re.search(r"\((\d+)\)", type_str or "")
|
|
134
|
+
return int(m.group(1)) if m else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_enum_members(type_str: str) -> list[str]:
|
|
138
|
+
"""Members of MySQL enum('a','b') / set(...), ClickHouse Enum8('a' = 1, 'b' = 2) (possibly wrapped in
|
|
139
|
+
Nullable/LowCardinality). Returns [] for other types. ClickHouse members keep their numeric code as 'a=1'."""
|
|
140
|
+
t = (type_str or "").strip()
|
|
141
|
+
m = re.search(r"\b(enum|set|enum8|enum16)\s*\((.*)\)", t, re.I | re.S)
|
|
142
|
+
if not m:
|
|
143
|
+
return []
|
|
144
|
+
body = m.group(2)
|
|
145
|
+
out = []
|
|
146
|
+
for lit in re.finditer(r"'((?:[^'\\]|\\.|'')*)'(?:\s*=\s*(-?\d+))?", body):
|
|
147
|
+
name = lit.group(1).replace("''", "'").replace("\\'", "'")
|
|
148
|
+
out.append(f"{name}={lit.group(2)}" if lit.group(2) is not None and m.group(1).lower().startswith("enum") and m.group(1).lower() != "enum" else name)
|
|
149
|
+
return out
|
keygraph/notes.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Generic importer: one markdown note per table -> table notes and column semantics.
|
|
2
|
+
|
|
3
|
+
Recognised (case-insensitive) heading words: grain|meaning -> grain; gotcha|caveat|pitfall|warning -> pitfalls;
|
|
4
|
+
column(s) -> bullets/rows like "- `col` - meaning" or "| col | meaning |"; code|status|enum -> "- `value` = label"
|
|
5
|
+
(optionally under a "### column" sub-heading); a verified section is read but does not mark the note verified (only a person or a checked run should). A "**Summary:**" line sets the
|
|
6
|
+
summary. Front matter keys: table (or the file stem), and the key named by schema_key for the schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
16
|
+
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
|
|
17
|
+
if not m:
|
|
18
|
+
return {}, text
|
|
19
|
+
meta = {}
|
|
20
|
+
for line in m.group(1).splitlines():
|
|
21
|
+
if ":" in line:
|
|
22
|
+
k, v = line.split(":", 1)
|
|
23
|
+
meta[k.strip()] = v.strip().strip("[]")
|
|
24
|
+
return meta, text[m.end():]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sections(body: str) -> list[tuple[int, str, list[str]]]:
|
|
28
|
+
out: list[tuple[int, str, list[str]]] = []
|
|
29
|
+
for line in body.splitlines():
|
|
30
|
+
h = re.match(r"^(#{1,4})\s+(.*)$", line)
|
|
31
|
+
if h:
|
|
32
|
+
out.append((len(h.group(1)), h.group(2).strip(), []))
|
|
33
|
+
elif out:
|
|
34
|
+
out[-1][2].append(line)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_BUL = re.compile(r"^\s*[-*]\s+`?([A-Za-z0-9_.\-]+)`?\s*(?:[:=—–-]+|\s)\s*(.+)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(lines: list[str]) -> str:
|
|
42
|
+
return re.sub(r"\s+", " ", " ".join(l.strip() for l in lines if l.strip() and not l.strip().startswith("_None"))).strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_note(path: str | Path, schema_key: str = "schema", default_schema: str = "main") -> dict | None:
|
|
46
|
+
p = Path(path)
|
|
47
|
+
meta, body = parse_frontmatter(p.read_text(encoding="utf-8"))
|
|
48
|
+
table = meta.get("table") or p.stem
|
|
49
|
+
schema = meta.get(schema_key) or default_schema
|
|
50
|
+
if meta.get("kind") not in (None, "table"):
|
|
51
|
+
return None
|
|
52
|
+
note: dict = {"table": f"{schema}.{table}", "columns": [], "source": str(p), "verified": False}
|
|
53
|
+
sm = re.search(r"\*\*Summary:\*\*\s*(.+)", body)
|
|
54
|
+
if sm:
|
|
55
|
+
note["summary"] = sm.group(1).strip()
|
|
56
|
+
pit: list[str] = []
|
|
57
|
+
cur_code_col = None
|
|
58
|
+
for level, title, lines in _sections(body):
|
|
59
|
+
tl = title.lower()
|
|
60
|
+
if re.search(r"grain|meaning", tl) and "column" not in tl:
|
|
61
|
+
note["grain"] = _text(lines)
|
|
62
|
+
elif re.search(r"gotcha|caveat|pitfall|warning", tl):
|
|
63
|
+
pit.extend(re.sub(r"^\s*[-*]\s+", "", l).strip() for l in lines if l.strip())
|
|
64
|
+
elif re.search(r"verified", tl):
|
|
65
|
+
note["verified_facts"] = _text(lines) # kept out of the verified flag: a note is a claim, not a check
|
|
66
|
+
elif re.search(r"column", tl):
|
|
67
|
+
for l in lines:
|
|
68
|
+
m = _BUL.match(l)
|
|
69
|
+
r = re.match(r"^\|\s*`?([A-Za-z0-9_]+)`?\s*\|\s*([^|]+)\|", l)
|
|
70
|
+
if (m or r) and not (r and set(r.group(1)) <= {"-"}):
|
|
71
|
+
c, mean = (m or r).group(1), (m or r).group(2).strip()
|
|
72
|
+
note["columns"].append({"column": c, "meaning": mean})
|
|
73
|
+
elif re.search(r"code|status|enum", tl):
|
|
74
|
+
cur_code_col = None
|
|
75
|
+
for l in lines:
|
|
76
|
+
m = _BUL.match(l)
|
|
77
|
+
if m:
|
|
78
|
+
cur_code_col = cur_code_col or re.sub(r"[^\w]", "", title.split()[-1])
|
|
79
|
+
note.setdefault("_codes", {}).setdefault(cur_code_col, {})[m.group(1)] = m.group(2).strip()
|
|
80
|
+
elif level >= 3 and note.get("_codes") is not None:
|
|
81
|
+
pass
|
|
82
|
+
if pit:
|
|
83
|
+
note["pitfalls"] = " | ".join(pit)
|
|
84
|
+
note["codes"] = note.pop("_codes", {})
|
|
85
|
+
return note
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def notes_to_context(root: str | Path, schema_key: str = "schema", default_schema: str = "main",
|
|
89
|
+
skip_names: tuple[str, ...] = ("memory.md", "readme.md")) -> dict:
|
|
90
|
+
"""Convert a folder of per-table markdown notes into a context structure (tables + columns).
|
|
91
|
+
|
|
92
|
+
Entries carry source = the note path; they are always unverified.
|
|
93
|
+
Column meanings and codes are always unverified: a note bullet is a claim, not a check.
|
|
94
|
+
"""
|
|
95
|
+
out: dict = {"tables": [], "columns": []}
|
|
96
|
+
for f in sorted(Path(root).rglob("*.md")):
|
|
97
|
+
if f.name.lower() in skip_names:
|
|
98
|
+
continue
|
|
99
|
+
n = parse_note(f, schema_key, default_schema)
|
|
100
|
+
if not n:
|
|
101
|
+
continue
|
|
102
|
+
cols, codes = n.pop("columns"), n.pop("codes", {})
|
|
103
|
+
n.pop("verified_facts", None)
|
|
104
|
+
out["tables"].append({k: v for k, v in n.items() if v not in ("", None)})
|
|
105
|
+
seen = set()
|
|
106
|
+
for c in cols:
|
|
107
|
+
out["columns"].append({"table": n["table"], "column": c["column"], "meaning": c["meaning"],
|
|
108
|
+
**({"codes": codes[c["column"]]} if c["column"] in codes else {}),
|
|
109
|
+
"source": str(f), "verified": False})
|
|
110
|
+
seen.add(c["column"])
|
|
111
|
+
for col, cd in codes.items():
|
|
112
|
+
if col not in seen:
|
|
113
|
+
out["columns"].append({"table": n["table"], "column": col, "codes": cd, "source": str(f), "verified": False})
|
|
114
|
+
return out
|
keygraph/overrides.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""User overrides: confirm / reject / add edges, table aliases, role aliases, prefixes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Overrides:
|
|
12
|
+
aliases: dict[str, str] = field(default_factory=dict)
|
|
13
|
+
role_aliases: dict[str, list[str]] = field(default_factory=dict)
|
|
14
|
+
prefixes: list[str] = field(default_factory=list)
|
|
15
|
+
confirm: list[dict] = field(default_factory=list)
|
|
16
|
+
reject: list[dict] = field(default_factory=list)
|
|
17
|
+
add: list[dict] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def load(cls, path: str | Path) -> "Overrides":
|
|
21
|
+
p = Path(path)
|
|
22
|
+
text = p.read_text(encoding="utf-8")
|
|
23
|
+
if p.suffix.lower() in (".yaml", ".yml"):
|
|
24
|
+
try:
|
|
25
|
+
import yaml
|
|
26
|
+
except ImportError as exc: # pragma: no cover
|
|
27
|
+
raise SystemExit("YAML needs PyYAML: pip install 'keygraph[yaml]' (or use a .json file)") from exc
|
|
28
|
+
raw = yaml.safe_load(text) or {}
|
|
29
|
+
else:
|
|
30
|
+
raw = json.loads(text)
|
|
31
|
+
ra = {k: ([v] if isinstance(v, str) else list(v)) for k, v in (raw.get("role_aliases") or {}).items()}
|
|
32
|
+
return cls(aliases=dict(raw.get("aliases") or {}), role_aliases=ra, prefixes=list(raw.get("prefixes") or []),
|
|
33
|
+
confirm=list(raw.get("confirm") or []), reject=list(raw.get("reject") or []),
|
|
34
|
+
add=list(raw.get("add") or []))
|
|
35
|
+
|
|
36
|
+
def configure(self, opts) -> None:
|
|
37
|
+
opts.aliases.update(self.aliases)
|
|
38
|
+
opts.role_aliases.update(self.role_aliases)
|
|
39
|
+
opts.prefixes.extend(p for p in self.prefixes if p not in opts.prefixes)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_ref(ref: str) -> tuple[str, list[str]]:
|
|
43
|
+
"""'shop.orders.customer_id' or 'orders.a,b' -> (table ref, columns)."""
|
|
44
|
+
head, cols = ref.rsplit(".", 1)
|
|
45
|
+
return head, [c.strip() for c in cols.split(",") if c.strip()]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve(nodes: dict, head: str) -> str | None:
|
|
49
|
+
if head in nodes:
|
|
50
|
+
return head
|
|
51
|
+
hits = [n for n in nodes if n.split(".", 1)[1] == head]
|
|
52
|
+
return hits[0] if len(hits) == 1 else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_overrides(data: dict, ov: Overrides) -> None:
|
|
56
|
+
nodes = {n["id"]: n for n in data["nodes"]}
|
|
57
|
+
applied = {"confirmed": 0, "rejected": 0, "added": 0}
|
|
58
|
+
notes = data["warnings"]
|
|
59
|
+
|
|
60
|
+
def locate(item):
|
|
61
|
+
fh, fc = parse_ref(item["from"])
|
|
62
|
+
ft = _resolve(nodes, fh)
|
|
63
|
+
if item.get("to"):
|
|
64
|
+
th, tc = parse_ref(item["to"])
|
|
65
|
+
tt = _resolve(nodes, th)
|
|
66
|
+
else:
|
|
67
|
+
tt, tc = None, []
|
|
68
|
+
return ft, fc, tt, tc
|
|
69
|
+
|
|
70
|
+
for item in ov.reject:
|
|
71
|
+
ft, fc, tt, tc = locate(item)
|
|
72
|
+
before = len(data["edges"])
|
|
73
|
+
data["edges"] = [e for e in data["edges"] if not (
|
|
74
|
+
e["kind"] == "inferred" and e["from"] == ft and e["from_columns"] == fc
|
|
75
|
+
and (tt is None or (e["to"] == tt and (not tc or e["to_columns"] == tc))))]
|
|
76
|
+
applied["rejected"] += before - len(data["edges"])
|
|
77
|
+
if ft is None:
|
|
78
|
+
notes.append(f"override reject: unknown table in {item['from']}")
|
|
79
|
+
for kind, items, conf in (("confirmed", ov.confirm, 0.98), ("added", ov.add, 0.9)):
|
|
80
|
+
for item in items:
|
|
81
|
+
ft, fc, tt, tc = locate(item)
|
|
82
|
+
if not (ft and tt):
|
|
83
|
+
notes.append(f"override {kind}: unknown table in {item}")
|
|
84
|
+
continue
|
|
85
|
+
have = {c["name"] for c in nodes[ft]["columns"]}, {c["name"] for c in nodes[tt]["columns"]}
|
|
86
|
+
if not (set(fc) <= have[0] and set(tc) <= have[1]) or not tc:
|
|
87
|
+
notes.append(f"override {kind}: unknown column in {item}")
|
|
88
|
+
continue
|
|
89
|
+
note = item.get("note", "")
|
|
90
|
+
match = [e for e in data["edges"] if e["from"] == ft and e["from_columns"] == fc and e["kind"] == "inferred"]
|
|
91
|
+
hit = [e for e in match if e["to"] == tt and e["to_columns"] == tc]
|
|
92
|
+
if kind == "confirmed" and hit:
|
|
93
|
+
data["edges"] = [e for e in data["edges"] if e not in match or e is hit[0]]
|
|
94
|
+
e = hit[0]
|
|
95
|
+
e["status"], e["confidence"], e["ambiguous"] = "confirmed", conf, False
|
|
96
|
+
e["evidence"].append("confirmed by user override" + (f": {note}" if note else ""))
|
|
97
|
+
else:
|
|
98
|
+
if kind == "confirmed":
|
|
99
|
+
data["edges"] = [e for e in data["edges"] if e not in match]
|
|
100
|
+
data["edges"] = [e for e in data["edges"] if not (
|
|
101
|
+
e["from"] == ft and e["from_columns"] == fc and e["to"] == tt and e["to_columns"] == tc)]
|
|
102
|
+
fk = nodes[ft]
|
|
103
|
+
from .infer import cardinality
|
|
104
|
+
from .model import Table, Index
|
|
105
|
+
t = Table(ft.split(".")[0], fk["table"], [], [Index(i["name"], i["columns"], i["unique"], i["primary"]) for i in fk["indexes"]])
|
|
106
|
+
data["edges"].append({
|
|
107
|
+
"kind": "inferred", "status": kind, "from": ft, "from_columns": fc, "to": tt, "to_columns": tc,
|
|
108
|
+
"confidence": conf, "cardinality": cardinality(t, fc), "ambiguous": False,
|
|
109
|
+
"evidence": [f"{'confirmed' if kind == 'confirmed' else 'added'} by user override" + (f": {note}" if note else "")],
|
|
110
|
+
})
|
|
111
|
+
applied[kind] += 1
|
|
112
|
+
data["overrides"] = applied
|
keygraph/probe.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Read-only value-overlap probe for inferred edges. Refuses anything that is not a single SELECT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .graph import Graph
|
|
10
|
+
|
|
11
|
+
_BAD = re.compile(r"\b(insert|update|delete|replace|drop|alter|create|truncate|grant|revoke|rename|call|handler|load|"
|
|
12
|
+
r"lock|unlock|merge|exec|execute|set|use|copy|vacuum|attach|pragma)\b|\binto\s+(outfile|dumpfile)\b|"
|
|
13
|
+
r"\bfor\s+update\b", re.I)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsafeSQL(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def assert_select(sql: str) -> str:
|
|
21
|
+
s = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
|
|
22
|
+
s = re.sub(r"--[^\n]*", " ", s).strip()
|
|
23
|
+
if s.endswith(";"):
|
|
24
|
+
s = s[:-1].strip()
|
|
25
|
+
if not s:
|
|
26
|
+
raise UnsafeSQL("empty statement")
|
|
27
|
+
if ";" in s:
|
|
28
|
+
raise UnsafeSQL("only one statement is allowed")
|
|
29
|
+
if not re.match(r"select\b", s, re.I):
|
|
30
|
+
raise UnsafeSQL("only SELECT statements are allowed")
|
|
31
|
+
stripped = re.sub(r"'(?:[^']|'')*'", "''", s)
|
|
32
|
+
m = _BAD.search(stripped)
|
|
33
|
+
if m:
|
|
34
|
+
raise UnsafeSQL(f"forbidden keyword in query: {m.group(0)}")
|
|
35
|
+
return s
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SelectOnlyRunner:
|
|
39
|
+
def __init__(self, execute: Callable[[str], list]):
|
|
40
|
+
self._execute = execute
|
|
41
|
+
|
|
42
|
+
def run(self, sql: str) -> list:
|
|
43
|
+
return list(self._execute(assert_select(sql)))
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_url(cls, url: str) -> "SelectOnlyRunner":
|
|
47
|
+
from sqlalchemy import create_engine, text
|
|
48
|
+
eng = create_engine(url)
|
|
49
|
+
|
|
50
|
+
def ex(sql):
|
|
51
|
+
with eng.connect() as c:
|
|
52
|
+
return [tuple(r) for r in c.execute(text(sql))]
|
|
53
|
+
return cls(ex)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dbapi(cls, conn) -> "SelectOnlyRunner":
|
|
57
|
+
def ex(sql):
|
|
58
|
+
cur = conn.cursor()
|
|
59
|
+
try:
|
|
60
|
+
cur.execute(sql)
|
|
61
|
+
return cur.fetchall()
|
|
62
|
+
finally:
|
|
63
|
+
cur.close()
|
|
64
|
+
return cls(ex)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _lit(v) -> str | None:
|
|
68
|
+
if isinstance(v, bool):
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(v, int):
|
|
71
|
+
return str(v)
|
|
72
|
+
if isinstance(v, Decimal) and v == v.to_integral_value():
|
|
73
|
+
return str(int(v))
|
|
74
|
+
if isinstance(v, str) and "\\" not in v and "\x00" not in v:
|
|
75
|
+
return "'" + v.replace("'", "''") + "'"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def probe_graph(graph: Graph, runner: SelectOnlyRunner, sample: int = 500, quote: str = '"',
|
|
80
|
+
max_edges: int = 200, min_conf: float = 0.0, statuses=("inferred",), profiles: dict | None = None) -> dict:
|
|
81
|
+
q = lambda name: f"{quote}{name}{quote}"
|
|
82
|
+
qt = lambda tid: ".".join(q(p) for p in tid.split(".", 1)) if not tid.startswith("main.") else q(tid.split(".", 1)[1])
|
|
83
|
+
res = {"probed": 0, "skipped": 0, "errors": []}
|
|
84
|
+
for e in graph.edges:
|
|
85
|
+
if e["status"] not in statuses or e["confidence"] < min_conf or res["probed"] >= max_edges:
|
|
86
|
+
continue
|
|
87
|
+
if len(e["from_columns"]) != 1:
|
|
88
|
+
res["skipped"] += 1
|
|
89
|
+
continue
|
|
90
|
+
fc, tc = e["from_columns"][0], e["to_columns"][0]
|
|
91
|
+
pf, pt = (profiles or {}).get(f"{e['from']}.{fc}", {}).get("numeric"), (profiles or {}).get(f"{e['to']}.{tc}", {}).get("numeric")
|
|
92
|
+
if pf and pt and (pf["min"] > pt["max"] or pf["max"] < pt["min"]):
|
|
93
|
+
e["evidence"].append(f"profile: value ranges do not overlap ({pf['min']:g}..{pf['max']:g} vs {pt['min']:g}..{pt['max']:g})")
|
|
94
|
+
e["confidence"] = round(e["confidence"] * 0.3, 2)
|
|
95
|
+
e["status"] = "probed"
|
|
96
|
+
res["probed"] += 1
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
vals = [r[0] for r in runner.run(
|
|
100
|
+
f"SELECT {q(fc)} FROM {qt(e['from'])} WHERE {q(fc)} IS NOT NULL LIMIT {int(sample)}")]
|
|
101
|
+
lits = sorted({l for l in map(_lit, vals) if l is not None})
|
|
102
|
+
if not lits:
|
|
103
|
+
res["skipped"] += 1
|
|
104
|
+
continue
|
|
105
|
+
found = runner.run(f"SELECT {q(tc)} FROM {qt(e['to'])} WHERE {q(tc)} IN ({','.join(lits)}) LIMIT {len(lits)}")
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
res["errors"].append(f"{e['id']}: {exc}")
|
|
108
|
+
continue
|
|
109
|
+
rate = len({r[0] for r in found}) / len(lits)
|
|
110
|
+
e["match_rate"] = round(rate, 3)
|
|
111
|
+
e["evidence"].append(f"probe: {rate:.0%} of {len(lits)} sampled distinct values of {e['from']}.{fc} exist in {e['to']}.{tc}")
|
|
112
|
+
c = e["confidence"]
|
|
113
|
+
c = min(0.99, c + 0.25) if rate >= 0.95 else min(0.99, c + 0.10) if rate >= 0.8 else c if rate >= 0.5 else \
|
|
114
|
+
min(c, 0.1) if rate == 0 else c * 0.5
|
|
115
|
+
e["confidence"] = round(c, 2)
|
|
116
|
+
e["status"] = "probed"
|
|
117
|
+
res["probed"] += 1
|
|
118
|
+
graph.refresh_stats()
|
|
119
|
+
return res
|