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.
Files changed (56) hide show
  1. metricflow_sql/__init__.py +8 -0
  2. metricflow_sql/compiler/__init__.py +0 -0
  3. metricflow_sql/compiler/catalog/__init__.py +0 -0
  4. metricflow_sql/compiler/catalog/cluster.py +152 -0
  5. metricflow_sql/compiler/catalog/information_schema.py +47 -0
  6. metricflow_sql/compiler/catalog/metadata.py +65 -0
  7. metricflow_sql/compiler/catalog/passthrough.py +184 -0
  8. metricflow_sql/compiler/catalog/protocols.py +67 -0
  9. metricflow_sql/compiler/catalog/semantic.py +59 -0
  10. metricflow_sql/compiler/compiler.py +297 -0
  11. metricflow_sql/compiler/rules/__init__.py +0 -0
  12. metricflow_sql/compiler/rules/expand_constellations.py +790 -0
  13. metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
  14. metricflow_sql/compiler/rules/expand_metadata.py +88 -0
  15. metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
  16. metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
  17. metricflow_sql/compiler/table_arguments.py +35 -0
  18. metricflow_sql/converters/__init__.py +0 -0
  19. metricflow_sql/converters/lookml.py +1041 -0
  20. metricflow_sql/objects/__init__.py +0 -0
  21. metricflow_sql/objects/base.py +308 -0
  22. metricflow_sql/objects/constellation.py +210 -0
  23. metricflow_sql/objects/dataset.py +275 -0
  24. metricflow_sql/objects/entity.py +131 -0
  25. metricflow_sql/objects/reference.py +35 -0
  26. metricflow_sql/objects/wiki.py +101 -0
  27. metricflow_sql/py.typed +0 -0
  28. metricflow_sql/registry/__init__.py +0 -0
  29. metricflow_sql/registry/memory.py +409 -0
  30. metricflow_sql/registry/operation.py +25 -0
  31. metricflow_sql/registry/reference_resolution.py +68 -0
  32. metricflow_sql/registry/registry.py +225 -0
  33. metricflow_sql/registry/type_resolver.py +460 -0
  34. metricflow_sql/schemas/__init__.py +0 -0
  35. metricflow_sql/schemas/bundle.py +327 -0
  36. metricflow_sql/schemas/fragment.schema.json +1165 -0
  37. metricflow_sql/schemas/generate.py +70 -0
  38. metricflow_sql/schemas/load.py +333 -0
  39. metricflow_sql/schemas/v1/__init__.py +51 -0
  40. metricflow_sql/schemas/v1/fragment.py +544 -0
  41. metricflow_sql/server/__init__.py +4 -0
  42. metricflow_sql/server/app.py +33 -0
  43. metricflow_sql/server/compile.py +69 -0
  44. metricflow_sql/server/errors.py +10 -0
  45. metricflow_sql/server/serve.py +161 -0
  46. metricflow_sql/utils/__init__.py +0 -0
  47. metricflow_sql/utils/composed_set.py +32 -0
  48. metricflow_sql/utils/normalize.py +74 -0
  49. metricflow_sql/utils/ordered_set.py +24 -0
  50. metricflow_sql/utils/sql.py +226 -0
  51. metricflow_sql/utils/template.py +57 -0
  52. metricflow_sql-0.1.1.dist-info/METADATA +30 -0
  53. metricflow_sql-0.1.1.dist-info/RECORD +56 -0
  54. metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
  55. metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
  56. 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