pretensor 0.1.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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Generate a compact Claude/Cursor skill markdown from a Kuzu graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pretensor.core.registry import RegistryEntry
|
|
10
|
+
from pretensor.core.store import KuzuStore
|
|
11
|
+
from pretensor.mcp.server import _build_oss_registry
|
|
12
|
+
|
|
13
|
+
__all__ = ["SkillGenerator", "write_skill_files_for_index"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _render_tool_lines() -> list[str]:
|
|
17
|
+
# Source the canonical tool list from the MCP registry so the skill body
|
|
18
|
+
# never drifts from `pretensor serve`. Handlers are closures that this code
|
|
19
|
+
# never invokes; the placeholder Path is only captured, never read.
|
|
20
|
+
registry = _build_oss_registry(Path("."))
|
|
21
|
+
out: list[str] = []
|
|
22
|
+
for tool in registry.list_tools():
|
|
23
|
+
desc = (tool.description or "").strip().split("\n", 1)[0]
|
|
24
|
+
first_sentence = desc.split(". ", 1)[0].rstrip(".")
|
|
25
|
+
if first_sentence:
|
|
26
|
+
out.append(f"- `{tool.name}` — {first_sentence}")
|
|
27
|
+
else:
|
|
28
|
+
out.append(f"- `{tool.name}`")
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_skill_files_for_index(
|
|
33
|
+
*,
|
|
34
|
+
body: str,
|
|
35
|
+
connection_name: str,
|
|
36
|
+
cwd: Path | None = None,
|
|
37
|
+
skills_target: str = "claude",
|
|
38
|
+
) -> list[Path]:
|
|
39
|
+
"""Write skill markdown to one or more paths (``--skills-target``)."""
|
|
40
|
+
root = (cwd or Path.cwd()).resolve()
|
|
41
|
+
safe = connection_name.replace("/", "_")
|
|
42
|
+
key = skills_target.strip().lower() if skills_target.strip() else "claude"
|
|
43
|
+
out: list[Path] = []
|
|
44
|
+
|
|
45
|
+
def _write_claude() -> Path:
|
|
46
|
+
out_dir = root / ".claude" / "skills" / f"pretensor-{safe}"
|
|
47
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
path = out_dir / "SKILL.md"
|
|
49
|
+
path.write_text(body, encoding="utf-8")
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
def _write_cursor() -> Path:
|
|
53
|
+
out_dir = root / ".cursor" / "rules"
|
|
54
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
path = out_dir / f"pretensor-{safe}.mdc"
|
|
56
|
+
path.write_text(body, encoding="utf-8")
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
if key in ("claude", ""):
|
|
60
|
+
out.append(_write_claude())
|
|
61
|
+
elif key == "cursor":
|
|
62
|
+
out.append(_write_cursor())
|
|
63
|
+
elif key == "all":
|
|
64
|
+
out.append(_write_claude())
|
|
65
|
+
out.append(_write_cursor())
|
|
66
|
+
else:
|
|
67
|
+
custom = Path(skills_target).expanduser()
|
|
68
|
+
if not custom.is_absolute():
|
|
69
|
+
custom = root / custom
|
|
70
|
+
custom.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
custom.write_text(body, encoding="utf-8")
|
|
72
|
+
out.append(custom)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class SkillGenerator:
|
|
78
|
+
"""Build `.claude/skills/pretensor-{db_name}/SKILL.md` after indexing."""
|
|
79
|
+
|
|
80
|
+
store: KuzuStore
|
|
81
|
+
connection_name: str
|
|
82
|
+
database: str
|
|
83
|
+
last_indexed_at: datetime
|
|
84
|
+
|
|
85
|
+
def render(self) -> str:
|
|
86
|
+
"""Return markdown body for the skill file."""
|
|
87
|
+
table_count, row_sum = self._table_stats()
|
|
88
|
+
entities = self._entity_map_lines()
|
|
89
|
+
top_joins = self._top_joins()
|
|
90
|
+
|
|
91
|
+
safe = self.connection_name.replace("/", "_")
|
|
92
|
+
skill_name = f"pretensor-{safe}"
|
|
93
|
+
skill_description = (
|
|
94
|
+
f"Pretensor graph for the `{self.database}` database "
|
|
95
|
+
f"(connection `{self.connection_name}`, {table_count} tables). "
|
|
96
|
+
"Use to look up tables, joins, and MCP query tools for this database."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
lines = [
|
|
100
|
+
"---",
|
|
101
|
+
f"name: {skill_name}",
|
|
102
|
+
f"description: {skill_description}",
|
|
103
|
+
"---",
|
|
104
|
+
"",
|
|
105
|
+
f"# Pretensor graph: {self.database}",
|
|
106
|
+
"",
|
|
107
|
+
"## Database overview",
|
|
108
|
+
"",
|
|
109
|
+
f"- **Connection name:** `{self.connection_name}`",
|
|
110
|
+
f"- **Logical database:** `{self.database}`",
|
|
111
|
+
f"- **Tables:** {table_count}",
|
|
112
|
+
f"- **Approx. row count (sum over tables):** {row_sum}",
|
|
113
|
+
f"- **Last indexed:** {self.last_indexed_at.isoformat()}",
|
|
114
|
+
"",
|
|
115
|
+
"## Key entities",
|
|
116
|
+
"",
|
|
117
|
+
]
|
|
118
|
+
if entities:
|
|
119
|
+
lines.extend(entities)
|
|
120
|
+
else:
|
|
121
|
+
lines.append(
|
|
122
|
+
"_No Entity nodes yet — run indexing with entity extraction enabled._"
|
|
123
|
+
)
|
|
124
|
+
lines.extend(
|
|
125
|
+
[
|
|
126
|
+
"",
|
|
127
|
+
"## MCP tools (Pretensor)",
|
|
128
|
+
"",
|
|
129
|
+
"Use the **pretensor** MCP server (`pretensor serve`):",
|
|
130
|
+
"",
|
|
131
|
+
*_render_tool_lines(),
|
|
132
|
+
"",
|
|
133
|
+
"Resources: `pretensor://databases`, `pretensor://db/{name}/overview`.",
|
|
134
|
+
"",
|
|
135
|
+
"## Common patterns",
|
|
136
|
+
"",
|
|
137
|
+
"_Placeholder — join-path and query patterns land in a later epic._",
|
|
138
|
+
"",
|
|
139
|
+
"## Top relationships (sample)",
|
|
140
|
+
"",
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
if top_joins:
|
|
144
|
+
lines.extend(top_joins)
|
|
145
|
+
else:
|
|
146
|
+
lines.append("_No join edges recorded._")
|
|
147
|
+
lines.append("")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
def _table_stats(self) -> tuple[int, int]:
|
|
151
|
+
rows = self.store.query_all_rows(
|
|
152
|
+
"MATCH (t:SchemaTable) RETURN count(*), sum(t.row_count)"
|
|
153
|
+
)
|
|
154
|
+
if not rows:
|
|
155
|
+
return 0, 0
|
|
156
|
+
c, s = rows[0]
|
|
157
|
+
tc = int(c) if c is not None else 0
|
|
158
|
+
rs = int(s) if s is not None else 0
|
|
159
|
+
return tc, rs
|
|
160
|
+
|
|
161
|
+
def _entity_map_lines(self) -> list[str]:
|
|
162
|
+
pairs = self.store.query_all_rows(
|
|
163
|
+
"""
|
|
164
|
+
MATCH (e:Entity)-[:REPRESENTS]->(t:SchemaTable)
|
|
165
|
+
RETURN e.name, t.schema_name, t.table_name
|
|
166
|
+
ORDER BY e.name, t.schema_name, t.table_name
|
|
167
|
+
"""
|
|
168
|
+
)
|
|
169
|
+
by_entity: dict[str, list[str]] = {}
|
|
170
|
+
for name, schema_name, table_name in pairs:
|
|
171
|
+
key = str(name)
|
|
172
|
+
qn = f"{schema_name}.{table_name}"
|
|
173
|
+
by_entity.setdefault(key, []).append(qn)
|
|
174
|
+
names_only = self.store.query_all_rows(
|
|
175
|
+
"MATCH (e:Entity) RETURN e.name ORDER BY e.name"
|
|
176
|
+
)
|
|
177
|
+
out: list[str] = []
|
|
178
|
+
for (n,) in names_only:
|
|
179
|
+
key = str(n)
|
|
180
|
+
tlist = sorted(set(by_entity.get(key, [])))
|
|
181
|
+
if tlist:
|
|
182
|
+
joined = ", ".join(tlist)
|
|
183
|
+
out.append(f"- **{key}** — {joined}")
|
|
184
|
+
else:
|
|
185
|
+
out.append(f"- **{key}**")
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
def _top_joins(self) -> list[str]:
|
|
189
|
+
fk = self.store.query_all_rows(
|
|
190
|
+
"""
|
|
191
|
+
MATCH (a:SchemaTable)-[:FK_REFERENCES]->(b:SchemaTable)
|
|
192
|
+
RETURN concat(a.schema_name, '.', a.table_name),
|
|
193
|
+
concat(b.schema_name, '.', b.table_name)
|
|
194
|
+
LIMIT 8
|
|
195
|
+
"""
|
|
196
|
+
)
|
|
197
|
+
inf = self.store.query_all_rows(
|
|
198
|
+
"""
|
|
199
|
+
MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
|
|
200
|
+
RETURN concat(a.schema_name, '.', a.table_name),
|
|
201
|
+
concat(b.schema_name, '.', b.table_name),
|
|
202
|
+
r.confidence
|
|
203
|
+
LIMIT 8
|
|
204
|
+
"""
|
|
205
|
+
)
|
|
206
|
+
lines: list[str] = []
|
|
207
|
+
for src, dst in fk:
|
|
208
|
+
lines.append(f"- `{src}` —[FK_REFERENCES]→ `{dst}`")
|
|
209
|
+
for src, dst, conf in inf:
|
|
210
|
+
conf_s = f" (confidence {float(conf):.2f})" if conf is not None else ""
|
|
211
|
+
lines.append(f"- `{src}` —[INFERRED_JOIN]→ `{dst}`{conf_s}")
|
|
212
|
+
return lines[:12]
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def write_for_index(
|
|
216
|
+
cls,
|
|
217
|
+
*,
|
|
218
|
+
store: KuzuStore,
|
|
219
|
+
entry: RegistryEntry,
|
|
220
|
+
cwd: Path | None = None,
|
|
221
|
+
skills_target: str = "claude",
|
|
222
|
+
) -> list[Path]:
|
|
223
|
+
"""Write skill file(s) per ``skills_target`` (see ``write_skill_files_for_index``)."""
|
|
224
|
+
body = cls(
|
|
225
|
+
store=store,
|
|
226
|
+
connection_name=entry.connection_name,
|
|
227
|
+
database=entry.database,
|
|
228
|
+
last_indexed_at=entry.last_indexed_at,
|
|
229
|
+
).render()
|
|
230
|
+
return write_skill_files_for_index(
|
|
231
|
+
body=body,
|
|
232
|
+
connection_name=entry.connection_name,
|
|
233
|
+
cwd=cwd,
|
|
234
|
+
skills_target=skills_target,
|
|
235
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Schema drift: snapshots, graph patching, and impact analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pretensor.staleness.graph_patcher import GraphPatcher, PatchResult
|
|
6
|
+
from pretensor.staleness.impact_analyzer import ImpactAnalyzer, ImpactReport
|
|
7
|
+
from pretensor.staleness.snapshot_store import SnapshotStore
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GraphPatcher",
|
|
11
|
+
"ImpactAnalyzer",
|
|
12
|
+
"ImpactReport",
|
|
13
|
+
"PatchResult",
|
|
14
|
+
"SnapshotStore",
|
|
15
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Apply :class:`SchemaChange` list to the Kuzu graph (incremental reindex)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from pretensor.connectors.models import Column, SchemaSnapshot, Table
|
|
9
|
+
from pretensor.connectors.snapshot import ChangeTarget, ChangeType, SchemaChange
|
|
10
|
+
from pretensor.core.ids import column_node_id, fk_edge_id, table_node_id
|
|
11
|
+
from pretensor.core.store import KuzuStore
|
|
12
|
+
from pretensor.graph_models.edge import GraphEdge
|
|
13
|
+
from pretensor.graph_models.node import GraphNode
|
|
14
|
+
|
|
15
|
+
__all__ = ["GraphPatcher", "PatchResult"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PatchResult:
|
|
20
|
+
"""Counts of mutations applied (or that would be applied in dry-run)."""
|
|
21
|
+
|
|
22
|
+
tables_added: int = 0
|
|
23
|
+
tables_removed: int = 0
|
|
24
|
+
columns_added: int = 0
|
|
25
|
+
columns_removed: int = 0
|
|
26
|
+
columns_updated: int = 0
|
|
27
|
+
fk_edges_removed: int = 0
|
|
28
|
+
inferred_joins_removed: int = 0
|
|
29
|
+
join_paths_marked_stale: int = 0
|
|
30
|
+
cluster_memberships_removed: int = 0
|
|
31
|
+
clusters_marked_stale: int = 0
|
|
32
|
+
foreign_keys_synced: int = 0
|
|
33
|
+
notes: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraphPatcher:
|
|
37
|
+
"""Translate pipeline :class:`SchemaChange` rows into Kuzu writes."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, store: KuzuStore) -> None:
|
|
40
|
+
self._store = store
|
|
41
|
+
|
|
42
|
+
def apply(
|
|
43
|
+
self,
|
|
44
|
+
changes: list[SchemaChange],
|
|
45
|
+
new_snapshot: SchemaSnapshot,
|
|
46
|
+
*,
|
|
47
|
+
dry_run: bool = False,
|
|
48
|
+
) -> PatchResult:
|
|
49
|
+
"""Apply changes using ``new_snapshot`` for full row data.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
changes: Output of :func:`pretensor.connectors.snapshot.diff_snapshots`.
|
|
53
|
+
new_snapshot: Fresh introspection result (source of truth for adds/updates).
|
|
54
|
+
dry_run: If True, compute counts only (no graph writes).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Summary of operations.
|
|
58
|
+
"""
|
|
59
|
+
self._store.ensure_schema()
|
|
60
|
+
result = PatchResult()
|
|
61
|
+
tables_by_key = {(t.schema_name, t.name): t for t in new_snapshot.tables}
|
|
62
|
+
connection_name = new_snapshot.connection_name
|
|
63
|
+
database_key = new_snapshot.database
|
|
64
|
+
|
|
65
|
+
def invalidate_table(schema_name: str, table_name: str) -> None:
|
|
66
|
+
tid = table_node_id(connection_name, schema_name, table_name)
|
|
67
|
+
if dry_run:
|
|
68
|
+
return
|
|
69
|
+
self._store.mark_join_paths_stale_for_table(tid, database_key)
|
|
70
|
+
self._store.remove_table_from_clusters(tid)
|
|
71
|
+
self._store.mark_clusters_stale_for_table(tid, database_key)
|
|
72
|
+
|
|
73
|
+
for ch in changes:
|
|
74
|
+
key = (ch.schema_name, ch.table_name)
|
|
75
|
+
if ch.change_type == ChangeType.ADDED and ch.target == ChangeTarget.TABLE:
|
|
76
|
+
tbl = tables_by_key.get(key)
|
|
77
|
+
if tbl is None:
|
|
78
|
+
result.notes.append(f"skip ADDED table missing in snapshot: {key}")
|
|
79
|
+
continue
|
|
80
|
+
if dry_run:
|
|
81
|
+
result.tables_added += 1
|
|
82
|
+
result.columns_added += len(tbl.columns)
|
|
83
|
+
continue
|
|
84
|
+
invalidate_table(ch.schema_name, ch.table_name)
|
|
85
|
+
grants_json = (
|
|
86
|
+
json.dumps(tbl.grants, ensure_ascii=False) if tbl.grants else None
|
|
87
|
+
)
|
|
88
|
+
node = GraphNode(
|
|
89
|
+
node_id=table_node_id(connection_name, tbl.schema_name, tbl.name),
|
|
90
|
+
connection_name=connection_name,
|
|
91
|
+
database=new_snapshot.database,
|
|
92
|
+
schema_name=tbl.schema_name,
|
|
93
|
+
table_name=tbl.name,
|
|
94
|
+
row_count=tbl.row_count,
|
|
95
|
+
comment=tbl.comment,
|
|
96
|
+
table_type=tbl.table_type,
|
|
97
|
+
seq_scan_count=tbl.seq_scan_count,
|
|
98
|
+
idx_scan_count=tbl.idx_scan_count,
|
|
99
|
+
insert_count=tbl.insert_count,
|
|
100
|
+
update_count=tbl.update_count,
|
|
101
|
+
delete_count=tbl.delete_count,
|
|
102
|
+
is_partitioned=tbl.is_partitioned,
|
|
103
|
+
partition_key=tbl.partition_key,
|
|
104
|
+
grants_json=grants_json,
|
|
105
|
+
access_read_count=tbl.access_read_count,
|
|
106
|
+
access_write_count=tbl.access_write_count,
|
|
107
|
+
days_since_last_access=tbl.days_since_last_access,
|
|
108
|
+
potentially_unused=tbl.potentially_unused,
|
|
109
|
+
table_bytes=tbl.table_bytes,
|
|
110
|
+
clustering_key=tbl.clustering_key,
|
|
111
|
+
)
|
|
112
|
+
self._store.upsert_table(node)
|
|
113
|
+
for col in tbl.columns:
|
|
114
|
+
self._upsert_column(
|
|
115
|
+
connection_name, new_snapshot.database, tbl, col
|
|
116
|
+
)
|
|
117
|
+
result.tables_added += 1
|
|
118
|
+
result.columns_added += len(tbl.columns)
|
|
119
|
+
|
|
120
|
+
elif (
|
|
121
|
+
ch.change_type == ChangeType.REMOVED and ch.target == ChangeTarget.TABLE
|
|
122
|
+
):
|
|
123
|
+
tid = table_node_id(connection_name, ch.schema_name, ch.table_name)
|
|
124
|
+
if dry_run:
|
|
125
|
+
result.tables_removed += 1
|
|
126
|
+
continue
|
|
127
|
+
self._store.delete_join_paths_for_table(tid, database_key)
|
|
128
|
+
self._store.remove_table_from_clusters(tid)
|
|
129
|
+
self._store.mark_clusters_stale_for_table(tid, database_key)
|
|
130
|
+
self._store.delete_table_node_cascade(tid)
|
|
131
|
+
result.tables_removed += 1
|
|
132
|
+
|
|
133
|
+
elif (
|
|
134
|
+
ch.change_type == ChangeType.ADDED and ch.target == ChangeTarget.COLUMN
|
|
135
|
+
):
|
|
136
|
+
tbl = tables_by_key.get(key)
|
|
137
|
+
if tbl is None or not ch.column_name:
|
|
138
|
+
continue
|
|
139
|
+
col = next((c for c in tbl.columns if c.name == ch.column_name), None)
|
|
140
|
+
if col is None:
|
|
141
|
+
continue
|
|
142
|
+
if dry_run:
|
|
143
|
+
result.columns_added += 1
|
|
144
|
+
continue
|
|
145
|
+
invalidate_table(ch.schema_name, ch.table_name)
|
|
146
|
+
self._upsert_column(connection_name, new_snapshot.database, tbl, col)
|
|
147
|
+
result.columns_added += 1
|
|
148
|
+
|
|
149
|
+
elif (
|
|
150
|
+
ch.change_type == ChangeType.REMOVED
|
|
151
|
+
and ch.target == ChangeTarget.COLUMN
|
|
152
|
+
):
|
|
153
|
+
if not ch.column_name:
|
|
154
|
+
continue
|
|
155
|
+
tid = table_node_id(connection_name, ch.schema_name, ch.table_name)
|
|
156
|
+
cid = column_node_id(
|
|
157
|
+
connection_name, ch.schema_name, ch.table_name, ch.column_name
|
|
158
|
+
)
|
|
159
|
+
if dry_run:
|
|
160
|
+
result.columns_removed += 1
|
|
161
|
+
result.fk_edges_removed += self._count_fk_touching(
|
|
162
|
+
tid, ch.column_name
|
|
163
|
+
)
|
|
164
|
+
result.inferred_joins_removed += self._count_inferred_touching(
|
|
165
|
+
tid, ch.column_name
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
invalidate_table(ch.schema_name, ch.table_name)
|
|
169
|
+
result.fk_edges_removed += self._store.delete_fk_edges_touching_column(
|
|
170
|
+
tid, ch.column_name
|
|
171
|
+
)
|
|
172
|
+
result.inferred_joins_removed += (
|
|
173
|
+
self._store.delete_inferred_joins_touching_column(
|
|
174
|
+
tid, ch.column_name
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
self._store.delete_column_node(cid)
|
|
178
|
+
result.columns_removed += 1
|
|
179
|
+
|
|
180
|
+
elif (
|
|
181
|
+
ch.change_type == ChangeType.MODIFIED
|
|
182
|
+
and ch.target == ChangeTarget.COLUMN
|
|
183
|
+
):
|
|
184
|
+
tbl = tables_by_key.get(key)
|
|
185
|
+
if tbl is None or not ch.column_name:
|
|
186
|
+
continue
|
|
187
|
+
col = next((c for c in tbl.columns if c.name == ch.column_name), None)
|
|
188
|
+
if col is None:
|
|
189
|
+
continue
|
|
190
|
+
if dry_run:
|
|
191
|
+
result.columns_updated += 1
|
|
192
|
+
continue
|
|
193
|
+
invalidate_table(ch.schema_name, ch.table_name)
|
|
194
|
+
self._upsert_column(connection_name, new_snapshot.database, tbl, col)
|
|
195
|
+
result.columns_updated += 1
|
|
196
|
+
|
|
197
|
+
elif (
|
|
198
|
+
ch.change_type == ChangeType.MODIFIED
|
|
199
|
+
and ch.target == ChangeTarget.TABLE
|
|
200
|
+
):
|
|
201
|
+
tbl = tables_by_key.get(key)
|
|
202
|
+
if tbl is None:
|
|
203
|
+
continue
|
|
204
|
+
if dry_run:
|
|
205
|
+
result.columns_updated += len(tbl.columns)
|
|
206
|
+
continue
|
|
207
|
+
invalidate_table(ch.schema_name, ch.table_name)
|
|
208
|
+
grants_json = (
|
|
209
|
+
json.dumps(tbl.grants, ensure_ascii=False) if tbl.grants else None
|
|
210
|
+
)
|
|
211
|
+
node = GraphNode(
|
|
212
|
+
node_id=table_node_id(connection_name, tbl.schema_name, tbl.name),
|
|
213
|
+
connection_name=connection_name,
|
|
214
|
+
database=new_snapshot.database,
|
|
215
|
+
schema_name=tbl.schema_name,
|
|
216
|
+
table_name=tbl.name,
|
|
217
|
+
row_count=tbl.row_count,
|
|
218
|
+
comment=tbl.comment,
|
|
219
|
+
table_type=tbl.table_type,
|
|
220
|
+
seq_scan_count=tbl.seq_scan_count,
|
|
221
|
+
idx_scan_count=tbl.idx_scan_count,
|
|
222
|
+
insert_count=tbl.insert_count,
|
|
223
|
+
update_count=tbl.update_count,
|
|
224
|
+
delete_count=tbl.delete_count,
|
|
225
|
+
is_partitioned=tbl.is_partitioned,
|
|
226
|
+
partition_key=tbl.partition_key,
|
|
227
|
+
grants_json=grants_json,
|
|
228
|
+
access_read_count=tbl.access_read_count,
|
|
229
|
+
access_write_count=tbl.access_write_count,
|
|
230
|
+
days_since_last_access=tbl.days_since_last_access,
|
|
231
|
+
potentially_unused=tbl.potentially_unused,
|
|
232
|
+
table_bytes=tbl.table_bytes,
|
|
233
|
+
clustering_key=tbl.clustering_key,
|
|
234
|
+
)
|
|
235
|
+
self._store.upsert_table(node)
|
|
236
|
+
for col in tbl.columns:
|
|
237
|
+
self._upsert_column(
|
|
238
|
+
connection_name, new_snapshot.database, tbl, col
|
|
239
|
+
)
|
|
240
|
+
result.columns_updated += len(tbl.columns)
|
|
241
|
+
|
|
242
|
+
if not dry_run:
|
|
243
|
+
result.foreign_keys_synced = self._sync_foreign_keys(new_snapshot)
|
|
244
|
+
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
def _upsert_column(
|
|
248
|
+
self,
|
|
249
|
+
connection_name: str,
|
|
250
|
+
database: str,
|
|
251
|
+
table: Table,
|
|
252
|
+
col: Column,
|
|
253
|
+
) -> None:
|
|
254
|
+
tid = table_node_id(connection_name, table.schema_name, table.name)
|
|
255
|
+
cid = column_node_id(connection_name, table.schema_name, table.name, col.name)
|
|
256
|
+
mcv_json = (
|
|
257
|
+
json.dumps(col.most_common_values, ensure_ascii=False)
|
|
258
|
+
if col.most_common_values
|
|
259
|
+
else None
|
|
260
|
+
)
|
|
261
|
+
hb_json = (
|
|
262
|
+
json.dumps(col.histogram_bounds, ensure_ascii=False)
|
|
263
|
+
if col.histogram_bounds
|
|
264
|
+
else None
|
|
265
|
+
)
|
|
266
|
+
self._store.upsert_column_for_table(
|
|
267
|
+
column_node_id=cid,
|
|
268
|
+
connection_name=connection_name,
|
|
269
|
+
database=database,
|
|
270
|
+
schema_name=table.schema_name,
|
|
271
|
+
table_name=table.name,
|
|
272
|
+
column_name=col.name,
|
|
273
|
+
data_type=col.data_type,
|
|
274
|
+
nullable=col.nullable,
|
|
275
|
+
is_primary_key=col.is_primary_key,
|
|
276
|
+
is_foreign_key=col.is_foreign_key,
|
|
277
|
+
table_node_id=tid,
|
|
278
|
+
comment=col.comment,
|
|
279
|
+
default_value=col.default_value,
|
|
280
|
+
is_indexed=col.is_indexed,
|
|
281
|
+
check_constraints=col.check_constraints,
|
|
282
|
+
ordinal_position=col.ordinal_position,
|
|
283
|
+
most_common_values_json=mcv_json,
|
|
284
|
+
histogram_bounds_json=hb_json,
|
|
285
|
+
stats_correlation=col.stats_correlation,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _sync_foreign_keys(self, snapshot: SchemaSnapshot) -> int:
|
|
289
|
+
"""Replace FK edges with the set implied by ``snapshot``."""
|
|
290
|
+
known = {
|
|
291
|
+
table_node_id(snapshot.connection_name, t.schema_name, t.name)
|
|
292
|
+
for t in snapshot.tables
|
|
293
|
+
}
|
|
294
|
+
new_edge_ids: set[str] = set()
|
|
295
|
+
edges: list[GraphEdge] = []
|
|
296
|
+
for t in snapshot.tables:
|
|
297
|
+
src_id = table_node_id(snapshot.connection_name, t.schema_name, t.name)
|
|
298
|
+
for fk in t.foreign_keys:
|
|
299
|
+
dst_id = table_node_id(
|
|
300
|
+
snapshot.connection_name, fk.target_schema, fk.target_table
|
|
301
|
+
)
|
|
302
|
+
if src_id not in known or dst_id not in known:
|
|
303
|
+
continue
|
|
304
|
+
edge = GraphEdge(
|
|
305
|
+
edge_id=fk_edge_id(fk),
|
|
306
|
+
source_node_id=src_id,
|
|
307
|
+
target_node_id=dst_id,
|
|
308
|
+
source_column=fk.source_column,
|
|
309
|
+
target_column=fk.target_column,
|
|
310
|
+
constraint_name=fk.constraint_name,
|
|
311
|
+
)
|
|
312
|
+
new_edge_ids.add(edge.edge_id)
|
|
313
|
+
edges.append(edge)
|
|
314
|
+
|
|
315
|
+
rows = self._store.query_all_rows(
|
|
316
|
+
"""
|
|
317
|
+
MATCH (src:SchemaTable)-[r:FK_REFERENCES]->(dst:SchemaTable)
|
|
318
|
+
WHERE src.connection_name = $cn
|
|
319
|
+
RETURN r.edge_id
|
|
320
|
+
""",
|
|
321
|
+
{"cn": snapshot.connection_name},
|
|
322
|
+
)
|
|
323
|
+
old_ids = {str(r[0]) for r in rows if r[0] is not None}
|
|
324
|
+
for eid in old_ids - new_edge_ids:
|
|
325
|
+
self._store.execute(
|
|
326
|
+
"MATCH ()-[r:FK_REFERENCES {edge_id: $eid}]->() DELETE r",
|
|
327
|
+
{"eid": eid},
|
|
328
|
+
)
|
|
329
|
+
for edge in edges:
|
|
330
|
+
self._store.merge_fk_edge(edge)
|
|
331
|
+
return len(edges)
|
|
332
|
+
|
|
333
|
+
def _count_fk_touching(self, table_node_id: str, column_name: str) -> int:
|
|
334
|
+
rows = self._store.query_all_rows(
|
|
335
|
+
"""
|
|
336
|
+
MATCH (src:SchemaTable)-[r:FK_REFERENCES]->(dst:SchemaTable)
|
|
337
|
+
WHERE (src.node_id = $tid AND r.source_column = $col)
|
|
338
|
+
OR (dst.node_id = $tid AND r.target_column = $col)
|
|
339
|
+
RETURN count(*)
|
|
340
|
+
""",
|
|
341
|
+
{"tid": table_node_id, "col": column_name},
|
|
342
|
+
)
|
|
343
|
+
return int(rows[0][0]) if rows and rows[0][0] is not None else 0
|
|
344
|
+
|
|
345
|
+
def _count_inferred_touching(self, table_node_id: str, column_name: str) -> int:
|
|
346
|
+
rows = self._store.query_all_rows(
|
|
347
|
+
"""
|
|
348
|
+
MATCH (src:SchemaTable)-[r:INFERRED_JOIN]->(dst:SchemaTable)
|
|
349
|
+
WHERE (src.node_id = $tid AND r.source_column = $col)
|
|
350
|
+
OR (dst.node_id = $tid AND r.target_column = $col)
|
|
351
|
+
RETURN count(*)
|
|
352
|
+
""",
|
|
353
|
+
{"tid": table_node_id, "col": column_name},
|
|
354
|
+
)
|
|
355
|
+
return int(rows[0][0]) if rows and rows[0][0] is not None else 0
|