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,468 @@
|
|
|
1
|
+
"""BigQuery introspection via ``google-cloud-bigquery`` (optional extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pretensor.connectors.base import (
|
|
11
|
+
BaseConnector,
|
|
12
|
+
ColumnInfo,
|
|
13
|
+
ColumnStats,
|
|
14
|
+
ForeignKeyInfo,
|
|
15
|
+
TableInfo,
|
|
16
|
+
)
|
|
17
|
+
from pretensor.errors import ConnectorError as _ConnectorError
|
|
18
|
+
from pretensor.introspection.models.config import ConnectionConfig, SchemaFilter
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_STALE_ACCESS_DAYS = 90
|
|
23
|
+
_JOBS_LOOKBACK_DAYS = 90
|
|
24
|
+
_NESTED_SCHEMA_MAX_DEPTH = 3
|
|
25
|
+
|
|
26
|
+
_BIGQUERY_EXTRA_HINT = (
|
|
27
|
+
"Install the BigQuery extra: pip install 'pretensor[bigquery]' "
|
|
28
|
+
"(or pip install google-cloud-bigquery)."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BigQueryConnectorError(_ConnectorError):
|
|
33
|
+
"""Raised when BigQuery connector operations fail."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_bigquery() -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return importlib.import_module("google.cloud.bigquery")
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
msg = (
|
|
41
|
+
f"BigQuery connector requires google-cloud-bigquery. {_BIGQUERY_EXTRA_HINT}"
|
|
42
|
+
)
|
|
43
|
+
raise ImportError(msg) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bq_table_type(raw: str | None) -> str:
|
|
47
|
+
if not raw:
|
|
48
|
+
return "table"
|
|
49
|
+
u = raw.upper()
|
|
50
|
+
if u == "BASE TABLE":
|
|
51
|
+
return "table"
|
|
52
|
+
if u == "VIEW":
|
|
53
|
+
return "view"
|
|
54
|
+
if u == "MATERIALIZED VIEW":
|
|
55
|
+
return "materialized_view"
|
|
56
|
+
if u == "EXTERNAL":
|
|
57
|
+
return "foreign_table"
|
|
58
|
+
return raw.lower().replace(" ", "_")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _jobs_region_dataset(location: str | None) -> str:
|
|
62
|
+
"""Region qualifier for project-level ``INFORMATION_SCHEMA.JOBS``.
|
|
63
|
+
|
|
64
|
+
Only the BigQuery multi-region identifiers (``US`` and ``EU``) have
|
|
65
|
+
dedicated ``region-us`` / ``region-eu`` INFORMATION_SCHEMA datasets.
|
|
66
|
+
All single-region locations (e.g. ``us-central1``, ``europe-west1``)
|
|
67
|
+
must use their own ``region-<location>`` qualifier.
|
|
68
|
+
"""
|
|
69
|
+
if not location:
|
|
70
|
+
return "region-us"
|
|
71
|
+
loc = location.strip().upper()
|
|
72
|
+
if loc == "US":
|
|
73
|
+
return "region-us"
|
|
74
|
+
if loc == "EU":
|
|
75
|
+
return "region-eu"
|
|
76
|
+
return f"region-{location.strip().lower()}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _schema_field_to_columns(
|
|
80
|
+
field: Any,
|
|
81
|
+
*,
|
|
82
|
+
parent_path: str | None,
|
|
83
|
+
depth: int,
|
|
84
|
+
ordinal_counter: list[int],
|
|
85
|
+
) -> list[ColumnInfo]:
|
|
86
|
+
"""Flatten ``SchemaField`` tree into :class:`ColumnInfo` rows (depth-capped)."""
|
|
87
|
+
out: list[ColumnInfo] = []
|
|
88
|
+
name = field.name
|
|
89
|
+
full_name = f"{parent_path}.{name}" if parent_path else name
|
|
90
|
+
mode = (getattr(field, "mode", None) or "").upper()
|
|
91
|
+
is_repeated = mode == "REPEATED"
|
|
92
|
+
field_type = (getattr(field, "field_type", None) or "").upper()
|
|
93
|
+
is_record = field_type in ("RECORD", "STRUCT")
|
|
94
|
+
|
|
95
|
+
bq_type = str(getattr(field, "field_type", "") or "STRING")
|
|
96
|
+
if is_repeated and not is_record:
|
|
97
|
+
data_type = f"ARRAY<{bq_type}>"
|
|
98
|
+
elif is_record:
|
|
99
|
+
data_type = "RECORD"
|
|
100
|
+
else:
|
|
101
|
+
data_type = bq_type
|
|
102
|
+
|
|
103
|
+
nullable = mode != "REQUIRED" and not is_repeated
|
|
104
|
+
ordinal_counter[0] += 1
|
|
105
|
+
ord_pos = ordinal_counter[0]
|
|
106
|
+
out.append(
|
|
107
|
+
ColumnInfo(
|
|
108
|
+
name=full_name,
|
|
109
|
+
data_type=data_type,
|
|
110
|
+
nullable=nullable,
|
|
111
|
+
is_primary_key=False,
|
|
112
|
+
default_value=None,
|
|
113
|
+
comment=getattr(field, "description", None) or None,
|
|
114
|
+
is_indexed=False,
|
|
115
|
+
ordinal_position=ord_pos,
|
|
116
|
+
parent_column=parent_path,
|
|
117
|
+
is_array=is_repeated,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
subfields = list(getattr(field, "fields", ()) or ())
|
|
121
|
+
if not subfields or depth >= _NESTED_SCHEMA_MAX_DEPTH:
|
|
122
|
+
return out
|
|
123
|
+
for sub in subfields:
|
|
124
|
+
out.extend(
|
|
125
|
+
_schema_field_to_columns(
|
|
126
|
+
sub,
|
|
127
|
+
parent_path=full_name,
|
|
128
|
+
depth=depth + 1,
|
|
129
|
+
ordinal_counter=ordinal_counter,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class BigQueryConnector(BaseConnector):
|
|
136
|
+
"""Introspect BigQuery via INFORMATION_SCHEMA and table metadata API."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, config: ConnectionConfig) -> None:
|
|
139
|
+
super().__init__(config)
|
|
140
|
+
self._client: Any = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def project_id(self) -> str:
|
|
144
|
+
extra = self.config.metadata_extra or {}
|
|
145
|
+
pid = (extra.get("bq_project") or self.config.host or "").strip()
|
|
146
|
+
if not pid:
|
|
147
|
+
raise BigQueryConnectorError(
|
|
148
|
+
"BigQuery project id is missing from connection config"
|
|
149
|
+
)
|
|
150
|
+
return pid
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def dataset_id(self) -> str:
|
|
154
|
+
sf = self.config.schema_filter
|
|
155
|
+
if sf.include and len(sf.include) == 1:
|
|
156
|
+
return sf.include[0]
|
|
157
|
+
db = (self.config.database or "").strip()
|
|
158
|
+
if "/" in db:
|
|
159
|
+
return db.split("/", 1)[1]
|
|
160
|
+
raise BigQueryConnectorError(
|
|
161
|
+
"BigQuery dataset id is required (DSN path bigquery://project/dataset)"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def location(self) -> str | None:
|
|
166
|
+
extra = self.config.metadata_extra or {}
|
|
167
|
+
loc = extra.get("bq_location")
|
|
168
|
+
return str(loc).strip() if loc else None
|
|
169
|
+
|
|
170
|
+
def _fq(self, suffix: str) -> str:
|
|
171
|
+
return f"`{self.project_id}.{self.dataset_id}.{suffix}`"
|
|
172
|
+
|
|
173
|
+
def _fq_jobs(self) -> str:
|
|
174
|
+
region_ds = _jobs_region_dataset(self.location)
|
|
175
|
+
return f"`{self.project_id}.{region_ds}.INFORMATION_SCHEMA.JOBS`"
|
|
176
|
+
|
|
177
|
+
def connect(self) -> None:
|
|
178
|
+
bigquery = _ensure_bigquery()
|
|
179
|
+
try:
|
|
180
|
+
client_kw: dict[str, Any] = {"project": self.project_id}
|
|
181
|
+
if self.location:
|
|
182
|
+
client_kw["location"] = self.location
|
|
183
|
+
self._client = bigquery.Client(**client_kw)
|
|
184
|
+
list(self._client.query("SELECT 1").result())
|
|
185
|
+
logger.info(
|
|
186
|
+
"Connected to BigQuery project %s dataset %s",
|
|
187
|
+
self.project_id,
|
|
188
|
+
self.dataset_id,
|
|
189
|
+
)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise BigQueryConnectorError(
|
|
192
|
+
f"Failed to connect to BigQuery: {exc}"
|
|
193
|
+
) from exc
|
|
194
|
+
|
|
195
|
+
def disconnect(self) -> None:
|
|
196
|
+
self._client = None
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def client(self) -> Any:
|
|
200
|
+
if self._client is None:
|
|
201
|
+
raise BigQueryConnectorError("Not connected.")
|
|
202
|
+
return self._client
|
|
203
|
+
|
|
204
|
+
def _run_query(self, sql: str) -> list[dict[str, Any]]:
|
|
205
|
+
rows: list[dict[str, Any]] = []
|
|
206
|
+
job = self.client.query(sql)
|
|
207
|
+
for row in job.result(page_size=1000):
|
|
208
|
+
rows.append(dict(row.items()))
|
|
209
|
+
return rows
|
|
210
|
+
|
|
211
|
+
def _schema_ok(self, schema_filter: SchemaFilter, schema_name: str) -> bool:
|
|
212
|
+
if schema_filter.include and schema_name not in schema_filter.include:
|
|
213
|
+
return False
|
|
214
|
+
if schema_filter.exclude and schema_name in schema_filter.exclude:
|
|
215
|
+
return False
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def get_tables(self, schema_filter: SchemaFilter | None = None) -> list[TableInfo]:
|
|
219
|
+
sf = schema_filter or self.config.schema_filter
|
|
220
|
+
desc_sql = f"""
|
|
221
|
+
SELECT table_name, option_value AS description
|
|
222
|
+
FROM {self._fq("INFORMATION_SCHEMA.TABLE_OPTIONS")}
|
|
223
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
224
|
+
AND option_name = 'description'
|
|
225
|
+
"""
|
|
226
|
+
descriptions: dict[str, str] = {}
|
|
227
|
+
try:
|
|
228
|
+
for row in self._run_query(desc_sql):
|
|
229
|
+
tname = row.get("table_name")
|
|
230
|
+
desc = row.get("description")
|
|
231
|
+
if tname and desc:
|
|
232
|
+
descriptions[str(tname)] = str(desc)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
logger.warning("BigQuery TABLE_OPTIONS query skipped: %s", exc)
|
|
235
|
+
|
|
236
|
+
tables_sql = f"""
|
|
237
|
+
SELECT table_schema, table_name, table_type, creation_time
|
|
238
|
+
FROM {self._fq("INFORMATION_SCHEMA.TABLES")}
|
|
239
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
240
|
+
ORDER BY table_name
|
|
241
|
+
"""
|
|
242
|
+
storage_sql = f"""
|
|
243
|
+
SELECT table_name, total_rows
|
|
244
|
+
FROM {self._fq("INFORMATION_SCHEMA.TABLE_STORAGE")}
|
|
245
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
storage: dict[str, int | None] = {}
|
|
249
|
+
try:
|
|
250
|
+
for row in self._run_query(storage_sql):
|
|
251
|
+
tn = str(row["table_name"])
|
|
252
|
+
tr = row.get("total_rows")
|
|
253
|
+
storage[tn] = int(tr) if tr is not None else None
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
logger.warning("BigQuery TABLE_STORAGE row counts skipped: %s", exc)
|
|
256
|
+
|
|
257
|
+
out: list[TableInfo] = []
|
|
258
|
+
for row in self._run_query(tables_sql):
|
|
259
|
+
schema_name = str(row.get("table_schema") or self.dataset_id)
|
|
260
|
+
if not self._schema_ok(sf, schema_name):
|
|
261
|
+
continue
|
|
262
|
+
tname = str(row["table_name"])
|
|
263
|
+
out.append(
|
|
264
|
+
TableInfo(
|
|
265
|
+
name=tname,
|
|
266
|
+
schema_name=schema_name,
|
|
267
|
+
row_count=storage.get(tname),
|
|
268
|
+
comment=descriptions.get(tname),
|
|
269
|
+
table_type=_bq_table_type(
|
|
270
|
+
str(row["table_type"]) if row.get("table_type") else None
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
def get_columns(self, table_name: str, schema_name: str) -> list[ColumnInfo]:
|
|
277
|
+
_ = schema_name
|
|
278
|
+
ref = f"{self.project_id}.{self.dataset_id}.{table_name}"
|
|
279
|
+
try:
|
|
280
|
+
table = self.client.get_table(ref)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
raise BigQueryConnectorError(
|
|
283
|
+
f"Failed to load BigQuery table schema for {ref}: {exc}"
|
|
284
|
+
) from exc
|
|
285
|
+
cols: list[ColumnInfo] = []
|
|
286
|
+
counter = [0]
|
|
287
|
+
for field in table.schema:
|
|
288
|
+
cols.extend(
|
|
289
|
+
_schema_field_to_columns(
|
|
290
|
+
field, parent_path=None, depth=1, ordinal_counter=counter
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return cols
|
|
294
|
+
|
|
295
|
+
def get_foreign_keys(self) -> list[ForeignKeyInfo]:
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
def get_table_row_count(self, table_name: str, schema_name: str) -> int:
|
|
299
|
+
tables = self.get_tables(
|
|
300
|
+
SchemaFilter(include=[schema_name]) if schema_name else None
|
|
301
|
+
)
|
|
302
|
+
for t in tables:
|
|
303
|
+
if t.name == table_name and t.schema_name == schema_name:
|
|
304
|
+
return int(t.row_count or 0)
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
def get_column_stats(
|
|
308
|
+
self, table_name: str, column_name: str, schema_name: str
|
|
309
|
+
) -> ColumnStats:
|
|
310
|
+
_ = (table_name, column_name, schema_name)
|
|
311
|
+
return ColumnStats()
|
|
312
|
+
|
|
313
|
+
def execute_query(self, sql: str) -> list[dict[str, Any]]:
|
|
314
|
+
return self._run_query(sql)
|
|
315
|
+
|
|
316
|
+
def load_deep_catalog(
|
|
317
|
+
self, schema_filter: SchemaFilter
|
|
318
|
+
) -> tuple[
|
|
319
|
+
dict[tuple[str, str], dict[str, Any]],
|
|
320
|
+
dict[tuple[str, str, str], dict[str, Any]],
|
|
321
|
+
]:
|
|
322
|
+
"""TABLE_STORAGE bytes, partition/cluster hints, and JOBS read aggregates."""
|
|
323
|
+
table_extra: dict[tuple[str, str], dict[str, Any]] = {}
|
|
324
|
+
column_extra: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
325
|
+
|
|
326
|
+
def _ok(sn: str) -> bool:
|
|
327
|
+
return self._schema_ok(schema_filter, sn)
|
|
328
|
+
|
|
329
|
+
storage_sql = f"""
|
|
330
|
+
SELECT table_name, active_logical_bytes
|
|
331
|
+
FROM {self._fq("INFORMATION_SCHEMA.TABLE_STORAGE")}
|
|
332
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
for row in self._run_query(storage_sql):
|
|
336
|
+
tn = str(row["table_name"])
|
|
337
|
+
key = (self.dataset_id, tn)
|
|
338
|
+
if not _ok(self.dataset_id):
|
|
339
|
+
continue
|
|
340
|
+
b = row.get("active_logical_bytes")
|
|
341
|
+
if b is not None:
|
|
342
|
+
table_extra.setdefault(key, {})["table_bytes"] = int(b)
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
logger.warning("BigQuery TABLE_STORAGE bytes skipped: %s", exc)
|
|
345
|
+
|
|
346
|
+
cluster_sql = f"""
|
|
347
|
+
SELECT
|
|
348
|
+
table_name,
|
|
349
|
+
STRING_AGG(column_name ORDER BY clustering_ordinal_position) AS clustering_key
|
|
350
|
+
FROM {self._fq("INFORMATION_SCHEMA.COLUMNS")}
|
|
351
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
352
|
+
AND clustering_ordinal_position IS NOT NULL
|
|
353
|
+
GROUP BY table_name
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
for row in self._run_query(cluster_sql):
|
|
357
|
+
tn = row.get("table_name")
|
|
358
|
+
ck = row.get("clustering_key")
|
|
359
|
+
if tn and ck:
|
|
360
|
+
key = (self.dataset_id, str(tn))
|
|
361
|
+
if _ok(self.dataset_id):
|
|
362
|
+
table_extra.setdefault(key, {})["clustering_key"] = str(ck)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
logger.warning("BigQuery clustering metadata skipped: %s", exc)
|
|
365
|
+
|
|
366
|
+
part_sql = f"""
|
|
367
|
+
SELECT
|
|
368
|
+
table_name,
|
|
369
|
+
STRING_AGG(column_name ORDER BY ordinal_position) AS partition_key
|
|
370
|
+
FROM {self._fq("INFORMATION_SCHEMA.COLUMNS")}
|
|
371
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
372
|
+
AND is_partitioning_column = 'YES'
|
|
373
|
+
GROUP BY table_name
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
for row in self._run_query(part_sql):
|
|
377
|
+
tn = row.get("table_name")
|
|
378
|
+
pk = row.get("partition_key")
|
|
379
|
+
if tn and pk:
|
|
380
|
+
key = (self.dataset_id, str(tn))
|
|
381
|
+
if _ok(self.dataset_id):
|
|
382
|
+
table_extra.setdefault(key, {}).update(
|
|
383
|
+
{
|
|
384
|
+
"is_partitioned": True,
|
|
385
|
+
"partition_key": str(pk),
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
logger.warning("BigQuery partition metadata skipped: %s", exc)
|
|
390
|
+
|
|
391
|
+
jobs_sql = f"""
|
|
392
|
+
SELECT
|
|
393
|
+
ref.project_id AS project_id,
|
|
394
|
+
ref.dataset_id AS dataset_id,
|
|
395
|
+
ref.table_id AS table_id,
|
|
396
|
+
COUNT(*) AS read_count,
|
|
397
|
+
MAX(j.creation_time) AS last_read
|
|
398
|
+
FROM {self._fq_jobs()} AS j,
|
|
399
|
+
UNNEST(j.referenced_tables) AS ref
|
|
400
|
+
WHERE j.job_type = 'QUERY'
|
|
401
|
+
AND j.creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {_JOBS_LOOKBACK_DAYS} DAY)
|
|
402
|
+
AND (j.cache_hit IS NOT TRUE)
|
|
403
|
+
GROUP BY project_id, dataset_id, table_id
|
|
404
|
+
"""
|
|
405
|
+
jobs_ok = False
|
|
406
|
+
try:
|
|
407
|
+
read_rows = self._run_query(jobs_sql)
|
|
408
|
+
jobs_ok = True
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
logger.warning(
|
|
411
|
+
"BigQuery INFORMATION_SCHEMA.JOBS access skipped (permissions or region): %s",
|
|
412
|
+
exc,
|
|
413
|
+
)
|
|
414
|
+
read_rows = []
|
|
415
|
+
|
|
416
|
+
now = datetime.now(timezone.utc)
|
|
417
|
+
for row in read_rows:
|
|
418
|
+
pid = (row.get("project_id") or "").strip()
|
|
419
|
+
did = (row.get("dataset_id") or "").strip()
|
|
420
|
+
tid = (row.get("table_id") or "").strip()
|
|
421
|
+
if pid != self.project_id or did != self.dataset_id or not tid:
|
|
422
|
+
continue
|
|
423
|
+
if not _ok(self.dataset_id):
|
|
424
|
+
continue
|
|
425
|
+
key = (self.dataset_id, tid)
|
|
426
|
+
rc = int(row.get("read_count") or 0)
|
|
427
|
+
last_read = row.get("last_read")
|
|
428
|
+
days_since: int | None = None
|
|
429
|
+
if last_read is not None:
|
|
430
|
+
lr = last_read
|
|
431
|
+
if isinstance(lr, datetime):
|
|
432
|
+
if lr.tzinfo is None:
|
|
433
|
+
lr = lr.replace(tzinfo=timezone.utc)
|
|
434
|
+
days_since = max(0, int((now - lr.astimezone(timezone.utc)).days))
|
|
435
|
+
potentially_unused = rc == 0 and (
|
|
436
|
+
days_since is None or days_since > _STALE_ACCESS_DAYS
|
|
437
|
+
)
|
|
438
|
+
table_extra.setdefault(key, {}).update(
|
|
439
|
+
{
|
|
440
|
+
"access_read_count": rc,
|
|
441
|
+
"days_since_last_access": days_since,
|
|
442
|
+
"potentially_unused": potentially_unused,
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if jobs_ok:
|
|
447
|
+
list_sql = f"""
|
|
448
|
+
SELECT table_name
|
|
449
|
+
FROM {self._fq("INFORMATION_SCHEMA.TABLES")}
|
|
450
|
+
WHERE table_schema = '{self.dataset_id}'
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
table_names = [str(r["table_name"]) for r in self._run_query(list_sql)]
|
|
454
|
+
except Exception:
|
|
455
|
+
table_names = []
|
|
456
|
+
for tn in table_names:
|
|
457
|
+
if not _ok(self.dataset_id):
|
|
458
|
+
continue
|
|
459
|
+
key = (self.dataset_id, tn)
|
|
460
|
+
entry = table_extra.setdefault(key, {})
|
|
461
|
+
if "access_read_count" not in entry:
|
|
462
|
+
entry["access_read_count"] = 0
|
|
463
|
+
if "days_since_last_access" not in entry:
|
|
464
|
+
entry["days_since_last_access"] = None
|
|
465
|
+
if "potentially_unused" not in entry:
|
|
466
|
+
entry["potentially_unused"] = True
|
|
467
|
+
|
|
468
|
+
return table_extra, column_extra
|