metricflow-sql 0.1.1__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.
- metricflow_sql/__init__.py +8 -0
- metricflow_sql/compiler/__init__.py +0 -0
- metricflow_sql/compiler/catalog/__init__.py +0 -0
- metricflow_sql/compiler/catalog/cluster.py +152 -0
- metricflow_sql/compiler/catalog/information_schema.py +47 -0
- metricflow_sql/compiler/catalog/metadata.py +65 -0
- metricflow_sql/compiler/catalog/passthrough.py +184 -0
- metricflow_sql/compiler/catalog/protocols.py +67 -0
- metricflow_sql/compiler/catalog/semantic.py +59 -0
- metricflow_sql/compiler/compiler.py +297 -0
- metricflow_sql/compiler/rules/__init__.py +0 -0
- metricflow_sql/compiler/rules/expand_constellations.py +790 -0
- metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
- metricflow_sql/compiler/rules/expand_metadata.py +88 -0
- metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
- metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
- metricflow_sql/compiler/table_arguments.py +35 -0
- metricflow_sql/converters/__init__.py +0 -0
- metricflow_sql/converters/lookml.py +1041 -0
- metricflow_sql/objects/__init__.py +0 -0
- metricflow_sql/objects/base.py +308 -0
- metricflow_sql/objects/constellation.py +210 -0
- metricflow_sql/objects/dataset.py +275 -0
- metricflow_sql/objects/entity.py +131 -0
- metricflow_sql/objects/reference.py +35 -0
- metricflow_sql/objects/wiki.py +101 -0
- metricflow_sql/py.typed +0 -0
- metricflow_sql/registry/__init__.py +0 -0
- metricflow_sql/registry/memory.py +409 -0
- metricflow_sql/registry/operation.py +25 -0
- metricflow_sql/registry/reference_resolution.py +68 -0
- metricflow_sql/registry/registry.py +225 -0
- metricflow_sql/registry/type_resolver.py +460 -0
- metricflow_sql/schemas/__init__.py +0 -0
- metricflow_sql/schemas/bundle.py +327 -0
- metricflow_sql/schemas/fragment.schema.json +1165 -0
- metricflow_sql/schemas/generate.py +70 -0
- metricflow_sql/schemas/load.py +333 -0
- metricflow_sql/schemas/v1/__init__.py +51 -0
- metricflow_sql/schemas/v1/fragment.py +544 -0
- metricflow_sql/server/__init__.py +4 -0
- metricflow_sql/server/app.py +33 -0
- metricflow_sql/server/compile.py +69 -0
- metricflow_sql/server/errors.py +10 -0
- metricflow_sql/server/serve.py +161 -0
- metricflow_sql/utils/__init__.py +0 -0
- metricflow_sql/utils/composed_set.py +32 -0
- metricflow_sql/utils/normalize.py +74 -0
- metricflow_sql/utils/ordered_set.py +24 -0
- metricflow_sql/utils/sql.py +226 -0
- metricflow_sql/utils/template.py +57 -0
- metricflow_sql-0.1.1.dist-info/METADATA +30 -0
- metricflow_sql-0.1.1.dist-info/RECORD +56 -0
- metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
- metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
- metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from sqlglot import Schema, parse
|
|
9
|
+
from sqlglot import expressions as exp
|
|
10
|
+
from sqlglot.dialects.dialect import DialectType, NormalizationStrategy
|
|
11
|
+
from sqlglot.helper import find_new_name
|
|
12
|
+
from sqlglot.optimizer.normalize_identifiers import normalize_identifiers
|
|
13
|
+
from sqlglot.optimizer.optimizer import OptimizerFn
|
|
14
|
+
from sqlglot.optimizer.qualify import qualify
|
|
15
|
+
from sqlglot.optimizer.qualify_columns import (
|
|
16
|
+
qualify_columns,
|
|
17
|
+
quote_identifiers,
|
|
18
|
+
validate_qualify_columns,
|
|
19
|
+
)
|
|
20
|
+
from sqlglot.optimizer.scope import traverse_scope
|
|
21
|
+
|
|
22
|
+
from metricflow_sql.compiler.catalog.cluster import Cluster
|
|
23
|
+
from metricflow_sql.compiler.catalog.metadata import INFORMATION_SCHEMA
|
|
24
|
+
from metricflow_sql.compiler.catalog.semantic import SemanticCatalog
|
|
25
|
+
from metricflow_sql.compiler.rules.expand_constellations import ExpandConstellationsRule
|
|
26
|
+
from metricflow_sql.compiler.rules.expand_datasets import ExpandDatasetsRule
|
|
27
|
+
from metricflow_sql.compiler.rules.expand_metadata import ENGINE, ExpandMetadataRule
|
|
28
|
+
from metricflow_sql.compiler.rules.isolate_fragments import isolate_fragments
|
|
29
|
+
from metricflow_sql.compiler.rules.passthrough_tables import name_passthrough_tables
|
|
30
|
+
from metricflow_sql.compiler.table_arguments import bind_table_arguments
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def qualify_tables(
|
|
34
|
+
expression: exp.Expr,
|
|
35
|
+
dialect: DialectType = None,
|
|
36
|
+
db: str | None = None,
|
|
37
|
+
catalog: str | None = None,
|
|
38
|
+
schema: dict[str, object] | Schema | None = None,
|
|
39
|
+
) -> exp.Expr:
|
|
40
|
+
return qualify(
|
|
41
|
+
expression,
|
|
42
|
+
dialect=dialect,
|
|
43
|
+
db=db,
|
|
44
|
+
catalog=catalog,
|
|
45
|
+
schema=schema,
|
|
46
|
+
qualify_columns=False,
|
|
47
|
+
quote_identifiers=True,
|
|
48
|
+
validate_qualify_columns=False,
|
|
49
|
+
isolate_tables=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Plan:
|
|
58
|
+
out_sql: str
|
|
59
|
+
|
|
60
|
+
# The query's projection names, when `out_sql` names them by position: `_0`, `_1`, ...
|
|
61
|
+
projection_names: tuple[str, ...] | None = None
|
|
62
|
+
|
|
63
|
+
# The engine that runs `out_sql`, e.g. the metadata engine for information_schema queries;
|
|
64
|
+
# None for the warehouse
|
|
65
|
+
engine: str | None = None
|
|
66
|
+
|
|
67
|
+
# Whether the SQL was `EXPLAIN <query>`, whose result is `out_sql` itself rather than its rows
|
|
68
|
+
explain: bool = False
|
|
69
|
+
|
|
70
|
+
# The physical tables `out_sql` reads, by their parts as written, e.g. `("shop", "main","orders")`.
|
|
71
|
+
# None unless compiled `with_tables`
|
|
72
|
+
tables: tuple[tuple[str, ...], ...] | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Names are quoted once normalized, so they compare exactly with the registry's.
|
|
76
|
+
ANALYZER_RULES: Sequence[OptimizerFn] = (
|
|
77
|
+
qualify_tables,
|
|
78
|
+
qualify_columns,
|
|
79
|
+
validate_qualify_columns,
|
|
80
|
+
quote_identifiers,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
PLANNER_RULES: Sequence[OptimizerFn] = (
|
|
84
|
+
isolate_fragments,
|
|
85
|
+
name_passthrough_tables,
|
|
86
|
+
ExpandMetadataRule(),
|
|
87
|
+
ExpandConstellationsRule(),
|
|
88
|
+
ExpandDatasetsRule(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Compiler:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
cluster: Cluster | None = None,
|
|
96
|
+
analyzer_rules: Sequence[OptimizerFn] = ANALYZER_RULES,
|
|
97
|
+
planner_rules: Sequence[OptimizerFn] = PLANNER_RULES,
|
|
98
|
+
catalog: str | None = None,
|
|
99
|
+
db: str | None = None,
|
|
100
|
+
input_dialect: str = "",
|
|
101
|
+
output_dialect: str = "",
|
|
102
|
+
engine_dialects: Mapping[str, str] | None = None,
|
|
103
|
+
normalization_strategy: NormalizationStrategy | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""`engine_dialects` is the SQL dialect of each engine a plan can name, e.g.
|
|
106
|
+
`{"metadata": <its dialect>}`; the warehouse's is `output_dialect`.
|
|
107
|
+
|
|
108
|
+
`normalization_strategy` is the registry's, which queries read names with, whatever
|
|
109
|
+
their dialect's own, so their names match the registry's."""
|
|
110
|
+
self.cluster = cluster or Cluster()
|
|
111
|
+
self.analyzer_rules = analyzer_rules
|
|
112
|
+
self.planner_rules = planner_rules
|
|
113
|
+
self.catalog = catalog
|
|
114
|
+
self.db = db
|
|
115
|
+
self.input_dialect = input_dialect
|
|
116
|
+
self.output_dialect = output_dialect
|
|
117
|
+
self.engine_dialects = dict(engine_dialects or {})
|
|
118
|
+
self.normalization_strategy = normalization_strategy
|
|
119
|
+
|
|
120
|
+
def compile(
|
|
121
|
+
self,
|
|
122
|
+
sql: str,
|
|
123
|
+
*,
|
|
124
|
+
read_only: bool = False,
|
|
125
|
+
input_dialect: str | None = None,
|
|
126
|
+
output_dialect: str | None = None,
|
|
127
|
+
positional_columns: bool = False,
|
|
128
|
+
with_tables: bool = False,
|
|
129
|
+
db: str | None = None,
|
|
130
|
+
) -> Plan:
|
|
131
|
+
"""Compiles `sql`.
|
|
132
|
+
|
|
133
|
+
`db` names the db unqualified tables are in, as a client gave it, e.g. a Trino session
|
|
134
|
+
schema `Sales`, instead of the compiler's. It's normalized as a name in `sql` is, quoted
|
|
135
|
+
only if it must be, e.g. `my-schema`.
|
|
136
|
+
|
|
137
|
+
With `positional_columns`, a query's result columns are named `_0`, `_1`, ... and
|
|
138
|
+
`Plan.projection_names` holds its own names, which a warehouse may reject, e.g. Looker's
|
|
139
|
+
`accounts.segment` on BigQuery.
|
|
140
|
+
|
|
141
|
+
`EXPLAIN <query>` compiles the query, with `Plan.explain` set; EXPLAIN's options, e.g.
|
|
142
|
+
`EXPLAIN ANALYZE`, aren't supported.
|
|
143
|
+
|
|
144
|
+
With `with_tables`, `Plan.tables` holds the physical tables the compiled SQL reads, e.g.
|
|
145
|
+
for dbt to order a model after the models that build them.
|
|
146
|
+
"""
|
|
147
|
+
input_dialect = self.input_dialect if input_dialect is None else input_dialect
|
|
148
|
+
output_dialect = self.output_dialect if output_dialect is None else output_dialect
|
|
149
|
+
# sqlglot's qualify takes the db as SQL, which it parses and normalizes with the query's
|
|
150
|
+
# dialect and normalization strategy.
|
|
151
|
+
default_db = self.db if db is None else exp.to_identifier(db).sql(dialect=input_dialect)
|
|
152
|
+
if self.normalization_strategy is not None:
|
|
153
|
+
input_dialect = (
|
|
154
|
+
f"{input_dialect}, normalization_strategy = "
|
|
155
|
+
f"{self.normalization_strategy.value.lower()}"
|
|
156
|
+
)
|
|
157
|
+
expressions = parse(sql, read=input_dialect)
|
|
158
|
+
explain = False
|
|
159
|
+
match expressions:
|
|
160
|
+
# sqlglot parses EXPLAIN as a command whose argument is the rest of the SQL, unparsed.
|
|
161
|
+
case [exp.Command(this=str(command), expression=exp.Literal(this=str(explained)))] if (
|
|
162
|
+
command.upper() == "EXPLAIN"
|
|
163
|
+
):
|
|
164
|
+
explain = True
|
|
165
|
+
expressions = parse(explained, read=input_dialect)
|
|
166
|
+
match expressions:
|
|
167
|
+
case [exp.Query() | exp.Values()]:
|
|
168
|
+
pass
|
|
169
|
+
case _:
|
|
170
|
+
raise ValueError("EXPLAIN supports only one query statement")
|
|
171
|
+
if read_only:
|
|
172
|
+
match expressions:
|
|
173
|
+
case [exp.Query() | exp.Values()]:
|
|
174
|
+
pass
|
|
175
|
+
case _:
|
|
176
|
+
raise ValueError("Only one query statement is supported")
|
|
177
|
+
|
|
178
|
+
out = []
|
|
179
|
+
tables: dict[tuple[str, ...], None] = {}
|
|
180
|
+
engines: set[str | None] = set()
|
|
181
|
+
projection_names: tuple[str, ...] | None = None
|
|
182
|
+
|
|
183
|
+
for expression in expressions:
|
|
184
|
+
if not expression:
|
|
185
|
+
continue
|
|
186
|
+
self._prepare(expression, input_dialect, default_db, read_only=read_only)
|
|
187
|
+
planned = self._apply_rules(expression, input_dialect, default_db)
|
|
188
|
+
engine = planned.meta.get(ENGINE)
|
|
189
|
+
engines.add(engine)
|
|
190
|
+
if len(engines) > 1:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"Statements that run on different engines must be compiled separately"
|
|
193
|
+
)
|
|
194
|
+
if positional_columns:
|
|
195
|
+
match planned:
|
|
196
|
+
case exp.Query():
|
|
197
|
+
projection_names, planned = _positional(planned)
|
|
198
|
+
if with_tables:
|
|
199
|
+
tables.update(dict.fromkeys(_physical_tables(planned)))
|
|
200
|
+
dialect = self._dialect(engine, output_dialect)
|
|
201
|
+
# The planned tree is discarded, so the generator needn't copy it first.
|
|
202
|
+
out.append(planned.sql(dialect=dialect, pretty=True, identify=True, copy=False))
|
|
203
|
+
|
|
204
|
+
if len(out) == 0:
|
|
205
|
+
raise ValueError("Failed to compile SQL")
|
|
206
|
+
return Plan(
|
|
207
|
+
out_sql=";\n".join(out),
|
|
208
|
+
projection_names=projection_names,
|
|
209
|
+
engine=engine,
|
|
210
|
+
explain=explain,
|
|
211
|
+
tables=tuple(tables) if with_tables else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _apply_rules(self, expression: exp.Expr, dialect: str, db: str | None) -> exp.Expr:
|
|
215
|
+
arguments = {
|
|
216
|
+
"schema": self.cluster,
|
|
217
|
+
"cluster": self.cluster,
|
|
218
|
+
"dialect": dialect,
|
|
219
|
+
"catalog": self.catalog,
|
|
220
|
+
"db": db,
|
|
221
|
+
}
|
|
222
|
+
for rule in (*self.analyzer_rules, *self.planner_rules):
|
|
223
|
+
parameters = inspect.getfullargspec(rule).args
|
|
224
|
+
expression = rule(expression, **{p: arguments[p] for p in parameters if p in arguments})
|
|
225
|
+
if ENGINE in expression.meta:
|
|
226
|
+
break
|
|
227
|
+
return expression
|
|
228
|
+
|
|
229
|
+
def _dialect(self, engine: str | None, output_dialect: str) -> str:
|
|
230
|
+
if engine is None:
|
|
231
|
+
return output_dialect
|
|
232
|
+
if engine not in self.engine_dialects:
|
|
233
|
+
raise ValueError(f"No dialect for engine {engine}")
|
|
234
|
+
return self.engine_dialects[engine]
|
|
235
|
+
|
|
236
|
+
def _prepare(
|
|
237
|
+
self, expression: exp.Expr, dialect: str, db: str | None, *, read_only: bool
|
|
238
|
+
) -> None:
|
|
239
|
+
for node in expression.walk():
|
|
240
|
+
match node:
|
|
241
|
+
case exp.Table(this=exp.Anonymous()) if self._is_dataset(node, dialect, db):
|
|
242
|
+
bind_table_arguments(node)
|
|
243
|
+
case exp.Into() if read_only:
|
|
244
|
+
raise ValueError("Only one query statement is supported")
|
|
245
|
+
|
|
246
|
+
def _is_dataset(self, table: exp.Table, dialect: str, db: str | None) -> bool:
|
|
247
|
+
"""Nothing is loaded yet, so a constellation counts too."""
|
|
248
|
+
# Not normalized yet, so normalize as `qualify` will. `normalize_identifiers` changes its
|
|
249
|
+
# argument, so the query's own catalog is copied.
|
|
250
|
+
if table.args.get("catalog"):
|
|
251
|
+
catalog = table.args["catalog"].copy()
|
|
252
|
+
elif self.catalog:
|
|
253
|
+
catalog = exp.parse_identifier(self.catalog, dialect=dialect)
|
|
254
|
+
else:
|
|
255
|
+
return False
|
|
256
|
+
match self.cluster.get_catalog(normalize_identifiers(catalog, dialect=dialect).name):
|
|
257
|
+
case SemanticCatalog():
|
|
258
|
+
return (table.db or db or "").lower() != INFORMATION_SCHEMA
|
|
259
|
+
case _:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _physical_tables(expression: exp.Expr) -> list[tuple[str, ...]]:
|
|
264
|
+
tables = [
|
|
265
|
+
source
|
|
266
|
+
for scope in traverse_scope(expression)
|
|
267
|
+
for _alias, (_node, source) in scope.selected_sources.items()
|
|
268
|
+
if isinstance(source, exp.Table)
|
|
269
|
+
]
|
|
270
|
+
return [tuple(part.name for part in table.parts) for table in tables]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _positional(query: exp.Query) -> tuple[tuple[str, ...], exp.Query]:
|
|
274
|
+
"""`query`'s column names, and a query that only renames its columns by position, e.g.
|
|
275
|
+
`SELECT 1 AS "a.b"` -> `SELECT q."a.b" AS _0 FROM (SELECT 1 AS "a.b") AS q`.
|
|
276
|
+
|
|
277
|
+
Qualification aliases every column. A repeated alias, which `q` couldn't tell apart, is first
|
|
278
|
+
renamed to one no column has, e.g. `_1` in `SELECT a, b AS a`.
|
|
279
|
+
"""
|
|
280
|
+
names = tuple(select.alias for select in query.selects)
|
|
281
|
+
taken = {name.lower() for name in names}
|
|
282
|
+
seen: set[str] = set()
|
|
283
|
+
for index, (select, name) in enumerate(zip(query.selects, names, strict=True)):
|
|
284
|
+
if name.lower() in seen:
|
|
285
|
+
alias = find_new_name(taken, f"_{index}")
|
|
286
|
+
taken.add(alias)
|
|
287
|
+
select.replace(exp.alias_(select.unalias(), alias, copy=False))
|
|
288
|
+
seen.add(name.lower())
|
|
289
|
+
|
|
290
|
+
# The planned tree is discarded, so nothing here needs a copy, but the alias each column names.
|
|
291
|
+
renamed = exp.select(
|
|
292
|
+
*(
|
|
293
|
+
exp.column(select.args["alias"], table="q").as_(f"_{index}", copy=False)
|
|
294
|
+
for index, select in enumerate(query.selects)
|
|
295
|
+
)
|
|
296
|
+
).from_(query.subquery("q", copy=False), copy=False)
|
|
297
|
+
return names, renamed
|
|
File without changes
|