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,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}"'
|