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,938 @@
1
+ """PostgreSQL connector.
2
+
3
+ All Postgres-specific imports and SQL live here. Nothing outside this
4
+ package should reference psycopg2 or sqlalchemy.dialects.postgresql.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import time
12
+ from typing import Any
13
+
14
+ from sqlalchemy import URL, create_engine, text
15
+ from sqlalchemy.engine import Engine
16
+ from sqlalchemy.exc import SQLAlchemyError
17
+
18
+ from pretensor.connectors.base import (
19
+ BaseConnector,
20
+ ColumnInfo,
21
+ ColumnStats,
22
+ ForeignKeyInfo,
23
+ TableGrant,
24
+ TableInfo,
25
+ )
26
+ from pretensor.connectors.lineage_sqlglot import dml_write_targets, table_refs_from_sql
27
+ from pretensor.connectors.models import ViewDependency
28
+ from pretensor.connectors.pg_array_parse import parse_pg_array_literal
29
+ from pretensor.errors import ConnectorError
30
+ from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _PROFILE_INDEX = os.environ.get("PRETENSOR_PROFILE_INDEX", "").lower() not in (
35
+ "",
36
+ "0",
37
+ "false",
38
+ "no",
39
+ )
40
+
41
+ LOW_CARDINALITY_THRESHOLD = 50
42
+ PG_STATS_MOST_COMMON_CAP = 20
43
+
44
+ # Wall-clock cap for live `SELECT COUNT(*)` against a view (catalog stats are NULL
45
+ # for views in pg_stat_user_tables). Capped server-side via SET LOCAL statement_timeout.
46
+ VIEW_COUNT_TIMEOUT_MS = 2000
47
+
48
+
49
+ def _pg_transitive_members(children: dict[str, set[str]], root: str) -> set[str]:
50
+ """All roles (including ``root``) that are direct or indirect members of ``root``.
51
+
52
+ ``children[r]`` is the set of direct members of role ``r`` (PostgreSQL ``pg_auth_members``).
53
+ """
54
+ out: set[str] = {root}
55
+ stack = [root]
56
+ while stack:
57
+ parent = stack.pop()
58
+ for mbr in children.get(parent, ()):
59
+ if mbr not in out:
60
+ out.add(mbr)
61
+ stack.append(mbr)
62
+ return out
63
+
64
+
65
+ def _map_pg_table_type(relkind: str | None, info_table_type: str) -> str:
66
+ """Normalize to classifier-friendly labels used in the graph."""
67
+ if relkind == "r":
68
+ return "table"
69
+ if relkind == "p":
70
+ return "partitioned_table"
71
+ if relkind == "v":
72
+ return "view"
73
+ if relkind == "m":
74
+ return "materialized_view"
75
+ if relkind == "f":
76
+ return "foreign_table"
77
+ if info_table_type == "VIEW":
78
+ return "view"
79
+ if info_table_type == "FOREIGN TABLE":
80
+ return "foreign_table"
81
+ if info_table_type == "BASE TABLE":
82
+ return "table"
83
+ return "table"
84
+
85
+
86
+ class PostgresConnector(BaseConnector):
87
+ """Postgres implementation of the database connector interface."""
88
+
89
+ def __init__(self, config: ConnectionConfig) -> None:
90
+ super().__init__(config)
91
+ self._engine: Engine | None = None
92
+ self._indexed_columns: set[tuple[str, str, str]] = set()
93
+ self._check_by_column: dict[tuple[str, str, str], list[str]] = {}
94
+
95
+ def _build_url(self) -> URL:
96
+ # ``URL.create`` (not an f-string) so the password is redacted in the
97
+ # URL repr that SQLAlchemy embeds in driver exceptions, and so special
98
+ # characters in the password are escaped correctly rather than breaking
99
+ # URL parsing.
100
+ cfg = self.config
101
+ return URL.create(
102
+ "postgresql+psycopg2",
103
+ username=cfg.user or "postgres",
104
+ password=cfg.password or "",
105
+ host=cfg.host or "localhost",
106
+ port=int(cfg.port) if cfg.port else 5432,
107
+ database=cfg.database or "postgres",
108
+ )
109
+
110
+ def connect(self) -> None:
111
+ self._indexed_columns = set()
112
+ self._check_by_column = {}
113
+ url = self._build_url()
114
+ try:
115
+ self._engine = create_engine(
116
+ url,
117
+ pool_pre_ping=True,
118
+ connect_args={"connect_timeout": 10},
119
+ )
120
+ with self._engine.connect() as conn:
121
+ conn.execute(text("SELECT 1"))
122
+ logger.info(
123
+ "Connected to %s:%s/%s",
124
+ self.config.host,
125
+ self.config.port,
126
+ self.config.database,
127
+ )
128
+ except Exception as exc:
129
+ # Keep the driver error (which may echo connection details) in the
130
+ # server log only; the raised message is host:port/db, no secret.
131
+ logger.warning(
132
+ "Postgres connection failed for %s:%s/%s: %s",
133
+ self.config.host,
134
+ self.config.port,
135
+ self.config.database,
136
+ exc,
137
+ )
138
+ raise ConnectorError(
139
+ f"Failed to connect to Postgres at {self.config.host}:"
140
+ f"{self.config.port}/{self.config.database}"
141
+ ) from exc
142
+
143
+ def disconnect(self) -> None:
144
+ if self._engine is not None:
145
+ self._engine.dispose()
146
+ self._engine = None
147
+ logger.info("Disconnected from %s", self.config.database)
148
+
149
+ @property
150
+ def engine(self) -> Engine:
151
+ if self._engine is None:
152
+ raise ConnectorError("Not connected. Call connect() first.")
153
+ return self._engine
154
+
155
+ def _refresh_column_metadata_cache(self, schema_filter: SchemaFilter) -> None:
156
+ """Load indexed-column and check-constraint maps for visible schemas."""
157
+ idx_sql = text("""\
158
+ SELECT DISTINCT n.nspname AS table_schema,
159
+ c.relname AS table_name,
160
+ a.attname AS column_name
161
+ FROM pg_catalog.pg_index i
162
+ JOIN pg_catalog.pg_class c ON c.oid = i.indrelid
163
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
164
+ CROSS JOIN LATERAL unnest(i.indkey) AS u(attnum)
165
+ JOIN pg_catalog.pg_attribute a
166
+ ON a.attrelid = c.oid AND a.attnum = u.attnum AND NOT a.attisdropped
167
+ WHERE i.indisvalid
168
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
169
+ """)
170
+ chk_sql = text("""\
171
+ SELECT n.nspname AS table_schema,
172
+ c.relname AS table_name,
173
+ a.attname AS column_name,
174
+ pg_catalog.pg_get_constraintdef(con.oid, true) AS check_def
175
+ FROM pg_catalog.pg_constraint con
176
+ JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
177
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
178
+ CROSS JOIN LATERAL unnest(con.conkey) AS ck(attnum)
179
+ JOIN pg_catalog.pg_attribute a
180
+ ON a.attrelid = c.oid AND a.attnum = ck.attnum AND NOT a.attisdropped
181
+ WHERE con.contype = 'c'
182
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
183
+ """)
184
+
185
+ self._indexed_columns = set()
186
+ self._check_by_column = {}
187
+
188
+ with self.engine.connect() as conn:
189
+ for row in conn.execute(idx_sql).mappings().all():
190
+ sn, tn, cn = row["table_schema"], row["table_name"], row["column_name"]
191
+ if schema_filter.include and sn not in schema_filter.include:
192
+ continue
193
+ if schema_filter.exclude and sn in schema_filter.exclude:
194
+ continue
195
+ self._indexed_columns.add((sn, tn, cn))
196
+
197
+ for row in conn.execute(chk_sql).mappings().all():
198
+ sn, tn, cn = row["table_schema"], row["table_name"], row["column_name"]
199
+ if schema_filter.include and sn not in schema_filter.include:
200
+ continue
201
+ if schema_filter.exclude and sn in schema_filter.exclude:
202
+ continue
203
+ key = (sn, tn, cn)
204
+ expr = row["check_def"]
205
+ if expr:
206
+ self._check_by_column.setdefault(key, []).append(str(expr))
207
+
208
+ def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
209
+ sf = schema_filter or self.config.schema_filter
210
+ self._refresh_column_metadata_cache(sf)
211
+
212
+ query = text("""\
213
+ SELECT
214
+ t.table_schema,
215
+ t.table_name,
216
+ t.table_type AS info_table_type,
217
+ c.relkind::text AS relkind,
218
+ obj_description(
219
+ (quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass
220
+ ) AS table_comment,
221
+ s.n_live_tup AS approx_row_count
222
+ FROM information_schema.tables t
223
+ LEFT JOIN pg_catalog.pg_namespace n ON n.nspname = t.table_schema
224
+ LEFT JOIN pg_catalog.pg_class c
225
+ ON c.relnamespace = n.oid AND c.relname = t.table_name
226
+ LEFT JOIN pg_stat_user_tables s
227
+ ON s.schemaname = t.table_schema
228
+ AND s.relname = t.table_name
229
+ WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
230
+ AND t.table_type IN ('BASE TABLE', 'VIEW', 'FOREIGN TABLE')
231
+ ORDER BY t.table_schema, t.table_name
232
+ """)
233
+
234
+ with self.engine.connect() as conn:
235
+ rows = conn.execute(query).mappings().all()
236
+
237
+ tables: list[TableInfo] = []
238
+ for row in rows:
239
+ schema_name = row["table_schema"]
240
+ if sf.include and schema_name not in sf.include:
241
+ continue
242
+ if sf.exclude and schema_name in sf.exclude:
243
+ continue
244
+ relkind = row["relkind"]
245
+ info_tt = row["info_table_type"] or "BASE TABLE"
246
+ mapped_type = _map_pg_table_type(relkind, info_tt)
247
+ approx = row["approx_row_count"]
248
+ if approx is not None:
249
+ row_count: int | None = int(approx)
250
+ row_count_source: str | None = "stat"
251
+ elif mapped_type == "view":
252
+ row_count, row_count_source = self._count_view_rows(
253
+ schema_name, row["table_name"]
254
+ )
255
+ else:
256
+ row_count = None
257
+ row_count_source = None
258
+ tables.append(
259
+ TableInfo(
260
+ name=row["table_name"],
261
+ schema_name=schema_name,
262
+ row_count=row_count,
263
+ row_count_source=row_count_source,
264
+ comment=row["table_comment"],
265
+ table_type=mapped_type,
266
+ )
267
+ )
268
+ return tables
269
+
270
+ def _count_view_rows(self, schema_name: str, view_name: str) -> tuple[int, str]:
271
+ """Run ``SELECT COUNT(*)`` on a view with a 2s statement timeout.
272
+
273
+ Returns ``(count, "view_count")`` on success, ``(-1, "view_timeout")``
274
+ on timeout or any error — the indexer must not fail because one view is
275
+ slow or broken.
276
+ """
277
+ # Quote identifiers to defeat any oddly-named schemas/views.
278
+ qualified = (
279
+ f'"{schema_name.replace(chr(34), chr(34) * 2)}"'
280
+ f'."{view_name.replace(chr(34), chr(34) * 2)}"'
281
+ )
282
+ try:
283
+ with self.engine.begin() as conn:
284
+ conn.execute(
285
+ text(f"SET LOCAL statement_timeout = {VIEW_COUNT_TIMEOUT_MS}")
286
+ )
287
+ result = conn.execute(text(f"SELECT COUNT(*) AS n FROM {qualified}"))
288
+ row = result.mappings().first()
289
+ if row is None or row["n"] is None:
290
+ return -1, "view_timeout"
291
+ return int(row["n"]), "view_count"
292
+ except SQLAlchemyError as exc:
293
+ logger.debug(
294
+ "View row-count failed for %s.%s: %s",
295
+ schema_name,
296
+ view_name,
297
+ exc,
298
+ )
299
+ return -1, "view_timeout"
300
+
301
+ def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
302
+ query = text("""\
303
+ SELECT
304
+ c.column_name,
305
+ c.ordinal_position,
306
+ c.data_type,
307
+ c.udt_name,
308
+ c.is_nullable,
309
+ c.column_default,
310
+ col_description(
311
+ (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
312
+ c.ordinal_position
313
+ ) AS column_comment,
314
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk
315
+ FROM information_schema.columns c
316
+ LEFT JOIN (
317
+ SELECT kcu.column_name, kcu.table_schema, kcu.table_name
318
+ FROM information_schema.table_constraints tc
319
+ JOIN information_schema.key_column_usage kcu
320
+ ON tc.constraint_name = kcu.constraint_name
321
+ AND tc.table_schema = kcu.table_schema
322
+ WHERE tc.constraint_type = 'PRIMARY KEY'
323
+ ) pk
324
+ ON pk.column_name = c.column_name
325
+ AND pk.table_schema = c.table_schema
326
+ AND pk.table_name = c.table_name
327
+ WHERE c.table_name = :table_name
328
+ AND c.table_schema = :schema_name
329
+ ORDER BY c.ordinal_position
330
+ """)
331
+
332
+ with self.engine.connect() as conn:
333
+ rows = (
334
+ conn.execute(
335
+ query,
336
+ {"table_name": table_name, "schema_name": schema_name},
337
+ )
338
+ .mappings()
339
+ .all()
340
+ )
341
+
342
+ columns: list[ColumnInfo] = []
343
+ for row in rows:
344
+ col_name = row["column_name"]
345
+ data_type = (
346
+ row["udt_name"]
347
+ if row["data_type"] == "USER-DEFINED"
348
+ else row["data_type"]
349
+ )
350
+ ck = (schema_name, table_name, col_name)
351
+ ord_pos = row["ordinal_position"]
352
+ columns.append(
353
+ ColumnInfo(
354
+ name=col_name,
355
+ data_type=data_type,
356
+ nullable=row["is_nullable"] == "YES",
357
+ is_primary_key=bool(row["is_pk"]),
358
+ default_value=row["column_default"],
359
+ comment=row["column_comment"],
360
+ is_indexed=ck in self._indexed_columns,
361
+ check_constraints=list(self._check_by_column.get(ck, [])),
362
+ ordinal_position=int(ord_pos) if ord_pos is not None else None,
363
+ )
364
+ )
365
+ return columns
366
+
367
+ def get_foreign_keys(self) -> list[ForeignKeyInfo]:
368
+ # Use key_column_usage on both sides to correctly pair composite FK
369
+ # columns. The source side (kcu_src) carries ordinal_position and
370
+ # position_in_unique_constraint; the target side (kcu_ref) is the
371
+ # referenced unique/PK constraint whose ordinal_position matches
372
+ # position_in_unique_constraint. This avoids the Cartesian product
373
+ # that occurs when joining through constraint_column_usage which
374
+ # lacks ordinal_position.
375
+ query = text("""\
376
+ SELECT
377
+ tc.constraint_name,
378
+ kcu_src.table_schema AS source_schema,
379
+ kcu_src.table_name AS source_table,
380
+ kcu_src.column_name AS source_column,
381
+ kcu_ref.table_schema AS target_schema,
382
+ kcu_ref.table_name AS target_table,
383
+ kcu_ref.column_name AS target_column
384
+ FROM information_schema.table_constraints tc
385
+ JOIN information_schema.key_column_usage kcu_src
386
+ ON tc.constraint_name = kcu_src.constraint_name
387
+ AND tc.table_schema = kcu_src.table_schema
388
+ JOIN information_schema.referential_constraints rc
389
+ ON tc.constraint_name = rc.constraint_name
390
+ AND tc.constraint_schema = rc.constraint_schema
391
+ JOIN information_schema.key_column_usage kcu_ref
392
+ ON rc.unique_constraint_name = kcu_ref.constraint_name
393
+ AND rc.unique_constraint_schema = kcu_ref.constraint_schema
394
+ AND kcu_src.position_in_unique_constraint = kcu_ref.ordinal_position
395
+ WHERE tc.constraint_type = 'FOREIGN KEY'
396
+ ORDER BY kcu_src.table_schema, kcu_src.table_name,
397
+ tc.constraint_name, kcu_src.ordinal_position
398
+ """)
399
+
400
+ with self.engine.connect() as conn:
401
+ rows = conn.execute(query).mappings().all()
402
+
403
+ return [
404
+ ForeignKeyInfo(
405
+ constraint_name=row["constraint_name"],
406
+ source_schema=row["source_schema"],
407
+ source_table=row["source_table"],
408
+ source_column=row["source_column"],
409
+ target_schema=row["target_schema"],
410
+ target_table=row["target_table"],
411
+ target_column=row["target_column"],
412
+ )
413
+ for row in rows
414
+ ]
415
+
416
+ def get_table_row_count(self, table_name: str, schema_name: str) -> int:
417
+ query = text("""\
418
+ SELECT n_live_tup AS approx_count
419
+ FROM pg_stat_user_tables
420
+ WHERE schemaname = :schema_name
421
+ AND relname = :table_name
422
+ """)
423
+
424
+ with self.engine.connect() as conn:
425
+ row = (
426
+ conn.execute(
427
+ query,
428
+ {"table_name": table_name, "schema_name": schema_name},
429
+ )
430
+ .mappings()
431
+ .first()
432
+ )
433
+
434
+ if row is None:
435
+ return 0
436
+ return int(row["approx_count"])
437
+
438
+ def get_column_stats(
439
+ self, table_name: str, column_name: str, schema_name: str
440
+ ) -> ColumnStats:
441
+ fqn = f"{_quote_ident(schema_name)}.{_quote_ident(table_name)}"
442
+ col = _quote_ident(column_name)
443
+
444
+ stats_query = text(f"""\
445
+ SELECT
446
+ COUNT(DISTINCT {col}) AS distinct_count,
447
+ MIN({col})::text AS min_val,
448
+ MAX({col})::text AS max_val,
449
+ ROUND(100.0 * SUM(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) / GREATEST(COUNT(*), 1), 2)
450
+ AS null_pct,
451
+ COUNT(*) AS total_rows
452
+ FROM {fqn}
453
+ """)
454
+
455
+ with self.engine.connect() as conn:
456
+ row = conn.execute(stats_query).mappings().first()
457
+
458
+ if row is None:
459
+ return ColumnStats()
460
+
461
+ distinct_count = int(row["distinct_count"])
462
+ sample_values: list[str] | None = None
463
+
464
+ if distinct_count <= LOW_CARDINALITY_THRESHOLD:
465
+ sample_query = text(
466
+ f"SELECT DISTINCT {col}::text AS val FROM {fqn} WHERE {col} IS NOT NULL ORDER BY val LIMIT {LOW_CARDINALITY_THRESHOLD}"
467
+ )
468
+ with self.engine.connect() as conn:
469
+ sample_rows = conn.execute(sample_query).mappings().all()
470
+ sample_values = [r["val"] for r in sample_rows]
471
+
472
+ return ColumnStats(
473
+ distinct_count=distinct_count,
474
+ min_value=row["min_val"],
475
+ max_value=row["max_val"],
476
+ null_percentage=float(row["null_pct"])
477
+ if row["null_pct"] is not None
478
+ else 0.0,
479
+ sample_distinct_values=sample_values,
480
+ )
481
+
482
+ def execute_query(self, sql: str) -> list[dict[str, Any]]:
483
+ with self.engine.connect() as conn:
484
+ rows = conn.execute(text(sql)).mappings().all()
485
+ return [dict(row) for row in rows]
486
+
487
+ def _schema_visible(self, schema_name: str, schema_filter: SchemaFilter) -> bool:
488
+ if schema_filter.include and schema_name not in schema_filter.include:
489
+ return False
490
+ if schema_filter.exclude and schema_name in schema_filter.exclude:
491
+ return False
492
+ return schema_name not in ("pg_catalog", "information_schema")
493
+
494
+ def get_table_grants(
495
+ self, schema_filter: SchemaFilter | None = None
496
+ ) -> list[TableGrant]:
497
+ """Return SELECT grants with grantees expanded along PostgreSQL role membership."""
498
+ sf = schema_filter or self.config.schema_filter
499
+
500
+ select_grants_sql = text("""\
501
+ SELECT grantee, table_schema, table_name
502
+ FROM information_schema.table_privileges
503
+ WHERE privilege_type = 'SELECT'
504
+ AND table_schema NOT IN ('pg_catalog', 'information_schema')
505
+ """)
506
+ membership_sql = text("""\
507
+ SELECT m.rolname AS member_name, r.rolname AS role_name
508
+ FROM pg_catalog.pg_auth_members am
509
+ JOIN pg_catalog.pg_roles m ON m.oid = am.member
510
+ JOIN pg_catalog.pg_roles r ON r.oid = am.roleid
511
+ """)
512
+
513
+ try:
514
+ with self.engine.connect() as conn:
515
+ priv_rows = conn.execute(select_grants_sql).mappings().all()
516
+ except SQLAlchemyError as exc:
517
+ logger.warning(
518
+ "Could not read SELECT grants from information_schema.table_privileges: %s",
519
+ exc,
520
+ )
521
+ return []
522
+
523
+ direct_keys: set[tuple[str, str, str]] = set()
524
+ direct: list[tuple[str, str, str]] = []
525
+ for row in priv_rows:
526
+ ge = row["grantee"]
527
+ ts = row["table_schema"]
528
+ tn = row["table_name"]
529
+ if ge is None or ts is None or tn is None:
530
+ continue
531
+ grantee = str(ge)
532
+ schema_name = str(ts)
533
+ table_name = str(tn)
534
+ if not self._schema_visible(schema_name, sf):
535
+ continue
536
+ if grantee == "PUBLIC":
537
+ continue
538
+ dk = (grantee, schema_name, table_name)
539
+ if dk in direct_keys:
540
+ continue
541
+ direct_keys.add(dk)
542
+ direct.append(dk)
543
+
544
+ children: dict[str, set[str]] = {}
545
+ try:
546
+ with self.engine.connect() as conn:
547
+ for row in conn.execute(membership_sql).mappings().all():
548
+ mname = row["member_name"]
549
+ rname = row["role_name"]
550
+ if mname is None or rname is None:
551
+ continue
552
+ parent = str(rname)
553
+ member = str(mname)
554
+ children.setdefault(parent, set()).add(member)
555
+ except SQLAlchemyError as exc:
556
+ # Falling back to direct grants only — an admin role without
557
+ # pg_catalog access still gets useful output instead of an empty
558
+ # visibility.yml. ``_pg_transitive_members`` with an empty children
559
+ # map yields ``{grantee}``, so the downstream loop preserves each
560
+ # direct grant one-to-one.
561
+ logger.warning(
562
+ "Could not read role membership from pg_auth_members "
563
+ "(returning direct grants only, no inherited-role expansion): %s",
564
+ exc,
565
+ )
566
+ children = {}
567
+
568
+ seen: set[tuple[str, str, str]] = set()
569
+ out: list[TableGrant] = []
570
+ for grantee, schema_name, table_name in direct:
571
+ for eff in _pg_transitive_members(children, grantee):
572
+ key = (eff, schema_name, table_name)
573
+ if key in seen:
574
+ continue
575
+ seen.add(key)
576
+ out.append(
577
+ TableGrant(
578
+ grantee=eff,
579
+ schema_name=schema_name,
580
+ table_name=table_name,
581
+ )
582
+ )
583
+ out.sort(key=lambda tg: (tg.schema_name, tg.table_name, tg.grantee))
584
+ return out
585
+
586
+ def load_view_dependencies(
587
+ self, schema_filter: SchemaFilter
588
+ ) -> list[ViewDependency]:
589
+ """Lineage from views, materialized views, and cross-table triggers."""
590
+ out: list[ViewDependency] = []
591
+ out.extend(self._load_pg_view_sql_lineage(schema_filter))
592
+ out.extend(self._load_pg_trigger_lineage(schema_filter))
593
+ return out
594
+
595
+ def _load_pg_view_sql_lineage(
596
+ self, schema_filter: SchemaFilter
597
+ ) -> list[ViewDependency]:
598
+ deps: list[ViewDependency] = []
599
+ views_sql = text("""\
600
+ SELECT schemaname, viewname, definition
601
+ FROM pg_catalog.pg_views
602
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
603
+ """)
604
+ mat_sql = text("""\
605
+ SELECT schemaname, matviewname, definition
606
+ FROM pg_catalog.pg_matviews
607
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
608
+ """)
609
+ with self.engine.connect() as conn:
610
+ view_rows = conn.execute(views_sql).mappings().all()
611
+ mat_rows = conn.execute(mat_sql).mappings().all()
612
+ for row in view_rows:
613
+ ts = str(row["schemaname"])
614
+ if not self._schema_visible(ts, schema_filter):
615
+ continue
616
+ tname = str(row["viewname"])
617
+ definition = row["definition"]
618
+ if definition is None:
619
+ continue
620
+ sql_text = str(definition)
621
+ for src_schema, src_table in table_refs_from_sql(
622
+ sql_text, dialect="postgres", default_schema=ts
623
+ ):
624
+ if not self._schema_visible(src_schema, schema_filter):
625
+ continue
626
+ if src_schema == ts and src_table == tname:
627
+ continue
628
+ deps.append(
629
+ ViewDependency(
630
+ source_schema=src_schema,
631
+ source_table=src_table,
632
+ target_schema=ts,
633
+ target_table=tname,
634
+ lineage_type="VIEW",
635
+ object_name=f"{ts}.{tname}",
636
+ confidence=1.0,
637
+ )
638
+ )
639
+ for row in mat_rows:
640
+ ts = str(row["schemaname"])
641
+ if not self._schema_visible(ts, schema_filter):
642
+ continue
643
+ tname = str(row["matviewname"])
644
+ definition = row["definition"]
645
+ if definition is None:
646
+ continue
647
+ sql_text = str(definition)
648
+ for src_schema, src_table in table_refs_from_sql(
649
+ sql_text, dialect="postgres", default_schema=ts
650
+ ):
651
+ if not self._schema_visible(src_schema, schema_filter):
652
+ continue
653
+ if src_schema == ts and src_table == tname:
654
+ continue
655
+ deps.append(
656
+ ViewDependency(
657
+ source_schema=src_schema,
658
+ source_table=src_table,
659
+ target_schema=ts,
660
+ target_table=tname,
661
+ lineage_type="MATERIALIZED_VIEW",
662
+ object_name=f"{ts}.{tname}",
663
+ confidence=1.0,
664
+ )
665
+ )
666
+ return deps
667
+
668
+ def _load_pg_trigger_lineage(
669
+ self, schema_filter: SchemaFilter
670
+ ) -> list[ViewDependency]:
671
+ deps: list[ViewDependency] = []
672
+ trig_sql = text("""\
673
+ SELECT t.tgname AS trigger_name,
674
+ n.nspname AS source_schema,
675
+ c.relname AS source_table,
676
+ pg_get_functiondef(p.oid) AS func_def,
677
+ l.lanname AS func_language
678
+ FROM pg_catalog.pg_trigger t
679
+ JOIN pg_catalog.pg_class c ON t.tgrelid = c.oid
680
+ JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
681
+ JOIN pg_catalog.pg_proc p ON t.tgfoid = p.oid
682
+ JOIN pg_catalog.pg_language l ON p.prolang = l.oid
683
+ WHERE NOT t.tgisinternal
684
+ """)
685
+ with self.engine.connect() as conn:
686
+ rows = conn.execute(trig_sql).mappings().all()
687
+ for row in rows:
688
+ source_schema = str(row["source_schema"])
689
+ if not self._schema_visible(source_schema, schema_filter):
690
+ continue
691
+ source_table = str(row["source_table"])
692
+ tgname = str(row["trigger_name"])
693
+ func_def = row["func_def"]
694
+ if func_def is None:
695
+ continue
696
+ body = str(func_def)
697
+ if "EXECUTE" in body.upper():
698
+ continue
699
+ lang = str(row["func_language"] or "").lower()
700
+ if lang != "sql":
701
+ # sqlglot cannot parse pg_get_functiondef output for plpgsql/c/
702
+ # internal function bodies; it falls back to a Command node
703
+ # which yields zero write targets and emits a noisy warning
704
+ # per trigger row. Skip non-SQL languages entirely.
705
+ continue
706
+ conf = 0.85
707
+ targets: set[tuple[str, str]] = set()
708
+ for ts, tt in dml_write_targets(
709
+ body, dialect="postgres", default_schema=source_schema
710
+ ):
711
+ targets.add((ts, tt))
712
+ for tgt_schema, tgt_table in targets:
713
+ if not self._schema_visible(tgt_schema, schema_filter):
714
+ continue
715
+ if tgt_schema == source_schema and tgt_table == source_table:
716
+ continue
717
+ deps.append(
718
+ ViewDependency(
719
+ source_schema=source_schema,
720
+ source_table=source_table,
721
+ target_schema=tgt_schema,
722
+ target_table=tgt_table,
723
+ lineage_type="TRIGGER",
724
+ object_name=f"{source_schema}.{source_table}:{tgname}",
725
+ confidence=conf,
726
+ )
727
+ )
728
+ return deps
729
+
730
+ def load_deep_catalog(
731
+ self, schema_filter: SchemaFilter
732
+ ) -> tuple[
733
+ dict[tuple[str, str], dict[str, Any]],
734
+ dict[tuple[str, str, str], dict[str, Any]],
735
+ ]:
736
+ """Usage stats, partitions, grants (``SchemaTable``) and ``pg_stats`` (columns)."""
737
+ table_extra: dict[tuple[str, str], dict[str, Any]] = {}
738
+ column_extra: dict[tuple[str, str, str], dict[str, Any]] = {}
739
+
740
+ usage_sql = text("""\
741
+ SELECT schemaname, relname,
742
+ seq_scan, idx_scan, n_tup_ins, n_tup_upd, n_tup_del
743
+ FROM pg_stat_user_tables
744
+ """)
745
+ part_sql = text("""\
746
+ SELECT n.nspname AS table_schema,
747
+ c.relname AS table_name,
748
+ a.attname AS partition_column
749
+ FROM pg_catalog.pg_partitioned_table pt
750
+ JOIN pg_catalog.pg_class c ON c.oid = pt.partrelid
751
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
752
+ JOIN pg_catalog.pg_attribute a
753
+ ON a.attrelid = c.oid
754
+ AND a.attnum = pt.partattrs[1] -- first key column only; multi-column keys are truncated
755
+ AND NOT a.attisdropped
756
+ """)
757
+ grant_sql = text("""\
758
+ SELECT table_schema, table_name, grantee, privilege_type
759
+ FROM information_schema.role_table_grants
760
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
761
+ """)
762
+ stats_sql = text("""\
763
+ SELECT schemaname, tablename, attname,
764
+ most_common_vals::text AS mcv_text,
765
+ histogram_bounds::text AS hb_text,
766
+ correlation,
767
+ n_distinct,
768
+ null_frac
769
+ FROM pg_stats
770
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
771
+ """)
772
+ index_sql = text("""\
773
+ WITH idx_cols AS (
774
+ SELECT n.nspname AS schemaname,
775
+ t.relname AS tablename,
776
+ a.attname AS attname,
777
+ am.amname AS index_type,
778
+ ix.indisunique AS index_is_unique,
779
+ ROW_NUMBER() OVER (
780
+ PARTITION BY t.oid, a.attnum
781
+ ORDER BY ix.indisunique DESC,
782
+ (am.amname = 'btree') DESC
783
+ ) AS rn
784
+ FROM pg_index ix
785
+ JOIN pg_class t ON t.oid = ix.indrelid
786
+ JOIN pg_namespace n ON n.oid = t.relnamespace
787
+ JOIN pg_class ic ON ic.oid = ix.indexrelid
788
+ JOIN pg_am am ON am.oid = ic.relam
789
+ JOIN pg_attribute a
790
+ ON a.attrelid = t.oid
791
+ AND a.attnum = ANY(ix.indkey)
792
+ AND a.attnum > 0
793
+ AND NOT a.attisdropped
794
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
795
+ )
796
+ SELECT schemaname, tablename, attname, index_type, index_is_unique
797
+ FROM idx_cols
798
+ WHERE rn = 1
799
+ """)
800
+
801
+ def _schema_ok(sn: str) -> bool:
802
+ if schema_filter.include and sn not in schema_filter.include:
803
+ return False
804
+ if schema_filter.exclude and sn in schema_filter.exclude:
805
+ return False
806
+ return True
807
+
808
+ with self.engine.connect() as conn:
809
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
810
+ usage_rows = conn.execute(usage_sql).mappings().all()
811
+ if _PROFILE_INDEX:
812
+ print(
813
+ f"[profile] load_deep_catalog.usage_sql: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(usage_rows)} rows)",
814
+ flush=True,
815
+ )
816
+ for row in usage_rows:
817
+ sn = str(row["schemaname"])
818
+ tn = str(row["relname"])
819
+ if not _schema_ok(sn):
820
+ continue
821
+ key = (sn, tn)
822
+ table_extra.setdefault(key, {})
823
+ table_extra[key].update(
824
+ {
825
+ "seq_scan_count": int(row["seq_scan"] or 0),
826
+ "idx_scan_count": int(row["idx_scan"] or 0),
827
+ "insert_count": int(row["n_tup_ins"] or 0),
828
+ "update_count": int(row["n_tup_upd"] or 0),
829
+ "delete_count": int(row["n_tup_del"] or 0),
830
+ }
831
+ )
832
+
833
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
834
+ part_rows = conn.execute(part_sql).mappings().all()
835
+ if _PROFILE_INDEX:
836
+ print(
837
+ f"[profile] load_deep_catalog.part_sql: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(part_rows)} rows)",
838
+ flush=True,
839
+ )
840
+ for row in part_rows:
841
+ sn = str(row["table_schema"])
842
+ tn = str(row["table_name"])
843
+ if not _schema_ok(sn):
844
+ continue
845
+ key = (sn, tn)
846
+ table_extra.setdefault(key, {})
847
+ table_extra[key]["is_partitioned"] = True
848
+ pc = row["partition_column"]
849
+ if pc is not None:
850
+ table_extra[key]["partition_key"] = str(pc)
851
+
852
+ grants_by_table: dict[tuple[str, str], list[dict[str, str]]] = {}
853
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
854
+ grant_rows = conn.execute(grant_sql).mappings().all()
855
+ if _PROFILE_INDEX:
856
+ print(
857
+ f"[profile] load_deep_catalog.grant_sql: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(grant_rows)} rows)",
858
+ flush=True,
859
+ )
860
+ for row in grant_rows:
861
+ sn = str(row["table_schema"])
862
+ tn = str(row["table_name"])
863
+ if not _schema_ok(sn):
864
+ continue
865
+ ge = row["grantee"]
866
+ priv = row["privilege_type"]
867
+ if ge is None or priv is None:
868
+ continue
869
+ gkey = (sn, tn)
870
+ grants_by_table.setdefault(gkey, []).append(
871
+ {"grantee": str(ge), "privilege": str(priv)}
872
+ )
873
+ for gkey, grants in grants_by_table.items():
874
+ table_extra.setdefault(gkey, {})
875
+ table_extra[gkey]["grants"] = grants
876
+
877
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
878
+ stats_rows = conn.execute(stats_sql).mappings().all()
879
+ if _PROFILE_INDEX:
880
+ print(
881
+ f"[profile] load_deep_catalog.stats_sql: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(stats_rows)} rows)",
882
+ flush=True,
883
+ )
884
+ for row in stats_rows:
885
+ sn = str(row["schemaname"])
886
+ tn = str(row["tablename"])
887
+ cn = str(row["attname"])
888
+ if not _schema_ok(sn):
889
+ continue
890
+ ckey = (sn, tn, cn)
891
+ mcv_raw = row["mcv_text"]
892
+ hb_raw = row["hb_text"]
893
+ corr = row["correlation"]
894
+ entry: dict[str, Any] = {}
895
+ if mcv_raw:
896
+ parsed = parse_pg_array_literal(str(mcv_raw))
897
+ if parsed:
898
+ entry["most_common_values"] = parsed[:PG_STATS_MOST_COMMON_CAP]
899
+ if hb_raw:
900
+ hb_parsed = parse_pg_array_literal(str(hb_raw))
901
+ if hb_parsed:
902
+ entry["histogram_bounds"] = hb_parsed
903
+ if corr is not None:
904
+ entry["stats_correlation"] = float(corr)
905
+ n_distinct = row["n_distinct"]
906
+ if n_distinct is not None:
907
+ entry["column_cardinality"] = int(round(float(n_distinct)))
908
+ null_frac = row["null_frac"]
909
+ if null_frac is not None:
910
+ entry["null_percentage"] = float(null_frac) * 100.0
911
+ if entry:
912
+ column_extra[ckey] = entry
913
+
914
+ _t = time.perf_counter() if _PROFILE_INDEX else 0.0
915
+ index_rows = conn.execute(index_sql).mappings().all()
916
+ if _PROFILE_INDEX:
917
+ print(
918
+ f"[profile] load_deep_catalog.index_sql: {(time.perf_counter() - _t) * 1000:.0f}ms ({len(index_rows)} rows)",
919
+ flush=True,
920
+ )
921
+ for row in index_rows:
922
+ sn = str(row["schemaname"])
923
+ tn = str(row["tablename"])
924
+ cn = str(row["attname"])
925
+ if not _schema_ok(sn):
926
+ continue
927
+ ckey = (sn, tn, cn)
928
+ column_extra.setdefault(ckey, {})
929
+ column_extra[ckey]["index_type"] = str(row["index_type"])
930
+ column_extra[ckey]["index_is_unique"] = bool(row["index_is_unique"])
931
+
932
+ return table_extra, column_extra
933
+
934
+
935
+ def _quote_ident(identifier: str) -> str:
936
+ """Quote a SQL identifier to prevent injection. Double any internal quotes."""
937
+ escaped = identifier.replace('"', '""')
938
+ return f'"{escaped}"'