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,321 @@
1
+ """Full-schema introspection via the connector registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ from pretensor.connectors.base import ForeignKeyInfo
12
+ from pretensor.connectors.models import (
13
+ Column,
14
+ ForeignKey,
15
+ SchemaSnapshot,
16
+ Table,
17
+ ViewDependency,
18
+ )
19
+ from pretensor.connectors.registry import get_connector
20
+ from pretensor.introspection.models.config import ConnectionConfig
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _PROFILE_INDEX = os.environ.get("PRETENSOR_PROFILE_INDEX", "").lower() not in (
25
+ "",
26
+ "0",
27
+ "false",
28
+ "no",
29
+ )
30
+
31
+ LOW_CARDINALITY_THRESHOLD = 50
32
+ SKIP_STATS_ROW_THRESHOLD = 0
33
+ LARGE_TABLE_ROW_THRESHOLD = 10_000_000
34
+
35
+ # Column types whose values cannot be aggregated with MIN/MAX or cast to text
36
+ # in a meaningful way for stats collection. Pre-filtering avoids noisy
37
+ # "Failed to collect stats" warnings on benign type mismatches.
38
+ _STATS_UNSUPPORTED_TYPES: frozenset[str] = frozenset({
39
+ "boolean",
40
+ "bool",
41
+ "bytea",
42
+ "tsvector",
43
+ "tsquery",
44
+ "xml",
45
+ "json",
46
+ "point",
47
+ "line",
48
+ "lseg",
49
+ "box",
50
+ "path",
51
+ "polygon",
52
+ "circle",
53
+ "pg_lsn",
54
+ "txid_snapshot",
55
+ })
56
+
57
+
58
+ def _index_foreign_keys(
59
+ fks: list[ForeignKeyInfo],
60
+ ) -> dict[tuple[str, str], list[ForeignKeyInfo]]:
61
+ index: dict[tuple[str, str], list[ForeignKeyInfo]] = {}
62
+ for fk in fks:
63
+ key = (fk.source_schema, fk.source_table)
64
+ index.setdefault(key, []).append(fk)
65
+ return index
66
+
67
+
68
+ def _fk_columns_for_table(
69
+ fk_index: dict[tuple[str, str], list[ForeignKeyInfo]],
70
+ schema_name: str,
71
+ table_name: str,
72
+ ) -> set[str]:
73
+ return {fk.source_column for fk in fk_index.get((schema_name, table_name), [])}
74
+
75
+
76
+ def inspect(config: ConnectionConfig) -> SchemaSnapshot:
77
+ """Run full schema introspection and return a :class:`SchemaSnapshot`.
78
+
79
+ Dispatches to the connector registered for ``config.type``.
80
+
81
+ Args:
82
+ config: Database connection configuration.
83
+
84
+ Returns:
85
+ Tables, columns, FKs, and optional column statistics.
86
+ """
87
+ start = time.monotonic()
88
+ connector = get_connector(config)
89
+
90
+ with connector:
91
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
92
+ tables_info = connector.get_tables()
93
+ if _PROFILE_INDEX:
94
+ print(
95
+ f"[profile] inspect.get_tables: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(tables_info)} tables)",
96
+ flush=True,
97
+ )
98
+ logger.info("Discovered %d tables", len(tables_info))
99
+
100
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
101
+ all_fks = connector.get_foreign_keys()
102
+ if _PROFILE_INDEX:
103
+ print(
104
+ f"[profile] inspect.get_foreign_keys: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(all_fks)} fks)",
105
+ flush=True,
106
+ )
107
+ fk_index = _index_foreign_keys(all_fks)
108
+ sf = config.schema_filter
109
+ try:
110
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
111
+ table_catalog_extra, column_catalog_extra = connector.load_deep_catalog(sf)
112
+ if _PROFILE_INDEX:
113
+ print(
114
+ f"[profile] inspect.load_deep_catalog: {(time.perf_counter() - _t) * 1000:.0f}ms",
115
+ flush=True,
116
+ )
117
+ except Exception:
118
+ logger.debug("Deep catalog enrichment failed; continuing without it")
119
+ table_catalog_extra, column_catalog_extra = {}, {}
120
+
121
+ schemas_seen: set[str] = set()
122
+ tables: list[Table] = []
123
+
124
+ # Per-table loop instrumentation accumulators
125
+ _columns_total = 0.0
126
+ _stats_total = 0.0
127
+ _stats_calls = 0
128
+ _stats_max = 0.0
129
+ _stats_max_target = ""
130
+ _loop_start = time.perf_counter() if _PROFILE_INDEX else 0.0
131
+
132
+ for idx, table_info in enumerate(tables_info, 1):
133
+ tname = table_info.name
134
+ sname = table_info.schema_name
135
+ schemas_seen.add(sname)
136
+
137
+ logger.info(
138
+ "Introspecting [%d/%d] %s.%s",
139
+ idx,
140
+ len(tables_info),
141
+ sname,
142
+ tname,
143
+ )
144
+ if _PROFILE_INDEX:
145
+ print(
146
+ f"[profile] inspect.table[{idx}/{len(tables_info)}] {sname}.{tname} (row_count={table_info.row_count})",
147
+ flush=True,
148
+ )
149
+
150
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
151
+ columns_info = connector.get_columns(tname, sname)
152
+ if _PROFILE_INDEX:
153
+ _columns_total += time.perf_counter() - _t
154
+ row_count = table_info.row_count
155
+
156
+ fk_columns = _fk_columns_for_table(fk_index, sname, tname)
157
+
158
+ columns: list[Column] = []
159
+ for col_info in columns_info:
160
+ stats_kwargs: dict[str, Any] = {}
161
+ cat_col = column_catalog_extra.get((sname, tname, col_info.name))
162
+
163
+ col_type = (col_info.data_type or "").lower()
164
+ should_collect_stats = (
165
+ row_count is not None
166
+ and row_count > SKIP_STATS_ROW_THRESHOLD
167
+ and row_count < LARGE_TABLE_ROW_THRESHOLD
168
+ and col_type not in _STATS_UNSUPPORTED_TYPES
169
+ )
170
+
171
+ if should_collect_stats:
172
+ try:
173
+ _t_stat = (
174
+ time.perf_counter() if _PROFILE_INDEX else 0.0
175
+ )
176
+ stats = connector.get_column_stats(tname, col_info.name, sname)
177
+ if _PROFILE_INDEX:
178
+ _elapsed = time.perf_counter() - _t_stat
179
+ _stats_total += _elapsed
180
+ _stats_calls += 1
181
+ if _elapsed > _stats_max:
182
+ _stats_max = _elapsed
183
+ _stats_max_target = f"{sname}.{tname}.{col_info.name}"
184
+ stats_kwargs = {
185
+ "distinct_count": stats.distinct_count,
186
+ "min_value": stats.min_value,
187
+ "max_value": stats.max_value,
188
+ "null_percentage": stats.null_percentage,
189
+ "sample_distinct_values": stats.sample_distinct_values,
190
+ }
191
+ except Exception:
192
+ logger.debug(
193
+ "Failed to collect stats for %s.%s.%s, skipping",
194
+ sname,
195
+ tname,
196
+ col_info.name,
197
+ )
198
+
199
+ if cat_col:
200
+ for k in (
201
+ "most_common_values",
202
+ "histogram_bounds",
203
+ "stats_correlation",
204
+ "null_percentage",
205
+ "column_cardinality",
206
+ "index_type",
207
+ "index_is_unique",
208
+ ):
209
+ if k in cat_col and cat_col[k] is not None:
210
+ stats_kwargs[k] = cat_col[k]
211
+
212
+ columns.append(
213
+ Column(
214
+ name=col_info.name,
215
+ data_type=col_info.data_type,
216
+ nullable=col_info.nullable,
217
+ is_primary_key=col_info.is_primary_key,
218
+ is_foreign_key=col_info.name in fk_columns,
219
+ default_value=col_info.default_value,
220
+ comment=col_info.comment,
221
+ is_indexed=col_info.is_indexed,
222
+ parent_column=col_info.parent_column,
223
+ is_array=col_info.is_array,
224
+ check_constraints=list(col_info.check_constraints or []),
225
+ ordinal_position=col_info.ordinal_position,
226
+ **stats_kwargs,
227
+ )
228
+ )
229
+
230
+ table_fks = [
231
+ ForeignKey(
232
+ constraint_name=fk.constraint_name,
233
+ source_schema=fk.source_schema,
234
+ source_table=fk.source_table,
235
+ source_column=fk.source_column,
236
+ target_schema=fk.target_schema,
237
+ target_table=fk.target_table,
238
+ target_column=fk.target_column,
239
+ )
240
+ for fk in fk_index.get((sname, tname), [])
241
+ ]
242
+
243
+ tex = table_catalog_extra.get((sname, tname), {})
244
+ table_kwargs: dict[str, Any] = {}
245
+ for k in (
246
+ "seq_scan_count",
247
+ "idx_scan_count",
248
+ "insert_count",
249
+ "update_count",
250
+ "delete_count",
251
+ "is_partitioned",
252
+ "partition_key",
253
+ "grants",
254
+ "access_read_count",
255
+ "access_write_count",
256
+ "days_since_last_access",
257
+ "potentially_unused",
258
+ "table_bytes",
259
+ "clustering_key",
260
+ ):
261
+ if k in tex and tex[k] is not None:
262
+ table_kwargs[k] = tex[k]
263
+
264
+ tables.append(
265
+ Table(
266
+ name=tname,
267
+ schema_name=sname,
268
+ table_type=table_info.table_type,
269
+ columns=columns,
270
+ row_count=row_count,
271
+ row_count_source=table_info.row_count_source,
272
+ comment=table_info.comment,
273
+ foreign_keys=table_fks,
274
+ **table_kwargs,
275
+ )
276
+ )
277
+
278
+ if _PROFILE_INDEX:
279
+ _loop_elapsed = time.perf_counter() - _loop_start
280
+ print(
281
+ f"[profile] inspect.table_loop_total: {_loop_elapsed:.2f}s "
282
+ f"(get_columns sum={_columns_total:.2f}s; "
283
+ f"get_column_stats sum={_stats_total:.2f}s, "
284
+ f"calls={_stats_calls}, "
285
+ f"max={_stats_max * 1000:.0f}ms @ {_stats_max_target or '-'})",
286
+ flush=True,
287
+ )
288
+
289
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
290
+ view_deps: list[ViewDependency] = []
291
+ try:
292
+ view_deps = connector.load_view_dependencies(config.schema_filter)
293
+ except Exception:
294
+ logger.debug(
295
+ "View/trigger lineage extraction failed; continuing without it"
296
+ )
297
+ if _PROFILE_INDEX:
298
+ print(
299
+ f"[profile] inspect.load_view_dependencies: {(time.perf_counter() - _t) * 1000:.0f}ms",
300
+ flush=True,
301
+ )
302
+
303
+ elapsed = time.monotonic() - start
304
+ logger.info(
305
+ "Introspection complete: %d tables, %d columns, %d foreign keys, %d lineage deps in %.1fs",
306
+ len(tables),
307
+ sum(len(t.columns) for t in tables),
308
+ sum(len(t.foreign_keys) for t in tables),
309
+ len(view_deps),
310
+ elapsed,
311
+ )
312
+
313
+ return SchemaSnapshot(
314
+ connection_name=config.name,
315
+ database=config.database or "",
316
+ schemas=sorted(schemas_seen),
317
+ tables=tables,
318
+ introspected_at=datetime.now(timezone.utc),
319
+ metadata={"connector": config.type.value},
320
+ view_dependencies=view_deps,
321
+ )
@@ -0,0 +1,97 @@
1
+ """Shared sqlglot helpers for structural table references (no literal capture)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import sqlglot
9
+ from sqlglot import exp
10
+ from sqlglot.errors import ErrorLevel
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def table_refs_from_sql(
16
+ sql: str,
17
+ *,
18
+ dialect: str,
19
+ default_schema: str,
20
+ ) -> list[tuple[str, str]]:
21
+ """Return ``(schema, table)`` pairs from SELECT/DML; empty on parse failure."""
22
+ if not sql or not sql.strip():
23
+ return []
24
+ try:
25
+ parsed = sqlglot.parse_one(sql, dialect=dialect, error_level=ErrorLevel.WARN)
26
+ except Exception as exc:
27
+ logger.warning("sqlglot parse failed (%s): %s", dialect, exc)
28
+ return []
29
+ return list(_tables_from_expression(parsed, default_schema=default_schema))
30
+
31
+
32
+ def _schema_for_table(t: exp.Table, default_schema: str) -> str:
33
+ db = t.db
34
+ if db:
35
+ return str(db)
36
+ cat = t.catalog
37
+ if cat:
38
+ return str(cat)
39
+ return default_schema
40
+
41
+
42
+ def _tables_from_expression(expr: Any, *, default_schema: str) -> list[tuple[str, str]]:
43
+ out: list[tuple[str, str]] = []
44
+ for t in expr.find_all(exp.Table):
45
+ name = t.name
46
+ if not name:
47
+ continue
48
+ out.append((_schema_for_table(t, default_schema), str(name)))
49
+ return out
50
+
51
+
52
+ def dml_write_targets(
53
+ sql: str,
54
+ *,
55
+ dialect: str,
56
+ default_schema: str,
57
+ ) -> list[tuple[str, str]]:
58
+ """Tables targeted by INSERT/UPDATE/DELETE/MERGE (best-effort)."""
59
+ if not sql or not sql.strip():
60
+ return []
61
+ try:
62
+ parsed = sqlglot.parse_one(sql, dialect=dialect, error_level=ErrorLevel.WARN)
63
+ except Exception as exc:
64
+ logger.warning("sqlglot parse failed (%s): %s", dialect, exc)
65
+ return []
66
+ return _write_targets_from_expr(parsed, default_schema=default_schema)
67
+
68
+
69
+ def _unwrap_target_table(node: Any) -> exp.Table | None:
70
+ """Unwrap a DML target to its Table; INSERT with a column list wraps the
71
+ table in ``exp.Schema(this=Table, expressions=[columns])``."""
72
+ if isinstance(node, exp.Schema):
73
+ node = node.this
74
+ return node if isinstance(node, exp.Table) else None
75
+
76
+
77
+ def _write_targets_from_expr(
78
+ expr: Any, *, default_schema: str
79
+ ) -> list[tuple[str, str]]:
80
+ out: list[tuple[str, str]] = []
81
+ for ins in expr.find_all(exp.Insert):
82
+ tbl = _unwrap_target_table(ins.this)
83
+ if tbl is not None:
84
+ out.append((_schema_for_table(tbl, default_schema), str(tbl.name)))
85
+ for upd in expr.find_all(exp.Update):
86
+ tbl = upd.this
87
+ if isinstance(tbl, exp.Table):
88
+ out.append((_schema_for_table(tbl, default_schema), str(tbl.name)))
89
+ for mer in expr.find_all(exp.Merge):
90
+ tgt = mer.this
91
+ if isinstance(tgt, exp.Table):
92
+ out.append((_schema_for_table(tgt, default_schema), str(tgt.name)))
93
+ for delete in expr.find_all(exp.Delete):
94
+ tbl = delete.this
95
+ if isinstance(tbl, exp.Table):
96
+ out.append((_schema_for_table(tbl, default_schema), str(tbl.name)))
97
+ return out
@@ -0,0 +1,130 @@
1
+ """Schema and connection DTOs for database introspection (graph layer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from io import StringIO
7
+ from typing import Any
8
+
9
+ from pydantic import Field
10
+ from ruamel.yaml import YAML
11
+
12
+ from pretensor.introspection.models.base import PretensorModel
13
+ from pretensor.introspection.models.config import ConnectionConfig
14
+
15
+ __all__ = [
16
+ "Column",
17
+ "ConnectionConfig",
18
+ "ForeignKey",
19
+ "SchemaSnapshot",
20
+ "Table",
21
+ "ViewDependency",
22
+ ]
23
+
24
+
25
+ class Column(PretensorModel):
26
+ name: str
27
+ data_type: str
28
+ nullable: bool = True
29
+ is_primary_key: bool = False
30
+ is_foreign_key: bool = False
31
+ default_value: str | None = None
32
+ comment: str | None = None
33
+ is_indexed: bool = False
34
+ parent_column: str | None = None
35
+ is_array: bool = False
36
+ check_constraints: list[str] = Field(default_factory=list)
37
+ ordinal_position: int | None = None
38
+
39
+ distinct_count: int | None = None
40
+ min_value: str | None = None
41
+ max_value: str | None = None
42
+ null_percentage: float | None = None
43
+ sample_distinct_values: list[str] | None = None
44
+ most_common_values: list[str] | None = None
45
+ histogram_bounds: list[str] | None = None
46
+ stats_correlation: float | None = None
47
+ column_cardinality: int | None = None
48
+ index_type: str | None = None
49
+ index_is_unique: bool | None = None
50
+
51
+
52
+ class ForeignKey(PretensorModel):
53
+ constraint_name: str | None = None
54
+ source_schema: str
55
+ source_table: str
56
+ source_column: str
57
+ target_schema: str
58
+ target_table: str
59
+ target_column: str
60
+
61
+
62
+ class ViewDependency(PretensorModel):
63
+ """Table-level lineage: source table feeds into target (e.g. view reads source)."""
64
+
65
+ source_schema: str
66
+ source_table: str
67
+ target_schema: str
68
+ target_table: str
69
+ lineage_type: str
70
+ object_name: str
71
+ confidence: float = 1.0
72
+
73
+
74
+ class Table(PretensorModel):
75
+ name: str
76
+ schema_name: str
77
+ table_type: str | None = None
78
+ columns: list[Column] = Field(default_factory=list)
79
+ row_count: int | None = None
80
+ row_count_source: str | None = None
81
+ comment: str | None = None
82
+ foreign_keys: list[ForeignKey] = Field(default_factory=list)
83
+ seq_scan_count: int | None = None
84
+ idx_scan_count: int | None = None
85
+ insert_count: int | None = None
86
+ update_count: int | None = None
87
+ delete_count: int | None = None
88
+ is_partitioned: bool | None = None
89
+ partition_key: str | None = None
90
+ grants: list[dict[str, Any]] | None = None
91
+ access_read_count: int | None = None
92
+ access_write_count: int | None = None
93
+ days_since_last_access: int | None = None
94
+ potentially_unused: bool | None = None
95
+ table_bytes: int | None = None
96
+ clustering_key: str | None = None
97
+
98
+
99
+ class SchemaSnapshot(PretensorModel):
100
+ connection_name: str
101
+ database: str
102
+ schemas: list[str]
103
+ tables: list[Table]
104
+ introspected_at: datetime
105
+ metadata: dict[str, Any] = Field(default_factory=dict)
106
+ view_dependencies: list[ViewDependency] = Field(default_factory=list)
107
+
108
+ def to_yaml(self) -> str:
109
+ data = self.model_dump(mode="json")
110
+ yaml = YAML()
111
+ yaml.default_flow_style = False
112
+ buf = StringIO()
113
+ yaml.dump(data, buf)
114
+ return buf.getvalue()
115
+
116
+ @classmethod
117
+ def from_yaml(cls, yaml_str: str) -> SchemaSnapshot:
118
+ yaml = YAML()
119
+ data = yaml.load(yaml_str)
120
+ return cls.model_validate(data)
121
+
122
+ @classmethod
123
+ def empty(cls, connection_name: str, database: str) -> SchemaSnapshot:
124
+ return cls(
125
+ connection_name=connection_name,
126
+ database=database,
127
+ schemas=[],
128
+ tables=[],
129
+ introspected_at=datetime.now(timezone.utc),
130
+ )