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,88 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from sqlglot import exp
6
+ from sqlglot.optimizer.scope import traverse_scope
7
+
8
+ from metricflow_sql.compiler.catalog.cluster import Cluster
9
+ from metricflow_sql.compiler.catalog.metadata import MetadataTable
10
+ from metricflow_sql.utils.sql import FINAL
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # The key in a planned query's `meta` naming the engine that runs it, when it reads metadata.
15
+ ENGINE = "engine"
16
+
17
+ # A table's name and alias; anything else, e.g. TABLESAMPLE, can't carry over to its source.
18
+ _NAME_ARGS = ("this", "db", "catalog", "alias")
19
+
20
+
21
+ class ExpandMetadataRule:
22
+ """Replace each metadata table, e.g. `information_schema.tables`, with the query defining it.
23
+
24
+ A query that reads metadata reads only metadata, all from one engine: any other table,
25
+ including a table function, would run on that engine and reach past what the metadata
26
+ tables expose.
27
+ """
28
+
29
+ def __call__(self, expression: exp.Expr, cluster: Cluster) -> exp.Expr:
30
+ metadata: list[tuple[exp.Table, MetadataTable]] = []
31
+ reads_other_tables = False
32
+ for scope in traverse_scope(expression):
33
+ for source in scope.sources.values():
34
+ match source:
35
+ # A warehouse table
36
+ case exp.Table() if source.meta.get(FINAL):
37
+ reads_other_tables = True
38
+ case exp.Table():
39
+ table = _metadata_table(cluster, source)
40
+ if table is None:
41
+ reads_other_tables = True
42
+ else:
43
+ metadata.append((source, table))
44
+
45
+ if not metadata:
46
+ return expression
47
+ if reads_other_tables:
48
+ raise ValueError("Queries mixing metadata and data tables are not supported")
49
+ _check(expression)
50
+
51
+ engines = {table.engine for _, table in metadata}
52
+ if len(engines) > 1:
53
+ raise ValueError("A query can only read metadata from one engine")
54
+
55
+ for source, table in metadata:
56
+ if any(value for key, value in source.args.items() if key not in _NAME_ARGS):
57
+ raise ValueError("Unsupported metadata table modifier")
58
+ source.replace(table.query().subquery(source.alias_or_name))
59
+
60
+ expression.meta[ENGINE] = engines.pop()
61
+ return expression
62
+
63
+
64
+ def _check(expression: exp.Expr) -> None:
65
+ """Only tables (each a metadata table, as checked above), subqueries, UNNEST and VALUES may
66
+ supply rows, e.g. not a `LATERAL` table function, which could read the engine itself.
67
+
68
+ Only functions sqlglot knows may be called: it passes any other through as is, e.g.
69
+ `query_to_xml('SELECT * FROM objects', ...)`, which runs a query of its own on the engine."""
70
+ for node in expression.find_all(exp.From, exp.Join, exp.Lateral, exp.Anonymous):
71
+ match node, node.this:
72
+ case exp.Anonymous(), _:
73
+ raise ValueError(f"Unsupported function in a metadata query: {node.name}")
74
+ case _, exp.Subquery():
75
+ pass
76
+ # A LATERAL may only wrap a subquery.
77
+ case exp.From() | exp.Join(), exp.Table() | exp.Unnest() | exp.Values() | exp.Lateral():
78
+ pass
79
+ case _:
80
+ raise ValueError(f"Unsupported metadata source: {node.this.sql()}")
81
+
82
+
83
+ def _metadata_table(cluster: Cluster, table: exp.Table) -> MetadataTable | None:
84
+ match cluster.find_table(table) if cluster.get_catalog(table.catalog) else None:
85
+ case MetadataTable() as metadata:
86
+ return metadata
87
+ case _:
88
+ return None
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlglot import exp
4
+ from sqlglot.helper import find_new_name, name_sequence
5
+ from sqlglot.optimizer.scope import (
6
+ Scope,
7
+ find_all_in_scope,
8
+ find_in_scope,
9
+ traverse_scope,
10
+ walk_in_scope,
11
+ )
12
+
13
+ # Clauses that are not part of the fragment. They move to the outer select.
14
+ _NON_FRAGMENT_ARGS = ("having", "qualify", "distinct", "order", "limit", "offset")
15
+
16
+ # Nodes of a GROUP BY that wrap the grouped expressions instead of being one.
17
+ _GROUPING_WRAPPERS = (exp.Cube, exp.GroupingSets, exp.Paren, exp.Rollup, exp.Tuple)
18
+
19
+
20
+ def isolate_fragments(expression: exp.Expr) -> exp.Expr:
21
+ """
22
+ Isolate the fragment of every select in a derived table. The fragment is the
23
+ projections, joins, WHERE and GROUP BY of the select. Its HAVING, QUALIFY,
24
+ DISTINCT, ORDER BY, LIMIT and OFFSET move to an outer select:
25
+
26
+ SELECT day, AGG(a) FROM t GROUP BY 1 HAVING AGG(b) > 0 ORDER BY day
27
+ =>
28
+ SELECT day, a
29
+ FROM (SELECT day, AGG(a) AS a, AGG(b) AS b FROM t GROUP BY 1) AS t
30
+ WHERE b > 0
31
+ ORDER BY day
32
+
33
+ The derived table takes the alias of the FROM, so that the outer select reads
34
+ like the original. The DISTINCT of a select that does not aggregate becomes
35
+ its GROUP BY instead.
36
+ """
37
+ next_alias = name_sequence("_f_")
38
+
39
+ # The scopes are built once, before any select changes. An outer select finds
40
+ # the references of an isolated subquery through the columns its scope read before the
41
+ # change, so clearing the cache of a scope in this loop loses them. A fresh build_scope
42
+ # per select would avoid that, but each select would rebuild the scopes below it.
43
+ for scope in traverse_scope(expression):
44
+ select = scope.expression
45
+ if isinstance(select, exp.Select) and not select.is_star:
46
+ _group_by_distinct(select)
47
+ if any(select.args.get(arg) for arg in _NON_FRAGMENT_ARGS):
48
+ fragment_alias = _from_alias(select) or exp.to_identifier(next_alias())
49
+ outer = _isolate(select, scope, fragment_alias)
50
+ if select is expression:
51
+ expression = outer
52
+
53
+ return expression
54
+
55
+
56
+ def _from_alias(select: exp.Select) -> exp.Identifier | None:
57
+ from_ = select.args.get("from_")
58
+ table_alias = from_.this.args.get("alias") if from_ else None
59
+ return table_alias.this.copy() if table_alias else None
60
+
61
+
62
+ def _group_by_distinct(select: exp.Select) -> None:
63
+ """
64
+ Replaces the DISTINCT of a select with a GROUP BY over its projections, which
65
+ returns the same rows. A select that aggregates or has a window function
66
+ keeps its DISTINCT, since it applies to the rows these produce.
67
+ """
68
+ distinct = select.args.get("distinct")
69
+ if (
70
+ not distinct
71
+ or distinct.args.get("on")
72
+ or select.args.get("group")
73
+ or select.args.get("having")
74
+ or any(
75
+ find_in_scope(projection, exp.AggFunc) or find_in_scope(projection, exp.Window)
76
+ for projection in select.expressions
77
+ )
78
+ ):
79
+ return
80
+
81
+ select.set("distinct", None)
82
+ select.group_by(*(projection.this.copy() for projection in select.expressions), copy=False)
83
+
84
+
85
+ def _isolate(select: exp.Select, scope: Scope, fragment_alias: exp.Identifier) -> exp.Select:
86
+ taken: set[str] = set()
87
+ inner_columns: dict[exp.Expr, exp.Identifier] = {}
88
+ outer_projections = []
89
+
90
+ # Deduplicate projection names, so that they can be unambiguously referenced from outer scopes
91
+ for projection in select.expressions:
92
+ name = projection.args["alias"]
93
+ inner = _reserve_name(name, taken)
94
+ projection.set("alias", inner)
95
+ inner_columns.setdefault(projection.this, inner)
96
+ outer_projections.append(exp.alias_(exp.column(inner, fragment_alias), name, copy=False))
97
+ outer = exp.select(*outer_projections, copy=False)
98
+
99
+ clauses = {arg: clause for arg in _NON_FRAGMENT_ARGS if (clause := select.args.get(arg))}
100
+
101
+ _rewrite_for_outer(clauses, select, scope, fragment_alias, inner_columns, taken)
102
+
103
+ for arg, clause in clauses.items():
104
+ select.set(arg, None)
105
+ if arg == "having":
106
+ outer.where(clause.this, copy=False)
107
+ else:
108
+ outer.set(arg, clause)
109
+
110
+ with_ = select.args.get("with_")
111
+ if with_:
112
+ outer.set("with_", with_.pop())
113
+
114
+ select.replace(outer)
115
+
116
+ outer.from_(select.subquery(fragment_alias, copy=False), copy=False)
117
+ return outer
118
+
119
+
120
+ def _rewrite_for_outer(
121
+ clauses: dict[str, exp.Expr],
122
+ select: exp.Select,
123
+ scope: Scope,
124
+ fragment_alias: exp.Identifier,
125
+ inner_columns: dict[exp.Expr, exp.Identifier],
126
+ taken: set[str],
127
+ ) -> None:
128
+ """
129
+ Rewrites the clauses in place to read what the select computes through its
130
+ derived table, projecting from the select whatever it does not project yet.
131
+ """
132
+ local_columns = {id(column) for column in scope.local_columns}
133
+ scope_columns = {id(column) for column in scope.columns}
134
+
135
+ grouped = _grouping_expressions(group) if (group := select.args.get("group")) else set()
136
+
137
+ # Subqueries that read a column of the select, at any depth. Nothing inside any
138
+ # other subquery can move, so the walk skips them. A climb stops at a query
139
+ # already added, since the queries above it are added too.
140
+ correlated_queries: set[int] = set()
141
+ for subquery_scope in scope.subquery_scopes:
142
+ for column in subquery_scope.external_columns:
143
+ if id(column) in local_columns:
144
+ query = column.find_ancestor(exp.Query)
145
+ while query and query is not select and id(query) not in correlated_queries:
146
+ correlated_queries.add(id(query))
147
+ query = query.find_ancestor(exp.Query)
148
+
149
+ def project(node: exp.Expr) -> None:
150
+ """
151
+ Replaces the node with the inner column that computes it, adding that
152
+ projection to the select unless it already has one.
153
+ """
154
+ identifier = inner_columns.get(node)
155
+ if identifier:
156
+ node.replace(exp.column(identifier, fragment_alias))
157
+ return
158
+
159
+ # A column, a path and a measure keep their identifier, anything nameless
160
+ # is numbered like qualify does.
161
+ identifier = _reserve_name(
162
+ _output_identifier(node)
163
+ or exp.to_identifier(node.output_name or f"_col_{len(select.expressions)}"),
164
+ taken,
165
+ )
166
+ inner_columns[node] = identifier
167
+ node.replace(exp.column(identifier, fragment_alias))
168
+ select.select(exp.alias_(node, identifier, copy=False), copy=False)
169
+
170
+ def move(node: exp.Expr, nested: bool) -> None:
171
+ root = node
172
+ while isinstance(root, exp.Dot):
173
+ root = root.this
174
+
175
+ # A local aggregate is evaluated by the select, wherever it is written.
176
+ # E.g. SUM(x.b) in: FROM x HAVING EXISTS (SELECT 1 FROM y WHERE y.b < SUM(x.b))
177
+ if _is_local_aggregate(node, nested, local_columns, scope_columns):
178
+ project(node)
179
+ elif id(root) in local_columns or (not nested and node in grouped):
180
+ project(node)
181
+ elif not isinstance(node, exp.Query) or id(node) in correlated_queries:
182
+ for child in node.iter_expressions():
183
+ move(child, nested or isinstance(node, exp.Query))
184
+
185
+ for clause in clauses.values():
186
+ move(clause, nested=False)
187
+
188
+
189
+ def _grouping_expressions(group: exp.Group) -> set[exp.Expr]:
190
+ return {
191
+ node
192
+ for expression in group.iter_expressions()
193
+ for node in walk_in_scope(
194
+ expression, prune=lambda node: not isinstance(node, _GROUPING_WRAPPERS)
195
+ )
196
+ if not isinstance(node, _GROUPING_WRAPPERS)
197
+ }
198
+
199
+
200
+ def _reserve_name(identifier: exp.Identifier, taken: set[str]) -> exp.Identifier:
201
+ name = find_new_name(taken, identifier.name)
202
+ taken.add(name)
203
+ if name == identifier.name:
204
+ return identifier
205
+
206
+ return exp.to_identifier(name, quoted=identifier.quoted)
207
+
208
+
209
+ def _output_identifier(node: exp.Expr) -> exp.Identifier | None:
210
+ """
211
+ The identifier that names a column, the last field of a path or the measure
212
+ of an AGG, so that the projection keeps its quotes.
213
+ """
214
+ if isinstance(node, exp.Agg):
215
+ node = node.this
216
+
217
+ if isinstance(node, exp.Dot):
218
+ node = node.expression
219
+ elif isinstance(node, exp.Column):
220
+ node = node.this
221
+
222
+ return node if isinstance(node, exp.Identifier) else None
223
+
224
+
225
+ def _is_local_aggregate(
226
+ node: exp.Expr, nested: bool, local_columns: set[int], scope_columns: set[int]
227
+ ) -> bool:
228
+ """
229
+ Whether the isolated select evaluates the aggregate. Only columns written
230
+ directly in the aggregate count.
231
+ """
232
+ # A FILTER moves with its aggregate, e.g. SUM(x) FILTER (WHERE y > 1), so the aggregate
233
+ # inside it isn't one on its own.
234
+ aggregate = node.this if isinstance(node, exp.Filter) else node
235
+ if not isinstance(aggregate, exp.AggFunc) or isinstance(node.parent, exp.Window | exp.Filter):
236
+ return False
237
+
238
+ columns = {id(column) for column in find_all_in_scope(node, exp.Column)}
239
+ if not columns:
240
+ # An aggregate without columns, like COUNT(*), belongs to the select it is written in
241
+ return not nested
242
+
243
+ # It reads at least one of the select's own columns and no column of a subquery
244
+ return not columns.isdisjoint(local_columns) and columns <= scope_columns
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from sqlglot import exp
6
+ from sqlglot.optimizer.scope import traverse_scope
7
+
8
+ from metricflow_sql.compiler.catalog.cluster import Cluster
9
+ from metricflow_sql.compiler.catalog.passthrough import PassthroughCatalog
10
+ from metricflow_sql.utils.sql import FINAL
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def name_passthrough_tables(expression: exp.Expr, cluster: Cluster) -> exp.Expr:
16
+ """
17
+ Rename each passthrough table to the warehouse's name for it, e.g. `analytics.sales.orders`
18
+ to `"ANALYTICS"."SALES"."ORDERS"` on Snowflake, and mark it final.
19
+
20
+ Runs before the other planner rules, which skip final tables.
21
+ """
22
+ for scope in traverse_scope(expression):
23
+ for source in scope.sources.values():
24
+ match source:
25
+ case exp.Table() if isinstance(
26
+ catalog := cluster.get_catalog(source.catalog), PassthroughCatalog
27
+ ):
28
+ # Already looked up while qualifying
29
+ found = catalog.get_table(source)
30
+ assert found is not None, f"Unknown passthrough table: {source.sql()}"
31
+ physical = found.physical.table
32
+ for part in ("catalog", "db", "this"):
33
+ name = physical.args.get(part)
34
+ source.set(part, name.copy() if name is not None else None)
35
+ source.meta[FINAL] = True
36
+ return expression
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from sqlglot import exp
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Key into `exp.Table.meta` holding a table function's arguments, by parameter name.
10
+ TABLE_ARGUMENTS = "arguments"
11
+
12
+
13
+ def bind_table_arguments(table: exp.Table) -> None:
14
+ """
15
+ Turns a table function into a plain table that carries its arguments.
16
+
17
+ A dataset's parameters are arguments of the table it is read as, e.g.
18
+ `FROM bulk_store_sales(min_quantity => 90)` becomes `FROM bulk_store_sales`, with
19
+ `{"min_quantity": 90}` in the table's meta for dataset expansion to render with. Must
20
+ run before qualification, so it sees ordinary tables.
21
+ """
22
+ match table.this:
23
+ case exp.Anonymous(this=name, expressions=arguments):
24
+ bound: dict[str, exp.Expr] = {}
25
+ for argument in arguments:
26
+ match argument:
27
+ case exp.Kwarg(this=parameter, expression=value):
28
+ bound[parameter.name] = value
29
+ case _:
30
+ raise ValueError(
31
+ f"Table function arguments must be named, e.g. "
32
+ f"`{name}(parameter => value)`: {argument.sql()}"
33
+ )
34
+ table.set("this", exp.to_identifier(name))
35
+ table.meta[TABLE_ARGUMENTS] = bound
File without changes