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,402 @@
|
|
|
1
|
+
"""MySQL connector.
|
|
2
|
+
|
|
3
|
+
All MySQL-specific imports and SQL live here. Nothing outside this
|
|
4
|
+
package should reference pymysql or sqlalchemy.dialects.mysql.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import create_engine, text
|
|
13
|
+
from sqlalchemy.engine import Engine
|
|
14
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
15
|
+
|
|
16
|
+
from pretensor.connectors.base import (
|
|
17
|
+
BaseConnector,
|
|
18
|
+
ColumnInfo,
|
|
19
|
+
ColumnStats,
|
|
20
|
+
ForeignKeyInfo,
|
|
21
|
+
TableInfo,
|
|
22
|
+
)
|
|
23
|
+
from pretensor.connectors.lineage_sqlglot import table_refs_from_sql
|
|
24
|
+
from pretensor.connectors.models import ViewDependency
|
|
25
|
+
from pretensor.errors import ConnectorError
|
|
26
|
+
from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
LOW_CARDINALITY_THRESHOLD = 50
|
|
31
|
+
|
|
32
|
+
# MySQL system databases excluded from all INFORMATION_SCHEMA queries.
|
|
33
|
+
_SYSTEM_SCHEMAS = frozenset(
|
|
34
|
+
{"information_schema", "performance_schema", "mysql", "sys"}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _map_mysql_table_type(table_type: str) -> str:
|
|
39
|
+
if table_type == "VIEW":
|
|
40
|
+
return "view"
|
|
41
|
+
return "table"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _quote_ident(identifier: str) -> str:
|
|
45
|
+
"""Backtick-quote a MySQL identifier; escape embedded backticks."""
|
|
46
|
+
escaped = identifier.replace("`", "``")
|
|
47
|
+
return f"`{escaped}`"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MySQLConnector(BaseConnector):
|
|
51
|
+
"""MySQL implementation of the database connector interface."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: ConnectionConfig) -> None:
|
|
54
|
+
super().__init__(config)
|
|
55
|
+
self._engine: Engine | None = None
|
|
56
|
+
|
|
57
|
+
def _build_url(self) -> str:
|
|
58
|
+
cfg = self.config
|
|
59
|
+
user = cfg.user or "root"
|
|
60
|
+
password = cfg.password or ""
|
|
61
|
+
host = cfg.host or "localhost"
|
|
62
|
+
port = cfg.port or 3306
|
|
63
|
+
database = cfg.database or ""
|
|
64
|
+
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
|
|
65
|
+
|
|
66
|
+
def connect(self) -> None:
|
|
67
|
+
url = self._build_url()
|
|
68
|
+
try:
|
|
69
|
+
self._engine = create_engine(
|
|
70
|
+
url,
|
|
71
|
+
pool_pre_ping=True,
|
|
72
|
+
connect_args={"connect_timeout": 10},
|
|
73
|
+
)
|
|
74
|
+
with self._engine.connect() as conn:
|
|
75
|
+
conn.execute(text("SELECT 1"))
|
|
76
|
+
logger.info(
|
|
77
|
+
"Connected to %s:%s/%s",
|
|
78
|
+
self.config.host,
|
|
79
|
+
self.config.port,
|
|
80
|
+
self.config.database,
|
|
81
|
+
)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise ConnectorError(
|
|
84
|
+
f"Failed to connect to MySQL at {self.config.host}:{self.config.port}: {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
def disconnect(self) -> None:
|
|
88
|
+
if self._engine is not None:
|
|
89
|
+
self._engine.dispose()
|
|
90
|
+
self._engine = None
|
|
91
|
+
logger.info("Disconnected from %s", self.config.database)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def engine(self) -> Engine:
|
|
95
|
+
if self._engine is None:
|
|
96
|
+
raise ConnectorError("Not connected. Call connect() first.")
|
|
97
|
+
return self._engine
|
|
98
|
+
|
|
99
|
+
def _effective_schema_filter(self, sf: SchemaFilter) -> SchemaFilter:
|
|
100
|
+
"""When no include list is given, default to the connected database."""
|
|
101
|
+
if not sf.include and self.config.database:
|
|
102
|
+
return SchemaFilter(
|
|
103
|
+
include=[self.config.database], exclude=list(sf.exclude)
|
|
104
|
+
)
|
|
105
|
+
return sf
|
|
106
|
+
|
|
107
|
+
def _schema_visible(self, schema_name: str, sf: SchemaFilter) -> bool:
|
|
108
|
+
if schema_name in _SYSTEM_SCHEMAS:
|
|
109
|
+
return False
|
|
110
|
+
if sf.include and schema_name not in sf.include:
|
|
111
|
+
return False
|
|
112
|
+
if sf.exclude and schema_name in sf.exclude:
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
|
|
117
|
+
sf = self._effective_schema_filter(schema_filter or self.config.schema_filter)
|
|
118
|
+
|
|
119
|
+
query = text("""\
|
|
120
|
+
SELECT
|
|
121
|
+
TABLE_SCHEMA,
|
|
122
|
+
TABLE_NAME,
|
|
123
|
+
TABLE_TYPE,
|
|
124
|
+
TABLE_COMMENT,
|
|
125
|
+
TABLE_ROWS
|
|
126
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
127
|
+
WHERE TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
|
128
|
+
AND TABLE_TYPE IN ('BASE TABLE', 'VIEW')
|
|
129
|
+
ORDER BY TABLE_SCHEMA, TABLE_NAME
|
|
130
|
+
""")
|
|
131
|
+
|
|
132
|
+
with self.engine.connect() as conn:
|
|
133
|
+
rows = conn.execute(query).mappings().all()
|
|
134
|
+
|
|
135
|
+
tables: list[TableInfo] = []
|
|
136
|
+
for row in rows:
|
|
137
|
+
schema_name = str(row["TABLE_SCHEMA"])
|
|
138
|
+
if not self._schema_visible(schema_name, sf):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
table_type = _map_mysql_table_type(str(row["TABLE_TYPE"] or "BASE TABLE"))
|
|
142
|
+
approx = row["TABLE_ROWS"]
|
|
143
|
+
comment_raw = row["TABLE_COMMENT"]
|
|
144
|
+
comment = str(comment_raw) if comment_raw else None
|
|
145
|
+
|
|
146
|
+
if table_type == "view":
|
|
147
|
+
row_count, row_count_source = self._count_view_rows(
|
|
148
|
+
schema_name, str(row["TABLE_NAME"])
|
|
149
|
+
)
|
|
150
|
+
elif approx is not None:
|
|
151
|
+
row_count: int | None = int(approx)
|
|
152
|
+
row_count_source: str | None = "stat"
|
|
153
|
+
else:
|
|
154
|
+
row_count = None
|
|
155
|
+
row_count_source = None
|
|
156
|
+
|
|
157
|
+
tables.append(
|
|
158
|
+
TableInfo(
|
|
159
|
+
name=str(row["TABLE_NAME"]),
|
|
160
|
+
schema_name=schema_name,
|
|
161
|
+
row_count=row_count,
|
|
162
|
+
row_count_source=row_count_source,
|
|
163
|
+
comment=comment,
|
|
164
|
+
table_type=table_type,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return tables
|
|
168
|
+
|
|
169
|
+
def _count_view_rows(self, schema_name: str, view_name: str) -> tuple[int, str]:
|
|
170
|
+
"""Run SELECT COUNT(*) on a view; return (-1, 'view_timeout') on any error."""
|
|
171
|
+
qualified = f"{_quote_ident(schema_name)}.{_quote_ident(view_name)}"
|
|
172
|
+
try:
|
|
173
|
+
with self.engine.connect() as conn:
|
|
174
|
+
result = conn.execute(text(f"SELECT COUNT(*) AS n FROM {qualified}"))
|
|
175
|
+
row = result.mappings().first()
|
|
176
|
+
if row is None or row["n"] is None:
|
|
177
|
+
return -1, "view_timeout"
|
|
178
|
+
return int(row["n"]), "view_count"
|
|
179
|
+
except SQLAlchemyError as exc:
|
|
180
|
+
logger.debug(
|
|
181
|
+
"View row-count failed for %s.%s: %s", schema_name, view_name, exc
|
|
182
|
+
)
|
|
183
|
+
return -1, "view_timeout"
|
|
184
|
+
|
|
185
|
+
def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
|
|
186
|
+
query = text("""\
|
|
187
|
+
SELECT
|
|
188
|
+
COLUMN_NAME,
|
|
189
|
+
ORDINAL_POSITION,
|
|
190
|
+
DATA_TYPE,
|
|
191
|
+
COLUMN_TYPE,
|
|
192
|
+
IS_NULLABLE,
|
|
193
|
+
COLUMN_DEFAULT,
|
|
194
|
+
COLUMN_COMMENT,
|
|
195
|
+
COLUMN_KEY,
|
|
196
|
+
EXTRA
|
|
197
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
198
|
+
WHERE TABLE_SCHEMA = :schema_name
|
|
199
|
+
AND TABLE_NAME = :table_name
|
|
200
|
+
ORDER BY ORDINAL_POSITION
|
|
201
|
+
""")
|
|
202
|
+
|
|
203
|
+
with self.engine.connect() as conn:
|
|
204
|
+
rows = (
|
|
205
|
+
conn.execute(
|
|
206
|
+
query,
|
|
207
|
+
{"schema_name": schema_name, "table_name": table_name},
|
|
208
|
+
)
|
|
209
|
+
.mappings()
|
|
210
|
+
.all()
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
columns: list[ColumnInfo] = []
|
|
214
|
+
for row in rows:
|
|
215
|
+
col_name = str(row["COLUMN_NAME"])
|
|
216
|
+
data_type = str(row["DATA_TYPE"])
|
|
217
|
+
col_key = str(row["COLUMN_KEY"] or "")
|
|
218
|
+
is_pk = col_key == "PRI"
|
|
219
|
+
is_indexed = col_key in ("PRI", "MUL", "UNI")
|
|
220
|
+
comment_raw = row["COLUMN_COMMENT"]
|
|
221
|
+
comment = str(comment_raw) if comment_raw else None
|
|
222
|
+
ord_pos = row["ORDINAL_POSITION"]
|
|
223
|
+
columns.append(
|
|
224
|
+
ColumnInfo(
|
|
225
|
+
name=col_name,
|
|
226
|
+
data_type=data_type,
|
|
227
|
+
nullable=str(row["IS_NULLABLE"]) == "YES",
|
|
228
|
+
is_primary_key=is_pk,
|
|
229
|
+
default_value=row["COLUMN_DEFAULT"],
|
|
230
|
+
comment=comment,
|
|
231
|
+
is_indexed=is_indexed,
|
|
232
|
+
check_constraints=[],
|
|
233
|
+
ordinal_position=int(ord_pos) if ord_pos is not None else None,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return columns
|
|
237
|
+
|
|
238
|
+
def get_foreign_keys(self) -> list[ForeignKeyInfo]:
|
|
239
|
+
"""All FK relationships via INFORMATION_SCHEMA.KEY_COLUMN_USAGE."""
|
|
240
|
+
sf = self._effective_schema_filter(self.config.schema_filter)
|
|
241
|
+
|
|
242
|
+
query = text("""\
|
|
243
|
+
SELECT
|
|
244
|
+
kcu.CONSTRAINT_NAME,
|
|
245
|
+
kcu.TABLE_SCHEMA AS source_schema,
|
|
246
|
+
kcu.TABLE_NAME AS source_table,
|
|
247
|
+
kcu.COLUMN_NAME AS source_column,
|
|
248
|
+
kcu.REFERENCED_TABLE_SCHEMA AS target_schema,
|
|
249
|
+
kcu.REFERENCED_TABLE_NAME AS target_table,
|
|
250
|
+
kcu.REFERENCED_COLUMN_NAME AS target_column
|
|
251
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
|
252
|
+
WHERE kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
|
253
|
+
AND kcu.TABLE_SCHEMA NOT IN (
|
|
254
|
+
'information_schema', 'performance_schema', 'mysql', 'sys'
|
|
255
|
+
)
|
|
256
|
+
ORDER BY kcu.TABLE_SCHEMA, kcu.TABLE_NAME,
|
|
257
|
+
kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
|
|
258
|
+
""")
|
|
259
|
+
|
|
260
|
+
with self.engine.connect() as conn:
|
|
261
|
+
rows = conn.execute(query).mappings().all()
|
|
262
|
+
|
|
263
|
+
fks: list[ForeignKeyInfo] = []
|
|
264
|
+
for row in rows:
|
|
265
|
+
source_schema = str(row["source_schema"])
|
|
266
|
+
if not self._schema_visible(source_schema, sf):
|
|
267
|
+
continue
|
|
268
|
+
fks.append(
|
|
269
|
+
ForeignKeyInfo(
|
|
270
|
+
constraint_name=str(row["CONSTRAINT_NAME"]),
|
|
271
|
+
source_schema=source_schema,
|
|
272
|
+
source_table=str(row["source_table"]),
|
|
273
|
+
source_column=str(row["source_column"]),
|
|
274
|
+
target_schema=str(row["target_schema"]),
|
|
275
|
+
target_table=str(row["target_table"]),
|
|
276
|
+
target_column=str(row["target_column"]),
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return fks
|
|
280
|
+
|
|
281
|
+
def get_table_row_count(self, table_name: str, schema_name: str) -> int:
|
|
282
|
+
query = text("""\
|
|
283
|
+
SELECT TABLE_ROWS AS approx_count
|
|
284
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
285
|
+
WHERE TABLE_SCHEMA = :schema_name
|
|
286
|
+
AND TABLE_NAME = :table_name
|
|
287
|
+
""")
|
|
288
|
+
|
|
289
|
+
with self.engine.connect() as conn:
|
|
290
|
+
row = (
|
|
291
|
+
conn.execute(
|
|
292
|
+
query,
|
|
293
|
+
{"schema_name": schema_name, "table_name": table_name},
|
|
294
|
+
)
|
|
295
|
+
.mappings()
|
|
296
|
+
.first()
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if row is None or row["approx_count"] is None:
|
|
300
|
+
return 0
|
|
301
|
+
return int(row["approx_count"])
|
|
302
|
+
|
|
303
|
+
def get_column_stats(
|
|
304
|
+
self, table_name: str, column_name: str, schema_name: str
|
|
305
|
+
) -> ColumnStats:
|
|
306
|
+
fqn = f"{_quote_ident(schema_name)}.{_quote_ident(table_name)}"
|
|
307
|
+
col = _quote_ident(column_name)
|
|
308
|
+
|
|
309
|
+
stats_query = text(f"""\
|
|
310
|
+
SELECT
|
|
311
|
+
COUNT(DISTINCT {col}) AS distinct_count,
|
|
312
|
+
MIN({col}) AS min_val,
|
|
313
|
+
MAX({col}) AS max_val,
|
|
314
|
+
ROUND(
|
|
315
|
+
100.0 * SUM(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END)
|
|
316
|
+
/ GREATEST(COUNT(*), 1),
|
|
317
|
+
2
|
|
318
|
+
) AS null_pct
|
|
319
|
+
FROM {fqn}
|
|
320
|
+
""")
|
|
321
|
+
|
|
322
|
+
with self.engine.connect() as conn:
|
|
323
|
+
row = conn.execute(stats_query).mappings().first()
|
|
324
|
+
|
|
325
|
+
if row is None:
|
|
326
|
+
return ColumnStats()
|
|
327
|
+
|
|
328
|
+
distinct_count = int(row["distinct_count"])
|
|
329
|
+
sample_values: list[str] | None = None
|
|
330
|
+
|
|
331
|
+
if distinct_count <= LOW_CARDINALITY_THRESHOLD:
|
|
332
|
+
sample_query = text(
|
|
333
|
+
f"SELECT DISTINCT CAST({col} AS CHAR) AS val"
|
|
334
|
+
f" FROM {fqn}"
|
|
335
|
+
f" WHERE {col} IS NOT NULL"
|
|
336
|
+
f" ORDER BY val"
|
|
337
|
+
f" LIMIT {LOW_CARDINALITY_THRESHOLD}"
|
|
338
|
+
)
|
|
339
|
+
with self.engine.connect() as conn:
|
|
340
|
+
sample_rows = conn.execute(sample_query).mappings().all()
|
|
341
|
+
sample_values = [str(r["val"]) for r in sample_rows if r["val"] is not None]
|
|
342
|
+
|
|
343
|
+
return ColumnStats(
|
|
344
|
+
distinct_count=distinct_count,
|
|
345
|
+
min_value=str(row["min_val"]) if row["min_val"] is not None else None,
|
|
346
|
+
max_value=str(row["max_val"]) if row["max_val"] is not None else None,
|
|
347
|
+
null_percentage=float(row["null_pct"])
|
|
348
|
+
if row["null_pct"] is not None
|
|
349
|
+
else 0.0,
|
|
350
|
+
sample_distinct_values=sample_values,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def execute_query(self, sql: str) -> list[dict[str, Any]]:
|
|
354
|
+
with self.engine.connect() as conn:
|
|
355
|
+
rows = conn.execute(text(sql)).mappings().all()
|
|
356
|
+
return [dict(row) for row in rows]
|
|
357
|
+
|
|
358
|
+
def load_view_dependencies(
|
|
359
|
+
self, schema_filter: SchemaFilter
|
|
360
|
+
) -> list[ViewDependency]:
|
|
361
|
+
"""Lineage from MySQL views via INFORMATION_SCHEMA.VIEWS."""
|
|
362
|
+
sf = self._effective_schema_filter(schema_filter)
|
|
363
|
+
views_sql = text("""\
|
|
364
|
+
SELECT TABLE_SCHEMA, TABLE_NAME, VIEW_DEFINITION
|
|
365
|
+
FROM INFORMATION_SCHEMA.VIEWS
|
|
366
|
+
WHERE TABLE_SCHEMA NOT IN (
|
|
367
|
+
'information_schema', 'performance_schema', 'mysql', 'sys'
|
|
368
|
+
)
|
|
369
|
+
""")
|
|
370
|
+
|
|
371
|
+
with self.engine.connect() as conn:
|
|
372
|
+
rows = conn.execute(views_sql).mappings().all()
|
|
373
|
+
|
|
374
|
+
deps: list[ViewDependency] = []
|
|
375
|
+
for row in rows:
|
|
376
|
+
view_schema = str(row["TABLE_SCHEMA"])
|
|
377
|
+
if not self._schema_visible(view_schema, sf):
|
|
378
|
+
continue
|
|
379
|
+
view_name = str(row["TABLE_NAME"])
|
|
380
|
+
definition = row["VIEW_DEFINITION"]
|
|
381
|
+
if definition is None:
|
|
382
|
+
continue
|
|
383
|
+
sql_text = str(definition)
|
|
384
|
+
for src_schema, src_table in table_refs_from_sql(
|
|
385
|
+
sql_text, dialect="mysql", default_schema=view_schema
|
|
386
|
+
):
|
|
387
|
+
if not self._schema_visible(src_schema, sf):
|
|
388
|
+
continue
|
|
389
|
+
if src_schema == view_schema and src_table == view_name:
|
|
390
|
+
continue
|
|
391
|
+
deps.append(
|
|
392
|
+
ViewDependency(
|
|
393
|
+
source_schema=src_schema,
|
|
394
|
+
source_table=src_table,
|
|
395
|
+
target_schema=view_schema,
|
|
396
|
+
target_table=view_name,
|
|
397
|
+
lineage_type="VIEW",
|
|
398
|
+
object_name=f"{view_schema}.{view_name}",
|
|
399
|
+
confidence=1.0,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
return deps
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Parse PostgreSQL array literal text from catalog views (e.g. ``pg_stats``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["parse_pg_array_literal"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_pg_array_literal(raw: str | None) -> list[str]:
|
|
9
|
+
"""Turn ``{a,b,"c,d"}``-style literals into string tokens (best-effort)."""
|
|
10
|
+
if raw is None:
|
|
11
|
+
return []
|
|
12
|
+
s = raw.strip()
|
|
13
|
+
if not s or s == "{}":
|
|
14
|
+
return []
|
|
15
|
+
if len(s) >= 2 and s[0] == "{" and s[-1] == "}":
|
|
16
|
+
s = s[1:-1]
|
|
17
|
+
if not s:
|
|
18
|
+
return []
|
|
19
|
+
out: list[str] = []
|
|
20
|
+
buf: list[str] = []
|
|
21
|
+
in_quote = False
|
|
22
|
+
i = 0
|
|
23
|
+
while i < len(s):
|
|
24
|
+
ch = s[i]
|
|
25
|
+
if in_quote:
|
|
26
|
+
if ch == "\\" and i + 1 < len(s):
|
|
27
|
+
buf.append(s[i + 1])
|
|
28
|
+
i += 2
|
|
29
|
+
continue
|
|
30
|
+
if ch == '"':
|
|
31
|
+
in_quote = False
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
buf.append(ch)
|
|
35
|
+
i += 1
|
|
36
|
+
continue
|
|
37
|
+
if ch == '"':
|
|
38
|
+
in_quote = True
|
|
39
|
+
i += 1
|
|
40
|
+
continue
|
|
41
|
+
if ch == ",":
|
|
42
|
+
token = "".join(buf).strip()
|
|
43
|
+
if token:
|
|
44
|
+
out.append(token)
|
|
45
|
+
buf = []
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
buf.append(ch)
|
|
49
|
+
i += 1
|
|
50
|
+
tail = "".join(buf).strip()
|
|
51
|
+
if tail:
|
|
52
|
+
out.append(tail)
|
|
53
|
+
return out
|