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,487 @@
|
|
|
1
|
+
"""Compile user-authored YAML metrics to validated SQL (OSS).
|
|
2
|
+
|
|
3
|
+
The :class:`MetricSqlCompiler` turns a :class:`Metric` from a
|
|
4
|
+
:class:`SemanticLayer` YAML (``src/pretensor/introspection/models/semantic.py``)
|
|
5
|
+
into an ANSI-quoted SQL string and runs it through the existing
|
|
6
|
+
:class:`pretensor.validation.query_validator.QueryValidator`. See
|
|
7
|
+
``docs/specs/semantic/spec.md`` for the contract.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from difflib import get_close_matches
|
|
15
|
+
|
|
16
|
+
import sqlglot
|
|
17
|
+
from sqlglot import exp
|
|
18
|
+
from sqlglot.errors import ParseError
|
|
19
|
+
|
|
20
|
+
from pretensor.core.store import KuzuStore
|
|
21
|
+
from pretensor.errors import PretensorError
|
|
22
|
+
from pretensor.introspection.models.semantic import (
|
|
23
|
+
Entity,
|
|
24
|
+
Metric,
|
|
25
|
+
MetricType,
|
|
26
|
+
)
|
|
27
|
+
from pretensor.introspection.models.semantic import (
|
|
28
|
+
SemanticLayer as SemanticLayerModel,
|
|
29
|
+
)
|
|
30
|
+
from pretensor.validation.query_validator import QueryValidator, ValidationResult
|
|
31
|
+
|
|
32
|
+
__all__ = ["CompiledMetric", "MetricCompileError", "MetricSqlCompiler"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_DID_YOU_MEAN_TOP_K = 3
|
|
39
|
+
_DID_YOU_MEAN_MAX_CANDIDATES = 1000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _suggest_did_you_mean(
|
|
43
|
+
unresolved: str,
|
|
44
|
+
candidates: list[str],
|
|
45
|
+
*,
|
|
46
|
+
top_k: int = _DID_YOU_MEAN_TOP_K,
|
|
47
|
+
) -> list[str]:
|
|
48
|
+
"""Rank ``candidates`` by similarity to ``unresolved``.
|
|
49
|
+
|
|
50
|
+
Prefers an embedding cosine ranking when the ``[embeddings]`` extra is
|
|
51
|
+
importable AND ``LocalEmbeddingClient.embed`` succeeds. Falls back to
|
|
52
|
+
:func:`difflib.get_close_matches` (Levenshtein-style ratio) so the UX
|
|
53
|
+
remains useful without the extra installed.
|
|
54
|
+
|
|
55
|
+
Returns at most ``top_k`` names. Returns ``[]`` when the candidate set
|
|
56
|
+
is empty or larger than ``_DID_YOU_MEAN_MAX_CANDIDATES`` (suggestions on
|
|
57
|
+
a 50k-column schema add no value and dominate compile-error latency).
|
|
58
|
+
|
|
59
|
+
Pure UX — no caller branches on the suggestion list, so this function
|
|
60
|
+
never raises and always degrades gracefully.
|
|
61
|
+
"""
|
|
62
|
+
if not candidates or len(candidates) > _DID_YOU_MEAN_MAX_CANDIDATES:
|
|
63
|
+
return []
|
|
64
|
+
deduped = list(dict.fromkeys(candidates))
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from pretensor.intelligence.embeddings import (
|
|
68
|
+
cosine_similarity,
|
|
69
|
+
embeddings_disabled_via_env,
|
|
70
|
+
get_default_embedding_client,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Kill switch: skip the embedding ranking entirely and use the
|
|
74
|
+
# same Levenshtein path as when the extra is absent.
|
|
75
|
+
if not embeddings_disabled_via_env():
|
|
76
|
+
client = get_default_embedding_client()
|
|
77
|
+
all_texts = [unresolved, *deduped]
|
|
78
|
+
vectors = client.embed(all_texts)
|
|
79
|
+
if vectors and len(vectors) == len(all_texts):
|
|
80
|
+
scored = [
|
|
81
|
+
(cosine_similarity(vectors[0], vectors[i + 1]), deduped[i])
|
|
82
|
+
for i in range(len(deduped))
|
|
83
|
+
]
|
|
84
|
+
scored.sort(key=lambda x: -x[0])
|
|
85
|
+
return [name for _, name in scored[:top_k]]
|
|
86
|
+
except ImportError:
|
|
87
|
+
# ``[embeddings]`` extra not installed — fall through to Levenshtein.
|
|
88
|
+
pass
|
|
89
|
+
except Exception as exc: # noqa: BLE001 — UX hint must never raise
|
|
90
|
+
logger.debug(
|
|
91
|
+
"compile_metric: embedding-based suggestions failed (%s); "
|
|
92
|
+
"falling back to difflib",
|
|
93
|
+
exc,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return list(get_close_matches(unresolved, deduped, n=top_k, cutoff=0.5))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_DEFAULT_DIALECT = "postgres"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pg_ident(part: str) -> str:
|
|
103
|
+
"""ANSI / PostgreSQL double-quote identifier escaping."""
|
|
104
|
+
return '"' + part.replace('"', '""') + '"'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _split_schema_table(qualified: str) -> tuple[str | None, str]:
|
|
108
|
+
"""Split ``schema.table`` into ``(schema, table)`` — ``(None, table)`` when unqualified."""
|
|
109
|
+
if "." in qualified:
|
|
110
|
+
schema, _, table = qualified.partition(".")
|
|
111
|
+
return schema, table
|
|
112
|
+
return None, qualified
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class CompiledMetric:
|
|
117
|
+
"""Outcome of compiling a single metric."""
|
|
118
|
+
|
|
119
|
+
metric: str
|
|
120
|
+
entity: str
|
|
121
|
+
sql: str
|
|
122
|
+
dialect: str
|
|
123
|
+
warnings: list[str] = field(default_factory=list)
|
|
124
|
+
validation: ValidationResult = field(
|
|
125
|
+
default_factory=lambda: ValidationResult(valid=True)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MetricCompileError(PretensorError, ValueError):
|
|
130
|
+
"""Raised when a metric cannot be compiled.
|
|
131
|
+
|
|
132
|
+
Carries a structured ``did_you_mean: list[str]`` field populated when
|
|
133
|
+
the failure was caused by an unresolved name (metric, table, column).
|
|
134
|
+
The hint is also embedded in the error string so plain ``str(exc)``
|
|
135
|
+
consumers see it; programmatic callers can read the attribute. Empty
|
|
136
|
+
for structural failures (parse error, ambiguous source table).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, message: str, *, did_you_mean: list[str] | None = None) -> None:
|
|
140
|
+
self.did_you_mean: list[str] = list(did_you_mean or [])
|
|
141
|
+
if self.did_you_mean:
|
|
142
|
+
hint = ", ".join(repr(s) for s in self.did_you_mean)
|
|
143
|
+
full = f"{message} (did you mean: {hint}?)"
|
|
144
|
+
else:
|
|
145
|
+
full = message
|
|
146
|
+
super().__init__(full)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MetricSqlCompiler:
|
|
150
|
+
"""Compile YAML-authored :class:`Metric` objects to SQL.
|
|
151
|
+
|
|
152
|
+
The compiler resolves the owning :class:`Entity` source table through the
|
|
153
|
+
same Kuzu lookup the validator uses so callers cannot compile metrics
|
|
154
|
+
against tables that are not in the indexed graph.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
store: KuzuStore,
|
|
160
|
+
*,
|
|
161
|
+
connection_name: str,
|
|
162
|
+
database_key: str,
|
|
163
|
+
dialect: str = _DEFAULT_DIALECT,
|
|
164
|
+
) -> None:
|
|
165
|
+
self._store = store
|
|
166
|
+
self._connection_name = connection_name
|
|
167
|
+
self._database_key = database_key
|
|
168
|
+
self._dialect = dialect
|
|
169
|
+
self._validator = QueryValidator(
|
|
170
|
+
store,
|
|
171
|
+
connection_name=connection_name,
|
|
172
|
+
database_key=database_key,
|
|
173
|
+
dialect=dialect,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def compile(
|
|
177
|
+
self,
|
|
178
|
+
layer: SemanticLayerModel,
|
|
179
|
+
metric_name: str,
|
|
180
|
+
) -> CompiledMetric:
|
|
181
|
+
"""Compile ``metric_name`` from ``layer`` to SQL and validate.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
MetricCompileError: when the metric is not found, its entity is
|
|
185
|
+
missing, its field is unknown, its expression is unparseable,
|
|
186
|
+
or the owning table cannot be resolved to an indexed table.
|
|
187
|
+
"""
|
|
188
|
+
entity, metric = self._find_metric(layer, metric_name)
|
|
189
|
+
warnings: list[str] = []
|
|
190
|
+
|
|
191
|
+
if metric.type is MetricType.DERIVED:
|
|
192
|
+
sql = self._compile_derived(metric, warnings)
|
|
193
|
+
else:
|
|
194
|
+
sql = self._compile_aggregate(entity, metric)
|
|
195
|
+
|
|
196
|
+
validation = self._validator.validate(sql)
|
|
197
|
+
return CompiledMetric(
|
|
198
|
+
metric=metric.name,
|
|
199
|
+
entity=entity.name,
|
|
200
|
+
sql=sql,
|
|
201
|
+
dialect=self._dialect,
|
|
202
|
+
warnings=warnings,
|
|
203
|
+
validation=validation,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# ---- internals ---------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _find_metric(
|
|
209
|
+
self, layer: SemanticLayerModel, metric_name: str
|
|
210
|
+
) -> tuple[Entity, Metric]:
|
|
211
|
+
all_metric_names: list[str] = []
|
|
212
|
+
for domain in layer.domains:
|
|
213
|
+
for entity in domain.entities:
|
|
214
|
+
for metric in entity.metrics:
|
|
215
|
+
if metric.name == metric_name:
|
|
216
|
+
return entity, metric
|
|
217
|
+
all_metric_names.append(metric.name)
|
|
218
|
+
suggestions = _suggest_did_you_mean(metric_name, all_metric_names)
|
|
219
|
+
raise MetricCompileError(
|
|
220
|
+
f"metric {metric_name!r} not found in semantic layer "
|
|
221
|
+
f"for connection {layer.connection_name!r}",
|
|
222
|
+
did_you_mean=suggestions,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _resolve_entity_table(self, entity: Entity) -> tuple[str, str]:
|
|
226
|
+
schema, table = _split_schema_table(entity.source_table)
|
|
227
|
+
if schema is not None:
|
|
228
|
+
rows = self._store.query_all_rows(
|
|
229
|
+
"""
|
|
230
|
+
MATCH (t:SchemaTable)
|
|
231
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
232
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
233
|
+
RETURN t.schema_name, t.table_name
|
|
234
|
+
LIMIT 1
|
|
235
|
+
""",
|
|
236
|
+
{
|
|
237
|
+
"cn": self._connection_name,
|
|
238
|
+
"db": self._database_key,
|
|
239
|
+
"sn": schema,
|
|
240
|
+
"tn": table,
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
rows = self._store.query_all_rows(
|
|
245
|
+
"""
|
|
246
|
+
MATCH (t:SchemaTable)
|
|
247
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
248
|
+
AND t.table_name = $tbl
|
|
249
|
+
RETURN t.schema_name, t.table_name
|
|
250
|
+
""",
|
|
251
|
+
{
|
|
252
|
+
"cn": self._connection_name,
|
|
253
|
+
"db": self._database_key,
|
|
254
|
+
"tbl": table,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
if len(rows) > 1:
|
|
258
|
+
raise MetricCompileError(
|
|
259
|
+
f"entity {entity.name!r} source table {entity.source_table!r} "
|
|
260
|
+
f"is ambiguous across schemas; qualify it as `schema.table`"
|
|
261
|
+
)
|
|
262
|
+
if not rows:
|
|
263
|
+
suggestions = _suggest_did_you_mean(
|
|
264
|
+
entity.source_table,
|
|
265
|
+
self._known_table_qualnames(),
|
|
266
|
+
)
|
|
267
|
+
raise MetricCompileError(
|
|
268
|
+
f"entity {entity.name!r} source table {entity.source_table!r} "
|
|
269
|
+
f"is not indexed for connection {self._connection_name!r}",
|
|
270
|
+
did_you_mean=suggestions,
|
|
271
|
+
)
|
|
272
|
+
sn, tn = rows[0]
|
|
273
|
+
return str(sn), str(tn)
|
|
274
|
+
|
|
275
|
+
def _known_table_qualnames(self) -> list[str]:
|
|
276
|
+
"""Return ``["schema.table", ...]`` for every indexed table.
|
|
277
|
+
|
|
278
|
+
Used as the candidate set for ``did_you_mean`` on unresolved
|
|
279
|
+
``entity.source_table`` references and ``unknown table`` errors in
|
|
280
|
+
derived-metric SQL. One bulk Kuzu query per compile error is fine
|
|
281
|
+
— error paths are not hot.
|
|
282
|
+
"""
|
|
283
|
+
rows = self._store.query_all_rows(
|
|
284
|
+
"""
|
|
285
|
+
MATCH (t:SchemaTable)
|
|
286
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
287
|
+
RETURN t.schema_name, t.table_name
|
|
288
|
+
ORDER BY t.schema_name, t.table_name
|
|
289
|
+
""",
|
|
290
|
+
{"cn": self._connection_name, "db": self._database_key},
|
|
291
|
+
)
|
|
292
|
+
return [f"{sn}.{tn}" for sn, tn in rows]
|
|
293
|
+
|
|
294
|
+
def _known_columns_on(self, schema_name: str, table_name: str) -> list[str]:
|
|
295
|
+
"""Return every column name on ``schema.table`` for did_you_mean ranking.
|
|
296
|
+
|
|
297
|
+
Mirrors :meth:`_known_table_qualnames` but scoped to one table —
|
|
298
|
+
used as the candidate set when an aggregate metric's ``field`` is
|
|
299
|
+
not present on the resolved entity table.
|
|
300
|
+
"""
|
|
301
|
+
rows = self._store.query_all_rows(
|
|
302
|
+
"""
|
|
303
|
+
MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
|
|
304
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
305
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
306
|
+
RETURN c.column_name
|
|
307
|
+
ORDER BY c.column_name
|
|
308
|
+
""",
|
|
309
|
+
{
|
|
310
|
+
"cn": self._connection_name,
|
|
311
|
+
"db": self._database_key,
|
|
312
|
+
"sn": schema_name,
|
|
313
|
+
"tn": table_name,
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
return [str(r[0]) for r in rows]
|
|
317
|
+
|
|
318
|
+
def _column_exists(
|
|
319
|
+
self, schema_name: str, table_name: str, column_name: str
|
|
320
|
+
) -> bool:
|
|
321
|
+
rows = self._store.query_all_rows(
|
|
322
|
+
"""
|
|
323
|
+
MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
|
|
324
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
325
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
326
|
+
AND c.column_name = $col
|
|
327
|
+
RETURN 1 LIMIT 1
|
|
328
|
+
""",
|
|
329
|
+
{
|
|
330
|
+
"cn": self._connection_name,
|
|
331
|
+
"db": self._database_key,
|
|
332
|
+
"sn": schema_name,
|
|
333
|
+
"tn": table_name,
|
|
334
|
+
"col": column_name,
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
return bool(rows)
|
|
338
|
+
|
|
339
|
+
def _compile_aggregate(self, entity: Entity, metric: Metric) -> str:
|
|
340
|
+
if metric.field is None:
|
|
341
|
+
raise MetricCompileError(
|
|
342
|
+
f"metric {metric.name!r} of type {metric.type.value!r} requires `field`"
|
|
343
|
+
)
|
|
344
|
+
schema_name, table_name = self._resolve_entity_table(entity)
|
|
345
|
+
if not self._column_exists(schema_name, table_name, metric.field):
|
|
346
|
+
suggestions = _suggest_did_you_mean(
|
|
347
|
+
metric.field,
|
|
348
|
+
self._known_columns_on(schema_name, table_name),
|
|
349
|
+
)
|
|
350
|
+
raise MetricCompileError(
|
|
351
|
+
f"metric {metric.name!r} field {metric.field!r} not found on "
|
|
352
|
+
f"{schema_name}.{table_name} in the indexed graph",
|
|
353
|
+
did_you_mean=suggestions,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
qschema = _pg_ident(schema_name)
|
|
357
|
+
qtable = _pg_ident(table_name)
|
|
358
|
+
qcol = _pg_ident(metric.field)
|
|
359
|
+
qalias = _pg_ident(metric.name)
|
|
360
|
+
|
|
361
|
+
if metric.type is MetricType.COUNT:
|
|
362
|
+
agg = f"COUNT({qcol})"
|
|
363
|
+
elif metric.type is MetricType.COUNT_DISTINCT:
|
|
364
|
+
agg = f"COUNT(DISTINCT {qcol})"
|
|
365
|
+
elif metric.type is MetricType.SUM:
|
|
366
|
+
agg = f"SUM({qcol})"
|
|
367
|
+
elif metric.type is MetricType.AVERAGE:
|
|
368
|
+
agg = f"AVG({qcol})"
|
|
369
|
+
else: # pragma: no cover — DERIVED routed elsewhere
|
|
370
|
+
raise MetricCompileError(
|
|
371
|
+
f"unsupported aggregate type {metric.type.value!r}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
sql = f"SELECT {agg} AS {qalias} FROM {qschema}.{qtable}"
|
|
375
|
+
if metric.filters:
|
|
376
|
+
joined = " AND ".join(f"({f})" for f in metric.filters)
|
|
377
|
+
sql = f"{sql} WHERE {joined}"
|
|
378
|
+
return sql
|
|
379
|
+
|
|
380
|
+
def _compile_derived(self, metric: Metric, warnings: list[str]) -> str:
|
|
381
|
+
if not metric.expression:
|
|
382
|
+
raise MetricCompileError(
|
|
383
|
+
f"metric {metric.name!r} of type 'derived' requires `expression`"
|
|
384
|
+
)
|
|
385
|
+
try:
|
|
386
|
+
parsed = sqlglot.parse_one(metric.expression, dialect=self._dialect)
|
|
387
|
+
except ParseError as exc:
|
|
388
|
+
raise MetricCompileError(
|
|
389
|
+
f"metric {metric.name!r} expression failed to parse: {exc}"
|
|
390
|
+
) from exc
|
|
391
|
+
|
|
392
|
+
tables: list[tuple[str, str]] = []
|
|
393
|
+
seen: set[tuple[str, str]] = set()
|
|
394
|
+
for table in parsed.find_all(exp.Table):
|
|
395
|
+
name = table.name
|
|
396
|
+
db = table.args.get("db")
|
|
397
|
+
schema = db.name if isinstance(db, exp.Identifier) else None
|
|
398
|
+
qual = f"{schema}.{name}" if schema else name
|
|
399
|
+
resolved = self._resolve_qualified(qual)
|
|
400
|
+
if resolved is None:
|
|
401
|
+
suggestions = _suggest_did_you_mean(qual, self._known_table_qualnames())
|
|
402
|
+
raise MetricCompileError(
|
|
403
|
+
f"metric {metric.name!r} expression references unknown "
|
|
404
|
+
f"table {qual!r}",
|
|
405
|
+
did_you_mean=suggestions,
|
|
406
|
+
)
|
|
407
|
+
if resolved not in seen:
|
|
408
|
+
seen.add(resolved)
|
|
409
|
+
tables.append(resolved)
|
|
410
|
+
|
|
411
|
+
# Warn about table pairs without a graph-backed join edge.
|
|
412
|
+
for i in range(len(tables)):
|
|
413
|
+
for j in range(i + 1, len(tables)):
|
|
414
|
+
a, b = tables[i], tables[j]
|
|
415
|
+
if not self._tables_joinable(a, b):
|
|
416
|
+
warnings.append(
|
|
417
|
+
f"no FK_REFERENCES or INFERRED_JOIN in graph between "
|
|
418
|
+
f"`{a[0]}.{a[1]}` and `{b[0]}.{b[1]}`"
|
|
419
|
+
)
|
|
420
|
+
return metric.expression.strip()
|
|
421
|
+
|
|
422
|
+
def _resolve_qualified(self, qualified: str) -> tuple[str, str] | None:
|
|
423
|
+
schema, table = _split_schema_table(qualified)
|
|
424
|
+
if schema is not None:
|
|
425
|
+
rows = self._store.query_all_rows(
|
|
426
|
+
"""
|
|
427
|
+
MATCH (t:SchemaTable)
|
|
428
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
429
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
430
|
+
RETURN t.schema_name, t.table_name
|
|
431
|
+
LIMIT 1
|
|
432
|
+
""",
|
|
433
|
+
{
|
|
434
|
+
"cn": self._connection_name,
|
|
435
|
+
"db": self._database_key,
|
|
436
|
+
"sn": schema,
|
|
437
|
+
"tn": table,
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
rows = self._store.query_all_rows(
|
|
442
|
+
"""
|
|
443
|
+
MATCH (t:SchemaTable)
|
|
444
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
445
|
+
AND t.table_name = $tbl
|
|
446
|
+
RETURN t.schema_name, t.table_name
|
|
447
|
+
""",
|
|
448
|
+
{
|
|
449
|
+
"cn": self._connection_name,
|
|
450
|
+
"db": self._database_key,
|
|
451
|
+
"tbl": table,
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
if len(rows) > 1:
|
|
455
|
+
return None
|
|
456
|
+
if not rows:
|
|
457
|
+
return None
|
|
458
|
+
sn, tn = rows[0]
|
|
459
|
+
return (str(sn), str(tn))
|
|
460
|
+
|
|
461
|
+
def _tables_joinable(self, left: tuple[str, str], right: tuple[str, str]) -> bool:
|
|
462
|
+
rows = self._store.query_all_rows(
|
|
463
|
+
"""
|
|
464
|
+
MATCH (a:SchemaTable), (b:SchemaTable)
|
|
465
|
+
WHERE a.connection_name = $cn AND a.database = $db
|
|
466
|
+
AND b.connection_name = $cn AND b.database = $db
|
|
467
|
+
AND a.schema_name = $lsa AND a.table_name = $lta
|
|
468
|
+
AND b.schema_name = $rsb AND b.table_name = $rtb
|
|
469
|
+
AND (
|
|
470
|
+
(a)-[:FK_REFERENCES]->(b) OR (b)-[:FK_REFERENCES]->(a)
|
|
471
|
+
OR (a)-[:INFERRED_JOIN]->(b) OR (b)-[:INFERRED_JOIN]->(a)
|
|
472
|
+
)
|
|
473
|
+
RETURN count(*) AS n
|
|
474
|
+
""",
|
|
475
|
+
{
|
|
476
|
+
"cn": self._connection_name,
|
|
477
|
+
"db": self._database_key,
|
|
478
|
+
"lsa": left[0],
|
|
479
|
+
"lta": left[1],
|
|
480
|
+
"rsb": right[0],
|
|
481
|
+
"rtb": right[1],
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
if not rows:
|
|
485
|
+
return False
|
|
486
|
+
n = rows[0][0]
|
|
487
|
+
return int(n) > 0 if n is not None else False
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""``YamlSemanticLayer`` — OSS :class:`SemanticLayer` backed by a YAML file.
|
|
2
|
+
|
|
3
|
+
Loads the Pydantic :class:`pretensor.introspection.models.semantic.SemanticLayer`
|
|
4
|
+
container and answers the :class:`pretensor.semantic.base.SemanticLayer` ABC
|
|
5
|
+
using the loaded definitions plus the indexed Kuzu graph. Query validation is
|
|
6
|
+
delegated to :class:`pretensor.validation.query_validator.QueryValidator`; the
|
|
7
|
+
compile path lives in :mod:`pretensor.semantic.compiler`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pretensor.core.store import KuzuStore
|
|
15
|
+
from pretensor.introspection.models.semantic import (
|
|
16
|
+
AttributeRole,
|
|
17
|
+
Entity,
|
|
18
|
+
)
|
|
19
|
+
from pretensor.introspection.models.semantic import (
|
|
20
|
+
SemanticLayer as SemanticLayerModel,
|
|
21
|
+
)
|
|
22
|
+
from pretensor.semantic.base import SemanticLayer
|
|
23
|
+
from pretensor.semantic.compiler import MetricSqlCompiler
|
|
24
|
+
from pretensor.validation.query_validator import QueryValidator
|
|
25
|
+
|
|
26
|
+
__all__ = ["YamlSemanticLayer"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_DEFAULT_DIALECT = "postgres"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YamlSemanticLayer(SemanticLayer):
|
|
33
|
+
"""File-based semantic layer for OSS deployments.
|
|
34
|
+
|
|
35
|
+
Constructed against a loaded :class:`SemanticLayerModel` and an open
|
|
36
|
+
:class:`KuzuStore`. Methods on the ABC return plain ``dict`` / ``list``
|
|
37
|
+
payloads as documented on :class:`SemanticLayer`; no implementation-specific model classes
|
|
38
|
+
leak through.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
layer: SemanticLayerModel,
|
|
44
|
+
*,
|
|
45
|
+
store: KuzuStore,
|
|
46
|
+
database_key: str,
|
|
47
|
+
dialect: str = _DEFAULT_DIALECT,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._layer = layer
|
|
50
|
+
self._store = store
|
|
51
|
+
self._database_key = database_key
|
|
52
|
+
self._dialect = dialect
|
|
53
|
+
self._validator = QueryValidator(
|
|
54
|
+
store,
|
|
55
|
+
connection_name=layer.connection_name,
|
|
56
|
+
database_key=database_key,
|
|
57
|
+
dialect=dialect,
|
|
58
|
+
)
|
|
59
|
+
self._compiler = MetricSqlCompiler(
|
|
60
|
+
store,
|
|
61
|
+
connection_name=layer.connection_name,
|
|
62
|
+
database_key=database_key,
|
|
63
|
+
dialect=dialect,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# ---- accessors --------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def layer(self) -> SemanticLayerModel:
|
|
70
|
+
return self._layer
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def compiler(self) -> MetricSqlCompiler:
|
|
74
|
+
return self._compiler
|
|
75
|
+
|
|
76
|
+
# ---- SemanticLayer ABC -------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def get_entity(self, entity_id: str) -> dict[str, Any] | None:
|
|
79
|
+
entity = self._find_entity(entity_id)
|
|
80
|
+
if entity is None:
|
|
81
|
+
return None
|
|
82
|
+
return entity.model_dump(mode="json")
|
|
83
|
+
|
|
84
|
+
def get_metric(self, metric_name: str) -> dict[str, Any] | None:
|
|
85
|
+
for domain in self._layer.domains:
|
|
86
|
+
for entity in domain.entities:
|
|
87
|
+
for metric in entity.metrics:
|
|
88
|
+
if metric.name == metric_name:
|
|
89
|
+
payload = metric.model_dump(mode="json")
|
|
90
|
+
payload["entity"] = entity.name
|
|
91
|
+
payload["source_table"] = entity.source_table
|
|
92
|
+
return payload
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
|
|
96
|
+
entity = self._find_entity(entity_id)
|
|
97
|
+
if entity is None:
|
|
98
|
+
return []
|
|
99
|
+
out: list[dict[str, Any]] = []
|
|
100
|
+
for attr in entity.attributes:
|
|
101
|
+
if attr.role in {
|
|
102
|
+
AttributeRole.DIMENSION,
|
|
103
|
+
AttributeRole.TIME_DIMENSION,
|
|
104
|
+
AttributeRole.IDENTIFIER,
|
|
105
|
+
}:
|
|
106
|
+
out.append(attr.model_dump(mode="json"))
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
|
|
110
|
+
# OSS has no business-rule surface. Extensions override.
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
|
|
114
|
+
if connection_name and connection_name != self._layer.connection_name:
|
|
115
|
+
return {
|
|
116
|
+
"valid": False,
|
|
117
|
+
"errors": [
|
|
118
|
+
f"connection {connection_name!r} does not match "
|
|
119
|
+
f"semantic layer connection {self._layer.connection_name!r}"
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
result = self._validator.validate(sql)
|
|
123
|
+
errors: list[str] = []
|
|
124
|
+
errors.extend(result.syntax_errors)
|
|
125
|
+
errors.extend(f"missing_table: {t}" for t in result.missing_tables)
|
|
126
|
+
errors.extend(f"missing_column: {c}" for c in result.missing_columns)
|
|
127
|
+
errors.extend(f"invalid_join: {j.message}" for j in result.invalid_joins)
|
|
128
|
+
return {"valid": result.valid, "errors": errors}
|
|
129
|
+
|
|
130
|
+
def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
|
|
131
|
+
rows = self._store.query_all_rows(
|
|
132
|
+
"""
|
|
133
|
+
MATCH (t:SchemaTable {node_id: $nid})
|
|
134
|
+
RETURN t.schema_name, t.table_name
|
|
135
|
+
LIMIT 1
|
|
136
|
+
""",
|
|
137
|
+
{"nid": table_node_id},
|
|
138
|
+
)
|
|
139
|
+
if not rows:
|
|
140
|
+
return []
|
|
141
|
+
sn, tn = rows[0]
|
|
142
|
+
schema_name = str(sn)
|
|
143
|
+
table_name = str(tn)
|
|
144
|
+
qualified_full = f"{schema_name}.{table_name}"
|
|
145
|
+
bare = table_name
|
|
146
|
+
out: list[dict[str, Any]] = []
|
|
147
|
+
for domain in self._layer.domains:
|
|
148
|
+
for entity in domain.entities:
|
|
149
|
+
owns = entity.source_table in (qualified_full, bare)
|
|
150
|
+
for metric in entity.metrics:
|
|
151
|
+
if owns:
|
|
152
|
+
out.append(
|
|
153
|
+
{
|
|
154
|
+
"kind": "metric",
|
|
155
|
+
"metric": metric.name,
|
|
156
|
+
"entity": entity.name,
|
|
157
|
+
"reason": "owns_source_table",
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
expr = metric.expression or ""
|
|
162
|
+
if expr and (qualified_full in expr or f" {bare} " in f" {expr} "):
|
|
163
|
+
out.append(
|
|
164
|
+
{
|
|
165
|
+
"kind": "metric",
|
|
166
|
+
"metric": metric.name,
|
|
167
|
+
"entity": entity.name,
|
|
168
|
+
"reason": "expression_references_table",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
# ---- internals --------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _find_entity(self, entity_id: str) -> Entity | None:
|
|
176
|
+
for domain in self._layer.domains:
|
|
177
|
+
for entity in domain.entities:
|
|
178
|
+
if entity.name == entity_id:
|
|
179
|
+
return entity
|
|
180
|
+
return None
|