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.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. 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