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,908 @@
|
|
|
1
|
+
"""Snowflake connector using SQLAlchemy (snowflake-sqlalchemy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import create_engine, text
|
|
11
|
+
from sqlalchemy.engine import Engine
|
|
12
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
13
|
+
|
|
14
|
+
from pretensor.connectors.base import (
|
|
15
|
+
BaseConnector,
|
|
16
|
+
ColumnInfo,
|
|
17
|
+
ColumnStats,
|
|
18
|
+
ForeignKeyInfo,
|
|
19
|
+
TableGrant,
|
|
20
|
+
TableInfo,
|
|
21
|
+
)
|
|
22
|
+
from pretensor.connectors.lineage_sqlglot import dml_write_targets, table_refs_from_sql
|
|
23
|
+
from pretensor.connectors.models import ViewDependency
|
|
24
|
+
from pretensor.errors import ConnectorError as _ConnectorError
|
|
25
|
+
from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_FQ_TABLE_QUOTED_RE = re.compile(
|
|
30
|
+
r'^"(?P<db>[^"]+)"\."(?P<schema>[^"]+)"\."(?P<table>[^"]+)"$'
|
|
31
|
+
)
|
|
32
|
+
_STALE_ACCESS_DAYS = 90
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _safe_int(value: Any, default: int = 0) -> int:
|
|
36
|
+
try:
|
|
37
|
+
return int(value)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_snowflake_table_fqn(fq: str, expected_db: str) -> tuple[str, str] | None:
|
|
43
|
+
"""Return ``(schema, table)`` when FQN targets ``expected_db``."""
|
|
44
|
+
m = _FQ_TABLE_QUOTED_RE.match(fq.strip())
|
|
45
|
+
if m:
|
|
46
|
+
if m.group("db").upper() != expected_db.upper():
|
|
47
|
+
return None
|
|
48
|
+
return m.group("schema"), m.group("table")
|
|
49
|
+
parts = fq.split(".")
|
|
50
|
+
if len(parts) == 3:
|
|
51
|
+
db, sn, tn = (p.strip().strip('"') for p in parts)
|
|
52
|
+
if db.upper() != expected_db.upper():
|
|
53
|
+
return None
|
|
54
|
+
return sn, tn
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SnowflakeConnectorError(_ConnectorError):
|
|
59
|
+
"""Raised when Snowflake connector operations fail."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sf_table_type(raw: str | None) -> str:
|
|
63
|
+
if not raw:
|
|
64
|
+
return "table"
|
|
65
|
+
u = raw.upper()
|
|
66
|
+
if u == "BASE TABLE":
|
|
67
|
+
return "table"
|
|
68
|
+
if u == "VIEW":
|
|
69
|
+
return "view"
|
|
70
|
+
if u == "MATERIALIZED VIEW":
|
|
71
|
+
return "materialized_view"
|
|
72
|
+
if u == "EXTERNAL TABLE":
|
|
73
|
+
return "foreign_table"
|
|
74
|
+
return raw.lower().replace(" ", "_")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _quote_ident(name: str) -> str:
|
|
78
|
+
"""Quote a Snowflake identifier with double-quote escaping."""
|
|
79
|
+
return f'"{name.replace(chr(34), chr(34) * 2)}"'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_private_key_bytes(path: str, passphrase: str | None) -> bytes:
|
|
83
|
+
"""Read a PEM private key file and return its DER-encoded bytes for Snowflake."""
|
|
84
|
+
from pathlib import Path as _Path
|
|
85
|
+
|
|
86
|
+
key_path = _Path(path).expanduser().resolve()
|
|
87
|
+
try:
|
|
88
|
+
key_data = key_path.read_bytes()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
raise SnowflakeConnectorError(
|
|
91
|
+
f"Private key file not found: {key_path}"
|
|
92
|
+
) from None
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise SnowflakeConnectorError(
|
|
95
|
+
f"Cannot read private key file {key_path}: {exc}"
|
|
96
|
+
) from exc
|
|
97
|
+
try:
|
|
98
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
99
|
+
Encoding,
|
|
100
|
+
NoEncryption,
|
|
101
|
+
PrivateFormat,
|
|
102
|
+
load_pem_private_key,
|
|
103
|
+
)
|
|
104
|
+
except ImportError as exc:
|
|
105
|
+
raise SnowflakeConnectorError(
|
|
106
|
+
"Private key authentication requires the `cryptography` package. "
|
|
107
|
+
"Install `pretensor[snowflake]` to get it."
|
|
108
|
+
) from exc
|
|
109
|
+
try:
|
|
110
|
+
pkey = load_pem_private_key(
|
|
111
|
+
key_data,
|
|
112
|
+
password=passphrase.encode() if passphrase else None,
|
|
113
|
+
)
|
|
114
|
+
return pkey.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
|
|
115
|
+
except SnowflakeConnectorError:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
raise SnowflakeConnectorError(
|
|
119
|
+
f"Invalid private key in {key_path}: {exc}"
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SnowflakeConnector(BaseConnector):
|
|
124
|
+
"""Introspect Snowflake via INFORMATION_SCHEMA and SHOW commands."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, config: ConnectionConfig) -> None:
|
|
127
|
+
super().__init__(config)
|
|
128
|
+
self._engine: Engine | None = None
|
|
129
|
+
self._pk_cache: dict[tuple[str, str], set[str]] | None = None
|
|
130
|
+
|
|
131
|
+
def _snowflake_url(self) -> str:
|
|
132
|
+
cfg = self.config
|
|
133
|
+
account = (cfg.host or "").strip()
|
|
134
|
+
if not account:
|
|
135
|
+
raise SnowflakeConnectorError("Snowflake account (DSN host) is required")
|
|
136
|
+
|
|
137
|
+
user = cfg.user or ""
|
|
138
|
+
password = cfg.password or ""
|
|
139
|
+
database = (cfg.database or "").strip()
|
|
140
|
+
if not database:
|
|
141
|
+
raise SnowflakeConnectorError(
|
|
142
|
+
"Snowflake database name is required in the DSN path"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
extra = cfg.metadata_extra or {}
|
|
146
|
+
schema = (extra.get("snowflake_schema") or "").strip()
|
|
147
|
+
warehouse = (extra.get("warehouse") or "").strip()
|
|
148
|
+
role = (extra.get("role") or "").strip()
|
|
149
|
+
|
|
150
|
+
path = f"/{database}"
|
|
151
|
+
if schema:
|
|
152
|
+
path += f"/{schema}"
|
|
153
|
+
|
|
154
|
+
query_parts: list[str] = []
|
|
155
|
+
if warehouse:
|
|
156
|
+
from urllib.parse import quote
|
|
157
|
+
|
|
158
|
+
query_parts.append(f"warehouse={quote(warehouse, safe='')}")
|
|
159
|
+
if role:
|
|
160
|
+
from urllib.parse import quote
|
|
161
|
+
|
|
162
|
+
query_parts.append(f"role={quote(role, safe='')}")
|
|
163
|
+
|
|
164
|
+
q = ("?" + "&".join(query_parts)) if query_parts else ""
|
|
165
|
+
private_key_path = (extra.get("private_key_path") or "").strip()
|
|
166
|
+
if private_key_path:
|
|
167
|
+
# Key-pair auth: password is passed via connect_args, not in the URL.
|
|
168
|
+
return f"snowflake://{user}@{account}{path}{q}"
|
|
169
|
+
return f"snowflake://{user}:{password}@{account}{path}{q}"
|
|
170
|
+
|
|
171
|
+
def connect(self) -> None:
|
|
172
|
+
extra = self.config.metadata_extra or {}
|
|
173
|
+
private_key_path = (extra.get("private_key_path") or "").strip()
|
|
174
|
+
engine_kwargs: dict[str, Any] = {"pool_pre_ping": True}
|
|
175
|
+
if private_key_path:
|
|
176
|
+
pkb = _load_private_key_bytes(
|
|
177
|
+
private_key_path, extra.get("private_key_passphrase") or None
|
|
178
|
+
)
|
|
179
|
+
engine_kwargs["connect_args"] = {"private_key": pkb}
|
|
180
|
+
url = self._snowflake_url()
|
|
181
|
+
try:
|
|
182
|
+
self._engine = create_engine(url, **engine_kwargs)
|
|
183
|
+
with self._engine.connect() as conn:
|
|
184
|
+
conn.execute(text("SELECT 1"))
|
|
185
|
+
logger.info(
|
|
186
|
+
"Connected to Snowflake account %s db %s",
|
|
187
|
+
self.config.host,
|
|
188
|
+
self.config.database,
|
|
189
|
+
)
|
|
190
|
+
except SnowflakeConnectorError:
|
|
191
|
+
raise
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
# The driver error may echo the DSN (incl. password); keep it in the
|
|
194
|
+
# server log only and raise an account/db-scoped message with no
|
|
195
|
+
# secret. (The Snowflake dialect uses a two-segment ``/db/schema``
|
|
196
|
+
# path that the generic ``URL.create`` cannot represent, so the URL
|
|
197
|
+
# is still assembled as a string above; SQLAlchemy masks the
|
|
198
|
+
# password in its own URL repr.)
|
|
199
|
+
logger.warning(
|
|
200
|
+
"Snowflake connection failed for account %s db %s: %s",
|
|
201
|
+
self.config.host,
|
|
202
|
+
self.config.database,
|
|
203
|
+
exc,
|
|
204
|
+
)
|
|
205
|
+
raise SnowflakeConnectorError(
|
|
206
|
+
f"Failed to connect to Snowflake account {self.config.host!r} "
|
|
207
|
+
f"database {self.config.database!r}"
|
|
208
|
+
) from exc
|
|
209
|
+
|
|
210
|
+
def disconnect(self) -> None:
|
|
211
|
+
if self._engine is not None:
|
|
212
|
+
self._engine.dispose()
|
|
213
|
+
self._engine = None
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def engine(self) -> Engine:
|
|
217
|
+
if self._engine is None:
|
|
218
|
+
raise SnowflakeConnectorError("Not connected.")
|
|
219
|
+
return self._engine
|
|
220
|
+
|
|
221
|
+
def _qualified_information_schema(self) -> str:
|
|
222
|
+
db = (self.config.database or "").replace('"', '""')
|
|
223
|
+
return f'"{db}".INFORMATION_SCHEMA'
|
|
224
|
+
|
|
225
|
+
def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
|
|
226
|
+
sf = schema_filter or self.config.schema_filter
|
|
227
|
+
ischema = self._qualified_information_schema()
|
|
228
|
+
|
|
229
|
+
query = text(f"""\
|
|
230
|
+
SELECT
|
|
231
|
+
table_schema,
|
|
232
|
+
table_name,
|
|
233
|
+
table_type,
|
|
234
|
+
comment,
|
|
235
|
+
row_count
|
|
236
|
+
FROM {ischema}.TABLES
|
|
237
|
+
WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
|
|
238
|
+
ORDER BY table_schema, table_name
|
|
239
|
+
""")
|
|
240
|
+
|
|
241
|
+
with self.engine.connect() as conn:
|
|
242
|
+
rows = conn.execute(query).mappings().all()
|
|
243
|
+
|
|
244
|
+
out: list[TableInfo] = []
|
|
245
|
+
for row in rows:
|
|
246
|
+
schema_name = str(row["table_schema"])
|
|
247
|
+
if sf.include and schema_name not in sf.include:
|
|
248
|
+
continue
|
|
249
|
+
if sf.exclude and schema_name in sf.exclude:
|
|
250
|
+
continue
|
|
251
|
+
rc = row["row_count"]
|
|
252
|
+
out.append(
|
|
253
|
+
TableInfo(
|
|
254
|
+
name=str(row["table_name"]),
|
|
255
|
+
schema_name=schema_name,
|
|
256
|
+
row_count=int(rc) if rc is not None else None,
|
|
257
|
+
comment=str(row["comment"]) if row["comment"] else None,
|
|
258
|
+
table_type=_sf_table_type(
|
|
259
|
+
str(row["table_type"]) if row["table_type"] else None
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
|
|
266
|
+
ischema = self._qualified_information_schema()
|
|
267
|
+
query = text(f"""\
|
|
268
|
+
SELECT
|
|
269
|
+
column_name,
|
|
270
|
+
ordinal_position,
|
|
271
|
+
data_type,
|
|
272
|
+
is_nullable,
|
|
273
|
+
column_default,
|
|
274
|
+
comment
|
|
275
|
+
FROM {ischema}.COLUMNS
|
|
276
|
+
WHERE table_schema = :schema_name
|
|
277
|
+
AND table_name = :table_name
|
|
278
|
+
ORDER BY ordinal_position
|
|
279
|
+
""")
|
|
280
|
+
|
|
281
|
+
pk_cols = self._primary_key_columns(schema_name, table_name)
|
|
282
|
+
checks_by_col = self._load_check_constraints_for_table(schema_name, table_name)
|
|
283
|
+
|
|
284
|
+
with self.engine.connect() as conn:
|
|
285
|
+
rows = (
|
|
286
|
+
conn.execute(
|
|
287
|
+
query,
|
|
288
|
+
{"schema_name": schema_name, "table_name": table_name},
|
|
289
|
+
)
|
|
290
|
+
.mappings()
|
|
291
|
+
.all()
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
columns: list[ColumnInfo] = []
|
|
295
|
+
for row in rows:
|
|
296
|
+
cname = str(row["column_name"])
|
|
297
|
+
ord_raw = row["ordinal_position"]
|
|
298
|
+
columns.append(
|
|
299
|
+
ColumnInfo(
|
|
300
|
+
name=cname,
|
|
301
|
+
data_type=str(row["data_type"] or ""),
|
|
302
|
+
nullable=str(row["is_nullable"] or "").upper() == "YES",
|
|
303
|
+
is_primary_key=cname in pk_cols,
|
|
304
|
+
default_value=str(row["column_default"])
|
|
305
|
+
if row["column_default"] is not None
|
|
306
|
+
else None,
|
|
307
|
+
comment=str(row["comment"]) if row["comment"] else None,
|
|
308
|
+
is_indexed=False,
|
|
309
|
+
check_constraints=list(checks_by_col.get(cname, [])),
|
|
310
|
+
ordinal_position=int(ord_raw) if ord_raw is not None else None,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return columns
|
|
314
|
+
|
|
315
|
+
def _load_primary_keys_for_schema(self, schema_name: str) -> None:
|
|
316
|
+
"""Populate ``_pk_cache`` via ``SHOW PRIMARY KEYS IN SCHEMA``.
|
|
317
|
+
|
|
318
|
+
Uses ``exec_driver_sql`` because SHOW commands don't support bind
|
|
319
|
+
params. Result columns by index: 0=created_on, 1=database_name,
|
|
320
|
+
2=schema_name, 3=table_name, 4=column_name, 5=key_sequence,
|
|
321
|
+
6=constraint_name.
|
|
322
|
+
"""
|
|
323
|
+
db = _quote_ident(self.config.database or "")
|
|
324
|
+
schema = _quote_ident(schema_name)
|
|
325
|
+
sql = f"SHOW PRIMARY KEYS IN SCHEMA {db}.{schema}"
|
|
326
|
+
cache: dict[tuple[str, str], set[str]] = {}
|
|
327
|
+
try:
|
|
328
|
+
with self.engine.connect() as conn:
|
|
329
|
+
rows = conn.exec_driver_sql(sql).all()
|
|
330
|
+
for row in rows:
|
|
331
|
+
sn, tn, col = str(row[2]), str(row[3]), str(row[4])
|
|
332
|
+
cache.setdefault((sn, tn), set()).add(col)
|
|
333
|
+
logger.debug(
|
|
334
|
+
"Loaded %d PK entries for %s.%s",
|
|
335
|
+
sum(len(v) for v in cache.values()),
|
|
336
|
+
self.config.database,
|
|
337
|
+
schema_name,
|
|
338
|
+
)
|
|
339
|
+
except Exception:
|
|
340
|
+
logger.debug(
|
|
341
|
+
"SHOW PRIMARY KEYS failed for %s.%s; PK info unavailable",
|
|
342
|
+
self.config.database,
|
|
343
|
+
schema_name,
|
|
344
|
+
)
|
|
345
|
+
self._pk_cache = cache
|
|
346
|
+
|
|
347
|
+
def _primary_key_columns(self, schema_name: str, table_name: str) -> set[str]:
|
|
348
|
+
if self._pk_cache is None:
|
|
349
|
+
self._load_primary_keys_for_schema(schema_name)
|
|
350
|
+
assert self._pk_cache is not None # populated by _load above
|
|
351
|
+
return self._pk_cache.get((schema_name, table_name), set())
|
|
352
|
+
|
|
353
|
+
def _load_check_constraints_for_table(
|
|
354
|
+
self, schema_name: str, table_name: str
|
|
355
|
+
) -> dict[str, list[str]]:
|
|
356
|
+
# Snowflake doesn't enforce CHECK constraints and exposes no SHOW
|
|
357
|
+
# equivalent — return empty unconditionally.
|
|
358
|
+
return {}
|
|
359
|
+
|
|
360
|
+
def get_foreign_keys(self) -> list[ForeignKeyInfo]:
|
|
361
|
+
show_fks, show_succeeded = self._foreign_keys_from_show()
|
|
362
|
+
if show_fks:
|
|
363
|
+
return show_fks
|
|
364
|
+
if show_succeeded:
|
|
365
|
+
logger.info(
|
|
366
|
+
"SHOW IMPORTED KEYS returned no rows for %s; trying INFORMATION_SCHEMA fallback",
|
|
367
|
+
self.config.database,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
logger.info(
|
|
371
|
+
"SHOW IMPORTED KEYS failed for %s; trying INFORMATION_SCHEMA fallback",
|
|
372
|
+
self.config.database,
|
|
373
|
+
)
|
|
374
|
+
return self._foreign_keys_from_information_schema()
|
|
375
|
+
|
|
376
|
+
def _foreign_keys_from_show(self) -> tuple[list[ForeignKeyInfo], bool]:
|
|
377
|
+
"""Fetch FKs via ``SHOW IMPORTED KEYS IN DATABASE``.
|
|
378
|
+
|
|
379
|
+
Result columns by index: 0=created_on, 1=pk_database_name,
|
|
380
|
+
2=pk_schema_name, 3=pk_table_name, 4=pk_column_name,
|
|
381
|
+
5=fk_database_name, 6=fk_schema_name, 7=fk_table_name,
|
|
382
|
+
8=fk_column_name, 9=key_sequence, 10=update_rule,
|
|
383
|
+
11=delete_rule, 12=fk_name, …
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Tuple of ``(foreign_keys, show_succeeded)`` where
|
|
387
|
+
``show_succeeded`` is False only if SHOW itself errored.
|
|
388
|
+
"""
|
|
389
|
+
db = _quote_ident(self.config.database or "")
|
|
390
|
+
sql = f"SHOW IMPORTED KEYS IN DATABASE {db}"
|
|
391
|
+
try:
|
|
392
|
+
with self.engine.connect() as conn:
|
|
393
|
+
rows = conn.exec_driver_sql(sql).all()
|
|
394
|
+
except Exception:
|
|
395
|
+
logger.debug(
|
|
396
|
+
"SHOW IMPORTED KEYS failed for %s",
|
|
397
|
+
self.config.database,
|
|
398
|
+
)
|
|
399
|
+
return [], False
|
|
400
|
+
if not rows:
|
|
401
|
+
return [], True
|
|
402
|
+
|
|
403
|
+
# Sort by fk_schema(6), fk_table(7), fk_name(12), key_sequence(9)
|
|
404
|
+
# so composite FK columns are ordered correctly for downstream grouping.
|
|
405
|
+
sorted_rows = sorted(
|
|
406
|
+
rows,
|
|
407
|
+
key=lambda r: (str(r[6]), str(r[7]), str(r[12]), _safe_int(r[9])),
|
|
408
|
+
)
|
|
409
|
+
fks = [
|
|
410
|
+
ForeignKeyInfo(
|
|
411
|
+
constraint_name=str(row[12]),
|
|
412
|
+
source_schema=str(row[6]),
|
|
413
|
+
source_table=str(row[7]),
|
|
414
|
+
source_column=str(row[8]),
|
|
415
|
+
target_schema=str(row[2]),
|
|
416
|
+
target_table=str(row[3]),
|
|
417
|
+
target_column=str(row[4]),
|
|
418
|
+
)
|
|
419
|
+
for row in sorted_rows
|
|
420
|
+
]
|
|
421
|
+
return fks, True
|
|
422
|
+
|
|
423
|
+
def _foreign_keys_from_information_schema(self) -> list[ForeignKeyInfo]:
|
|
424
|
+
ischema = self._qualified_information_schema()
|
|
425
|
+
query = text(f"""\
|
|
426
|
+
SELECT
|
|
427
|
+
tc.constraint_name,
|
|
428
|
+
kcu_src.table_schema AS source_schema,
|
|
429
|
+
kcu_src.table_name AS source_table,
|
|
430
|
+
kcu_src.column_name AS source_column,
|
|
431
|
+
kcu_ref.table_schema AS target_schema,
|
|
432
|
+
kcu_ref.table_name AS target_table,
|
|
433
|
+
kcu_ref.column_name AS target_column,
|
|
434
|
+
kcu_src.ordinal_position AS source_ordinal_position
|
|
435
|
+
FROM {ischema}.TABLE_CONSTRAINTS tc
|
|
436
|
+
JOIN {ischema}.KEY_COLUMN_USAGE kcu_src
|
|
437
|
+
ON tc.constraint_name = kcu_src.constraint_name
|
|
438
|
+
AND tc.constraint_schema = kcu_src.constraint_schema
|
|
439
|
+
AND tc.table_schema = kcu_src.table_schema
|
|
440
|
+
AND tc.table_name = kcu_src.table_name
|
|
441
|
+
JOIN {ischema}.REFERENTIAL_CONSTRAINTS rc
|
|
442
|
+
ON tc.constraint_name = rc.constraint_name
|
|
443
|
+
AND tc.constraint_schema = rc.constraint_schema
|
|
444
|
+
JOIN {ischema}.KEY_COLUMN_USAGE kcu_ref
|
|
445
|
+
ON rc.unique_constraint_name = kcu_ref.constraint_name
|
|
446
|
+
AND rc.unique_constraint_schema = kcu_ref.constraint_schema
|
|
447
|
+
AND kcu_src.position_in_unique_constraint = kcu_ref.ordinal_position
|
|
448
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
449
|
+
""")
|
|
450
|
+
try:
|
|
451
|
+
with self.engine.connect() as conn:
|
|
452
|
+
rows = conn.execute(query).mappings().all()
|
|
453
|
+
except SQLAlchemyError:
|
|
454
|
+
logger.debug(
|
|
455
|
+
"INFORMATION_SCHEMA FK fallback failed for %s",
|
|
456
|
+
self.config.database,
|
|
457
|
+
)
|
|
458
|
+
return []
|
|
459
|
+
sorted_rows = sorted(
|
|
460
|
+
rows,
|
|
461
|
+
key=lambda row: (
|
|
462
|
+
str(row["source_schema"]),
|
|
463
|
+
str(row["source_table"]),
|
|
464
|
+
str(row["constraint_name"]),
|
|
465
|
+
_safe_int(row["source_ordinal_position"]),
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
return [
|
|
469
|
+
ForeignKeyInfo(
|
|
470
|
+
constraint_name=str(row["constraint_name"]),
|
|
471
|
+
source_schema=str(row["source_schema"]),
|
|
472
|
+
source_table=str(row["source_table"]),
|
|
473
|
+
source_column=str(row["source_column"]),
|
|
474
|
+
target_schema=str(row["target_schema"]),
|
|
475
|
+
target_table=str(row["target_table"]),
|
|
476
|
+
target_column=str(row["target_column"]),
|
|
477
|
+
)
|
|
478
|
+
for row in sorted_rows
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
def get_table_row_count(self, table_name: str, schema_name: str) -> int:
|
|
482
|
+
ischema = self._qualified_information_schema()
|
|
483
|
+
q = text(f"""\
|
|
484
|
+
SELECT row_count
|
|
485
|
+
FROM {ischema}.TABLES
|
|
486
|
+
WHERE table_schema = :sn AND table_name = :tn
|
|
487
|
+
""")
|
|
488
|
+
with self.engine.connect() as conn:
|
|
489
|
+
row = conn.execute(q, {"sn": schema_name, "tn": table_name}).first()
|
|
490
|
+
if row is None or row[0] is None:
|
|
491
|
+
return 0
|
|
492
|
+
return int(row[0])
|
|
493
|
+
|
|
494
|
+
def get_column_stats(
|
|
495
|
+
self, table_name: str, column_name: str, schema_name: str
|
|
496
|
+
) -> ColumnStats:
|
|
497
|
+
return ColumnStats()
|
|
498
|
+
|
|
499
|
+
def execute_query(self, sql: str) -> list[dict[str, Any]]:
|
|
500
|
+
with self.engine.connect() as conn:
|
|
501
|
+
rows = conn.execute(text(sql)).mappings().all()
|
|
502
|
+
return [dict(row) for row in rows]
|
|
503
|
+
|
|
504
|
+
def get_table_grants(
|
|
505
|
+
self, schema_filter: SchemaFilter | None = None
|
|
506
|
+
) -> list[TableGrant]:
|
|
507
|
+
"""Return direct SELECT grants on tables from OBJECT_PRIVILEGES.
|
|
508
|
+
|
|
509
|
+
Uses ``GRANTEE``, ``OBJECT_SCHEMA``, and ``OBJECT_NAME`` (aliased in SQL).
|
|
510
|
+
Recursive expansion of nested roles (``SHOW GRANTS TO ROLE``) is not
|
|
511
|
+
implemented; only privileges visible in INFORMATION_SCHEMA are returned.
|
|
512
|
+
"""
|
|
513
|
+
sf = schema_filter or self.config.schema_filter
|
|
514
|
+
ischema = self._qualified_information_schema()
|
|
515
|
+
query = text(f"""\
|
|
516
|
+
SELECT
|
|
517
|
+
GRANTEE AS grantee_name,
|
|
518
|
+
OBJECT_SCHEMA AS table_schema,
|
|
519
|
+
OBJECT_NAME AS table_name
|
|
520
|
+
FROM {ischema}.OBJECT_PRIVILEGES
|
|
521
|
+
WHERE PRIVILEGE_TYPE = 'SELECT'
|
|
522
|
+
AND OBJECT_TYPE = 'TABLE'
|
|
523
|
+
""")
|
|
524
|
+
try:
|
|
525
|
+
with self.engine.connect() as conn:
|
|
526
|
+
rows = conn.execute(query).mappings().all()
|
|
527
|
+
except SQLAlchemyError as exc:
|
|
528
|
+
logger.warning(
|
|
529
|
+
"Could not read SELECT grants from information_schema.object_privileges: %s",
|
|
530
|
+
exc,
|
|
531
|
+
)
|
|
532
|
+
return []
|
|
533
|
+
|
|
534
|
+
seen: set[tuple[str, str, str]] = set()
|
|
535
|
+
out: list[TableGrant] = []
|
|
536
|
+
for row in rows:
|
|
537
|
+
ge = row["grantee_name"]
|
|
538
|
+
ts = row["table_schema"]
|
|
539
|
+
tn = row["table_name"]
|
|
540
|
+
if ge is None or ts is None or tn is None:
|
|
541
|
+
continue
|
|
542
|
+
grantee = str(ge)
|
|
543
|
+
schema_name = str(ts)
|
|
544
|
+
table_name = str(tn)
|
|
545
|
+
if not self._schema_ok(schema_name, sf):
|
|
546
|
+
continue
|
|
547
|
+
key = (grantee, schema_name, table_name)
|
|
548
|
+
if key in seen:
|
|
549
|
+
continue
|
|
550
|
+
seen.add(key)
|
|
551
|
+
out.append(
|
|
552
|
+
TableGrant(
|
|
553
|
+
grantee=grantee,
|
|
554
|
+
schema_name=schema_name,
|
|
555
|
+
table_name=table_name,
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
out.sort(key=lambda tg: (tg.schema_name, tg.table_name, tg.grantee))
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
def _schema_ok(self, sn: str, schema_filter: SchemaFilter) -> bool:
|
|
562
|
+
if schema_filter.include and sn not in schema_filter.include:
|
|
563
|
+
return False
|
|
564
|
+
if schema_filter.exclude and sn in schema_filter.exclude:
|
|
565
|
+
return False
|
|
566
|
+
return sn.upper() != "INFORMATION_SCHEMA"
|
|
567
|
+
|
|
568
|
+
def _sf_lineage_type(self, domain: str | None) -> str | None:
|
|
569
|
+
if not domain:
|
|
570
|
+
return None
|
|
571
|
+
u = str(domain).strip().upper().replace(" ", "_")
|
|
572
|
+
if u == "MATERIALIZED_VIEW" or u == "MATERIALIZEDVIEW":
|
|
573
|
+
return "MATERIALIZED_VIEW"
|
|
574
|
+
if u in ("VIEW", "TRIGGER", "TASK", "STREAM"):
|
|
575
|
+
return u
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
def load_view_dependencies(
|
|
579
|
+
self, schema_filter: SchemaFilter
|
|
580
|
+
) -> list[ViewDependency]:
|
|
581
|
+
"""Lineage from OBJECT_DEPENDENCIES, view SQL, SHOW STREAMS, and SHOW TASKS."""
|
|
582
|
+
deps: list[ViewDependency] = []
|
|
583
|
+
obj_dep_keys: set[tuple[str, str, str, str]] = set()
|
|
584
|
+
deps.extend(self._snowflake_object_dependencies(schema_filter, obj_dep_keys))
|
|
585
|
+
deps.extend(self._snowflake_view_sql_fallback(schema_filter, obj_dep_keys))
|
|
586
|
+
deps.extend(self._snowflake_streams_lineage(schema_filter))
|
|
587
|
+
deps.extend(self._snowflake_tasks_lineage(schema_filter))
|
|
588
|
+
return deps
|
|
589
|
+
|
|
590
|
+
def _snowflake_object_dependencies(
|
|
591
|
+
self,
|
|
592
|
+
schema_filter: SchemaFilter,
|
|
593
|
+
obj_dep_keys: set[tuple[str, str, str, str]],
|
|
594
|
+
) -> list[ViewDependency]:
|
|
595
|
+
out: list[ViewDependency] = []
|
|
596
|
+
ischema = self._qualified_information_schema()
|
|
597
|
+
q = text(f"""\
|
|
598
|
+
SELECT
|
|
599
|
+
referencing_object_schema,
|
|
600
|
+
referencing_object_name,
|
|
601
|
+
referencing_object_domain,
|
|
602
|
+
referenced_object_schema,
|
|
603
|
+
referenced_object_name
|
|
604
|
+
FROM {ischema}.OBJECT_DEPENDENCIES
|
|
605
|
+
WHERE referenced_object_domain = 'TABLE'
|
|
606
|
+
""")
|
|
607
|
+
try:
|
|
608
|
+
with self.engine.connect() as conn:
|
|
609
|
+
rows = conn.execute(q).mappings().all()
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
logger.debug("Snowflake OBJECT_DEPENDENCIES unavailable: %s", exc)
|
|
612
|
+
return out
|
|
613
|
+
for row in rows:
|
|
614
|
+
ref_schema = str(row["referenced_object_schema"] or "")
|
|
615
|
+
ref_name = str(row["referenced_object_name"] or "")
|
|
616
|
+
tgt_schema = str(row["referencing_object_schema"] or "")
|
|
617
|
+
tgt_name = str(row["referencing_object_name"] or "")
|
|
618
|
+
dom = row["referencing_object_domain"]
|
|
619
|
+
ltype = self._sf_lineage_type(str(dom) if dom is not None else None)
|
|
620
|
+
if ltype is None:
|
|
621
|
+
continue
|
|
622
|
+
if not self._schema_ok(ref_schema, schema_filter):
|
|
623
|
+
continue
|
|
624
|
+
if not self._schema_ok(tgt_schema, schema_filter):
|
|
625
|
+
continue
|
|
626
|
+
obj = f"{tgt_schema}.{tgt_name}"
|
|
627
|
+
out.append(
|
|
628
|
+
ViewDependency(
|
|
629
|
+
source_schema=ref_schema,
|
|
630
|
+
source_table=ref_name,
|
|
631
|
+
target_schema=tgt_schema,
|
|
632
|
+
target_table=tgt_name,
|
|
633
|
+
lineage_type=ltype,
|
|
634
|
+
object_name=obj,
|
|
635
|
+
confidence=1.0,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
obj_dep_keys.add((tgt_schema, tgt_name, ref_schema, ref_name))
|
|
639
|
+
return out
|
|
640
|
+
|
|
641
|
+
def _snowflake_view_sql_fallback(
|
|
642
|
+
self,
|
|
643
|
+
schema_filter: SchemaFilter,
|
|
644
|
+
obj_dep_keys: set[tuple[str, str, str, str]],
|
|
645
|
+
) -> list[ViewDependency]:
|
|
646
|
+
out: list[ViewDependency] = []
|
|
647
|
+
ischema = self._qualified_information_schema()
|
|
648
|
+
q = text(f"""\
|
|
649
|
+
SELECT table_schema, table_name, view_definition
|
|
650
|
+
FROM {ischema}.VIEWS
|
|
651
|
+
WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
|
|
652
|
+
""")
|
|
653
|
+
try:
|
|
654
|
+
with self.engine.connect() as conn:
|
|
655
|
+
rows = conn.execute(q).mappings().all()
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
logger.debug("Snowflake VIEWS lineage fallback skipped: %s", exc)
|
|
658
|
+
return out
|
|
659
|
+
for row in rows:
|
|
660
|
+
ts = str(row["table_schema"] or "")
|
|
661
|
+
if not self._schema_ok(ts, schema_filter):
|
|
662
|
+
continue
|
|
663
|
+
tname = str(row["table_name"] or "")
|
|
664
|
+
vd = row["view_definition"]
|
|
665
|
+
if vd is None:
|
|
666
|
+
continue
|
|
667
|
+
sql_text = str(vd)
|
|
668
|
+
for src_schema, src_table in table_refs_from_sql(
|
|
669
|
+
sql_text, dialect="snowflake", default_schema=ts
|
|
670
|
+
):
|
|
671
|
+
if not self._schema_ok(src_schema, schema_filter):
|
|
672
|
+
continue
|
|
673
|
+
if src_schema == ts and src_table == tname:
|
|
674
|
+
continue
|
|
675
|
+
key = (ts, tname, src_schema, src_table)
|
|
676
|
+
if key in obj_dep_keys:
|
|
677
|
+
continue
|
|
678
|
+
out.append(
|
|
679
|
+
ViewDependency(
|
|
680
|
+
source_schema=src_schema,
|
|
681
|
+
source_table=src_table,
|
|
682
|
+
target_schema=ts,
|
|
683
|
+
target_table=tname,
|
|
684
|
+
lineage_type="VIEW",
|
|
685
|
+
object_name=f"{ts}.{tname}",
|
|
686
|
+
confidence=0.85,
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
return out
|
|
690
|
+
|
|
691
|
+
def _snowflake_streams_lineage(
|
|
692
|
+
self, schema_filter: SchemaFilter
|
|
693
|
+
) -> list[ViewDependency]:
|
|
694
|
+
out: list[ViewDependency] = []
|
|
695
|
+
db = (self.config.database or "").replace('"', '""')
|
|
696
|
+
if not db:
|
|
697
|
+
return out
|
|
698
|
+
q = text(f'SHOW STREAMS IN DATABASE "{db}"')
|
|
699
|
+
try:
|
|
700
|
+
with self.engine.connect() as conn:
|
|
701
|
+
rows = conn.execute(q).mappings().all()
|
|
702
|
+
except Exception as exc:
|
|
703
|
+
logger.debug("Snowflake SHOW STREAMS unavailable: %s", exc)
|
|
704
|
+
return out
|
|
705
|
+
for row in rows:
|
|
706
|
+
m = dict(row)
|
|
707
|
+
name = m.get("name") or m.get("NAME")
|
|
708
|
+
schema_key = m.get("schema_name") or m.get("SCHEMA_NAME")
|
|
709
|
+
table_key = m.get("table_name") or m.get("TABLE_NAME")
|
|
710
|
+
if name is None or schema_key is None or table_key is None:
|
|
711
|
+
continue
|
|
712
|
+
tgt_schema = str(schema_key)
|
|
713
|
+
tgt_name = str(name)
|
|
714
|
+
src_table = str(table_key)
|
|
715
|
+
src_schema = str(
|
|
716
|
+
m.get("source_schema") or m.get("SOURCE_SCHEMA") or tgt_schema
|
|
717
|
+
)
|
|
718
|
+
if not self._schema_ok(tgt_schema, schema_filter):
|
|
719
|
+
continue
|
|
720
|
+
if not self._schema_ok(src_schema, schema_filter):
|
|
721
|
+
continue
|
|
722
|
+
out.append(
|
|
723
|
+
ViewDependency(
|
|
724
|
+
source_schema=src_schema,
|
|
725
|
+
source_table=src_table,
|
|
726
|
+
target_schema=tgt_schema,
|
|
727
|
+
target_table=tgt_name,
|
|
728
|
+
lineage_type="STREAM",
|
|
729
|
+
object_name=f"{tgt_schema}.{tgt_name}",
|
|
730
|
+
confidence=1.0,
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
return out
|
|
734
|
+
|
|
735
|
+
def _snowflake_tasks_lineage(
|
|
736
|
+
self, schema_filter: SchemaFilter
|
|
737
|
+
) -> list[ViewDependency]:
|
|
738
|
+
out: list[ViewDependency] = []
|
|
739
|
+
db = (self.config.database or "").replace('"', '""')
|
|
740
|
+
if not db:
|
|
741
|
+
return out
|
|
742
|
+
q = text(f'SHOW TASKS IN DATABASE "{db}"')
|
|
743
|
+
try:
|
|
744
|
+
with self.engine.connect() as conn:
|
|
745
|
+
rows = conn.execute(q).mappings().all()
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
logger.debug("Snowflake SHOW TASKS unavailable: %s", exc)
|
|
748
|
+
return out
|
|
749
|
+
for row in rows:
|
|
750
|
+
m = dict(row)
|
|
751
|
+
name = m.get("name") or m.get("NAME")
|
|
752
|
+
schema_key = m.get("schema_name") or m.get("SCHEMA_NAME")
|
|
753
|
+
definition = m.get("definition") or m.get("DEFINITION")
|
|
754
|
+
if name is None or schema_key is None or definition is None:
|
|
755
|
+
continue
|
|
756
|
+
ts = str(schema_key)
|
|
757
|
+
if not self._schema_ok(ts, schema_filter):
|
|
758
|
+
continue
|
|
759
|
+
tname = str(name)
|
|
760
|
+
sql_text = str(definition)
|
|
761
|
+
reads = table_refs_from_sql(
|
|
762
|
+
sql_text, dialect="snowflake", default_schema=ts
|
|
763
|
+
)
|
|
764
|
+
writes = dml_write_targets(sql_text, dialect="snowflake", default_schema=ts)
|
|
765
|
+
if not writes:
|
|
766
|
+
continue
|
|
767
|
+
for ws, wt in writes:
|
|
768
|
+
if not self._schema_ok(ws, schema_filter):
|
|
769
|
+
continue
|
|
770
|
+
for rs, rt in reads:
|
|
771
|
+
if not self._schema_ok(rs, schema_filter):
|
|
772
|
+
continue
|
|
773
|
+
if rs == ws and rt == wt:
|
|
774
|
+
continue
|
|
775
|
+
out.append(
|
|
776
|
+
ViewDependency(
|
|
777
|
+
source_schema=rs,
|
|
778
|
+
source_table=rt,
|
|
779
|
+
target_schema=ws,
|
|
780
|
+
target_table=wt,
|
|
781
|
+
lineage_type="TASK",
|
|
782
|
+
object_name=f"{ts}.{tname}",
|
|
783
|
+
confidence=0.85,
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
return out
|
|
787
|
+
|
|
788
|
+
def load_deep_catalog(
|
|
789
|
+
self, schema_filter: SchemaFilter
|
|
790
|
+
) -> tuple[
|
|
791
|
+
dict[tuple[str, str], dict[str, Any]],
|
|
792
|
+
dict[tuple[str, str, str], dict[str, Any]],
|
|
793
|
+
]:
|
|
794
|
+
"""TABLES extras and optional ACCOUNT_USAGE access aggregates (best-effort)."""
|
|
795
|
+
table_extra: dict[tuple[str, str], dict[str, Any]] = {}
|
|
796
|
+
column_extra: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
797
|
+
|
|
798
|
+
def _schema_ok(sn: str) -> bool:
|
|
799
|
+
if schema_filter.include and sn not in schema_filter.include:
|
|
800
|
+
return False
|
|
801
|
+
if schema_filter.exclude and sn in schema_filter.exclude:
|
|
802
|
+
return False
|
|
803
|
+
return True
|
|
804
|
+
|
|
805
|
+
ischema = self._qualified_information_schema()
|
|
806
|
+
db_name = (self.config.database or "").replace('"', '""')
|
|
807
|
+
|
|
808
|
+
ext_sql = text(f"""\
|
|
809
|
+
SELECT table_schema, table_name, bytes, clustering_key
|
|
810
|
+
FROM {ischema}.TABLES
|
|
811
|
+
WHERE table_schema NOT IN ('INFORMATION_SCHEMA')
|
|
812
|
+
""")
|
|
813
|
+
try:
|
|
814
|
+
with self.engine.connect() as conn:
|
|
815
|
+
for row in conn.execute(ext_sql).mappings().all():
|
|
816
|
+
sn = str(row["table_schema"])
|
|
817
|
+
tn = str(row["table_name"])
|
|
818
|
+
if not _schema_ok(sn):
|
|
819
|
+
continue
|
|
820
|
+
key = (sn, tn)
|
|
821
|
+
entry: dict[str, Any] = {}
|
|
822
|
+
b = row.get("bytes")
|
|
823
|
+
if b is not None:
|
|
824
|
+
entry["table_bytes"] = int(b)
|
|
825
|
+
ck = row.get("clustering_key")
|
|
826
|
+
if ck:
|
|
827
|
+
entry["clustering_key"] = str(ck)
|
|
828
|
+
if entry:
|
|
829
|
+
table_extra.setdefault(key, {}).update(entry)
|
|
830
|
+
except Exception as exc:
|
|
831
|
+
logger.debug("Snowflake TABLES extended fields skipped: %s", exc)
|
|
832
|
+
|
|
833
|
+
read_sql = text("""\
|
|
834
|
+
SELECT
|
|
835
|
+
f.value:objectName::string AS fq_name,
|
|
836
|
+
COUNT(*) AS read_count,
|
|
837
|
+
MAX(q.query_start_time) AS last_read
|
|
838
|
+
FROM snowflake.account_usage.access_history q,
|
|
839
|
+
LATERAL FLATTEN(input => q.base_objects_accessed) f
|
|
840
|
+
WHERE q.query_start_time >= DATEADD(day, -365, CURRENT_TIMESTAMP())
|
|
841
|
+
AND f.value:objectDomain::string = 'Table'
|
|
842
|
+
GROUP BY 1
|
|
843
|
+
""")
|
|
844
|
+
write_sql = text("""\
|
|
845
|
+
SELECT
|
|
846
|
+
f.value:objectName::string AS fq_name,
|
|
847
|
+
COUNT(*) AS write_count
|
|
848
|
+
FROM snowflake.account_usage.access_history q,
|
|
849
|
+
LATERAL FLATTEN(input => q.objects_modified) f
|
|
850
|
+
WHERE q.query_start_time >= DATEADD(day, -365, CURRENT_TIMESTAMP())
|
|
851
|
+
AND f.value:objectDomain::string IN ('Table', 'Materialized View')
|
|
852
|
+
GROUP BY 1
|
|
853
|
+
""")
|
|
854
|
+
|
|
855
|
+
read_map: dict[str, tuple[int, datetime | None]] = {}
|
|
856
|
+
write_map: dict[str, int] = {}
|
|
857
|
+
try:
|
|
858
|
+
with self.engine.connect() as conn:
|
|
859
|
+
for row in conn.execute(read_sql).mappings().all():
|
|
860
|
+
fq = row["fq_name"]
|
|
861
|
+
if fq is None:
|
|
862
|
+
continue
|
|
863
|
+
read_map[str(fq)] = (
|
|
864
|
+
int(row["read_count"] or 0),
|
|
865
|
+
row["last_read"],
|
|
866
|
+
)
|
|
867
|
+
for row in conn.execute(write_sql).mappings().all():
|
|
868
|
+
fq = row["fq_name"]
|
|
869
|
+
if fq is None:
|
|
870
|
+
continue
|
|
871
|
+
write_map[str(fq)] = int(row["write_count"] or 0)
|
|
872
|
+
except Exception as exc:
|
|
873
|
+
logger.debug("Snowflake ACCOUNT_USAGE access history skipped: %s", exc)
|
|
874
|
+
|
|
875
|
+
now = datetime.now(timezone.utc)
|
|
876
|
+
all_fq = set(read_map) | set(write_map)
|
|
877
|
+
for fq in all_fq:
|
|
878
|
+
parsed = _parse_snowflake_table_fqn(fq, db_name)
|
|
879
|
+
if parsed is None:
|
|
880
|
+
continue
|
|
881
|
+
sn, tn = parsed
|
|
882
|
+
if not _schema_ok(sn):
|
|
883
|
+
continue
|
|
884
|
+
rc, last_read = read_map.get(fq, (0, None))
|
|
885
|
+
wc = write_map.get(fq, 0)
|
|
886
|
+
key = (sn, tn)
|
|
887
|
+
days_since: int | None = None
|
|
888
|
+
if last_read is not None:
|
|
889
|
+
lr = last_read
|
|
890
|
+
if lr.tzinfo is None:
|
|
891
|
+
lr = lr.replace(tzinfo=timezone.utc)
|
|
892
|
+
days_since = max(0, int((now - lr.astimezone(timezone.utc)).days))
|
|
893
|
+
# A table absent from read_map was never accessed in the 365-day window,
|
|
894
|
+
# which is a stronger unused signal than one accessed >90 days ago.
|
|
895
|
+
fq_in_read_map = fq in read_map
|
|
896
|
+
potentially_unused = not fq_in_read_map or (
|
|
897
|
+
days_since is not None and days_since >= _STALE_ACCESS_DAYS
|
|
898
|
+
)
|
|
899
|
+
table_extra.setdefault(key, {}).update(
|
|
900
|
+
{
|
|
901
|
+
"access_read_count": rc,
|
|
902
|
+
"access_write_count": wc,
|
|
903
|
+
"days_since_last_access": days_since,
|
|
904
|
+
"potentially_unused": potentially_unused,
|
|
905
|
+
}
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
return table_extra, column_extra
|