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