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,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
|
+
)
|