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,790 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
from sqlglot import exp
|
|
8
|
+
from sqlglot.helper import find_new_name
|
|
9
|
+
from sqlglot.optimizer.qualify import qualify
|
|
10
|
+
from sqlglot.optimizer.qualify_columns import quote_identifiers
|
|
11
|
+
from sqlglot.optimizer.scope import find_all_in_scope, find_in_scope, traverse_scope
|
|
12
|
+
from sqlglot.optimizer.simplify import simplify_parens
|
|
13
|
+
|
|
14
|
+
from metricflow_sql.compiler.catalog.cluster import Cluster
|
|
15
|
+
from metricflow_sql.compiler.catalog.semantic import SemanticCatalog
|
|
16
|
+
from metricflow_sql.compiler.table_arguments import TABLE_ARGUMENTS
|
|
17
|
+
from metricflow_sql.objects.base import resolve_dataset_path
|
|
18
|
+
from metricflow_sql.objects.constellation import Metric, Role
|
|
19
|
+
from metricflow_sql.registry.reference_resolution import resolve_metric
|
|
20
|
+
from metricflow_sql.registry.registry import ConstellationProxy, Registry
|
|
21
|
+
from metricflow_sql.utils.sql import group_all_keys, normalize_group, path_names, table_scopes
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Query clauses that can't be compiled on constellations yet. HAVING, QUALIFY, DISTINCT,
|
|
26
|
+
# ORDER BY, LIMIT and OFFSET never get here, since isolate_fragments moves them to an outer
|
|
27
|
+
# query first.
|
|
28
|
+
# - JOIN would be pushed down into every branch, like WHERE. It isn't built yet.
|
|
29
|
+
UNSUPPORTED_CLAUSES = {"joins": "JOIN"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _column(*parts: exp.Identifier) -> exp.Expr:
|
|
33
|
+
"""`accounts, account_facts, x` -> `accounts.account_facts.x`, reusing the parts."""
|
|
34
|
+
return exp.column(*reversed(parts[:4]), fields=list(parts[4:]) or None, copy=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prefixed(column: exp.Column, name: exp.Identifier) -> exp.Expr:
|
|
38
|
+
"""`bill_customer.birth_year` -> `catalog_sales.bill_customer.birth_year`"""
|
|
39
|
+
# `name` prefixes every column of an expression.
|
|
40
|
+
return _column(name.copy(), *path_names(column))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _replace(node: exp.Expr, replacement: exp.Expr) -> exp.Expr:
|
|
44
|
+
"""
|
|
45
|
+
Returns `replacement` to stand in for `node` in a transform, parenthesized when `node` sits
|
|
46
|
+
inside a larger expression, e.g. a metric `a + b` in `100 * AGG(metric)` gives `100 * (a + b)`.
|
|
47
|
+
|
|
48
|
+
sqlglot doesn't parenthesize a replaced node, so it's always wrapped here, and the rule drops
|
|
49
|
+
the parentheses that aren't needed once the query is expanded.
|
|
50
|
+
"""
|
|
51
|
+
if node.parent is not None:
|
|
52
|
+
return exp.Paren(this=replacement)
|
|
53
|
+
return replacement
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Leaf:
|
|
58
|
+
"""
|
|
59
|
+
An aggregate over a single dataset, computed by that dataset's branch.
|
|
60
|
+
|
|
61
|
+
Metrics are unfolded until only leaves are left. In the TPC-DS registry,
|
|
62
|
+
|
|
63
|
+
store_return_rate = AGG(store_return_amount) / AGG(store_sales_amount)
|
|
64
|
+
store_return_amount = AGG(store_returns.return_amount)
|
|
65
|
+
store_sales_amount = AGG(store_sales.sales_amount)
|
|
66
|
+
|
|
67
|
+
so `AGG(store_return_rate)` unfolds to two leaves: `AGG(return_amount)` on `store_returns`
|
|
68
|
+
and `AGG(sales_amount)` on `store_sales`.
|
|
69
|
+
|
|
70
|
+
A leaf is named after what it computes: the calculation (`return_amount`), or the metric
|
|
71
|
+
for an aggregate written inline in it (`catalog_sales_amount = SUM(...)`).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
dataset: exp.Table
|
|
75
|
+
expr: exp.Expr
|
|
76
|
+
name: str
|
|
77
|
+
# A filter over roles that only this aggregate applies, from a query's
|
|
78
|
+
# `AGG(metric) FILTER (WHERE ...)`, e.g. `customer.birth_year > 1960`.
|
|
79
|
+
condition: exp.Expr | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ConstellationContext:
|
|
84
|
+
registry: Registry
|
|
85
|
+
constellation: ConstellationProxy
|
|
86
|
+
catalog: str
|
|
87
|
+
# The name the query's FROM gives the constellation, e.g. `u` in `FROM universe AS u`.
|
|
88
|
+
# qualify prefixes the constellation's columns with it.
|
|
89
|
+
alias: str
|
|
90
|
+
# `FROM universe(min_birth_year => 1985)` reads each branch's dataset with them.
|
|
91
|
+
arguments: dict[str, exp.Expr]
|
|
92
|
+
# The schema branches are qualified against before ExpandDatasetsRule expands them.
|
|
93
|
+
cluster: Cluster
|
|
94
|
+
# The query's GROUP BY expressions, i.e. the join keys, mapped to the column the branches
|
|
95
|
+
# output them as, e.g. `item.category` -> `item_category`. Matched structurally, since
|
|
96
|
+
# qualify writes every use of a GROUP BY expression the same way.
|
|
97
|
+
keys: dict[exp.Expr, str] = field(default_factory=dict)
|
|
98
|
+
# Keyed by (dataset, aggregate, filter), so an aggregate shared by several metrics is
|
|
99
|
+
# computed once.
|
|
100
|
+
leaves: dict[tuple[exp.Table, exp.Expr, exp.Expr | None], Leaf] = field(default_factory=dict)
|
|
101
|
+
# Metrics being unfolded right now, to catch a metric that references itself. Keyed by
|
|
102
|
+
# constellation too, as imported metrics may share a name.
|
|
103
|
+
expanding: set[tuple[exp.Table, exp.Identifier]] = field(default_factory=set)
|
|
104
|
+
# Whether the query has no GROUP BY or AGG, so it reads the datasets' rows, stacked.
|
|
105
|
+
stack: bool = False
|
|
106
|
+
# The query's dialect, used to qualify branches, normalize metrics and print errors.
|
|
107
|
+
dialect: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ExpandConstellationsRule:
|
|
111
|
+
"""
|
|
112
|
+
Sqlglot optimizer rule that compiles queries over constellations with drill across.
|
|
113
|
+
|
|
114
|
+
A constellation's metrics can read from several datasets, e.g. a return rate divides
|
|
115
|
+
returns from `store_returns` by sales from `store_sales`. Joining those datasets row by row
|
|
116
|
+
through a shared role such as the item category would pair every sale with every return
|
|
117
|
+
in the same category, and inflate both sums (the chasm trap). So the rule compiles:
|
|
118
|
+
|
|
119
|
+
1. One branch per dataset the metrics read from: a query that aggregates that dataset by
|
|
120
|
+
the query's GROUP BY expressions, so it has exactly one row per key, e.g. sales per
|
|
121
|
+
category. A GROUP BY expression can be any expression over roles, e.g. a birth decade,
|
|
122
|
+
and each branch computes it from its own assignments, like it does the WHERE.
|
|
123
|
+
2. A full outer join of the branches on those keys. Since every branch has one row per key,
|
|
124
|
+
the join can't multiply anything, and being full it keeps keys that only some datasets
|
|
125
|
+
have.
|
|
126
|
+
3. The metric arithmetic, on the joined rows. In a metric, `+` and `-` treat NULL as 0 and
|
|
127
|
+
a division by 0 gives NULL; arithmetic written in the query is left as written.
|
|
128
|
+
|
|
129
|
+
HAVING, ORDER BY and LIMIT were already moved to an outer query by isolate_fragments, which
|
|
130
|
+
also selects any metric they use, so e.g. an ORDER BY metric's dataset joins like any other.
|
|
131
|
+
|
|
132
|
+
For example, `SELECT item.category, AGG(store_return_rate) FROM universe GROUP BY 1` becomes
|
|
133
|
+
return amounts per category from `store_returns`, full outer joined with sales per category
|
|
134
|
+
from `store_sales`, and the rate is their ratio. Without GROUP BY, each branch is a single
|
|
135
|
+
row and the join matches them all.
|
|
136
|
+
|
|
137
|
+
Without metrics, the constellation stands for all its datasets stacked, so every dataset
|
|
138
|
+
that provides all the roles the query uses gets a branch with just the keys, and the join
|
|
139
|
+
merges them. Without GROUP BY either, the query reads the stacked rows themselves, so its
|
|
140
|
+
projections are the keys and the branches are stacked with UNION ALL instead.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def _fail(
|
|
144
|
+
self, ctx: ConstellationContext, message: str, expr: exp.Expr | None = None
|
|
145
|
+
) -> NoReturn:
|
|
146
|
+
if expr is not None:
|
|
147
|
+
message = f"{message}: {expr.sql(dialect=ctx.dialect)}"
|
|
148
|
+
raise ValueError(message)
|
|
149
|
+
|
|
150
|
+
def __call__(
|
|
151
|
+
self, expression: exp.Expr, cluster: Cluster, dialect: str | None = None
|
|
152
|
+
) -> exp.Expr:
|
|
153
|
+
for scope, select, table in table_scopes(expression):
|
|
154
|
+
if not (registry_and_constellation := cluster.get_constellation(table)):
|
|
155
|
+
continue
|
|
156
|
+
registry, constellation = registry_and_constellation
|
|
157
|
+
# Only a semantic catalog has constellations.
|
|
158
|
+
if not isinstance(cluster.get_catalog(table.catalog), SemanticCatalog):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
ctx = ConstellationContext(
|
|
162
|
+
registry=registry,
|
|
163
|
+
constellation=constellation,
|
|
164
|
+
catalog=table.catalog,
|
|
165
|
+
alias=table.alias_or_name,
|
|
166
|
+
arguments=table.meta.get(TABLE_ARGUMENTS, {}),
|
|
167
|
+
cluster=cluster,
|
|
168
|
+
dialect=dialect,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
query = self._expand_scope(select, ctx).transform(simplify_parens, dialect, copy=False)
|
|
172
|
+
|
|
173
|
+
select.replace(query)
|
|
174
|
+
|
|
175
|
+
if not scope.parent:
|
|
176
|
+
return query
|
|
177
|
+
|
|
178
|
+
return expression
|
|
179
|
+
|
|
180
|
+
def _expand_scope(self, select: exp.Select, ctx: ConstellationContext) -> exp.Select:
|
|
181
|
+
"""
|
|
182
|
+
Rewrites one SELECT over a constellation, following the steps in the class docstring.
|
|
183
|
+
|
|
184
|
+
Rewriting the projections registers the leaves, and the leaves decide which branches get
|
|
185
|
+
built and joined. Without leaves, every dataset that provides the roles the query uses gets a
|
|
186
|
+
branch.
|
|
187
|
+
"""
|
|
188
|
+
for arg, clause in UNSUPPORTED_CLAUSES.items():
|
|
189
|
+
if select.args.get(arg):
|
|
190
|
+
self._fail(ctx, f"Constellation queries don't support {clause} yet")
|
|
191
|
+
|
|
192
|
+
ctx.keys = self._keys(select, ctx)
|
|
193
|
+
condition = None
|
|
194
|
+
if isinstance(where := select.args.get("where"), exp.Where):
|
|
195
|
+
condition = where.this
|
|
196
|
+
self._check_condition(
|
|
197
|
+
condition,
|
|
198
|
+
"WHERE on a constellation can only use roles, use HAVING for metrics",
|
|
199
|
+
ctx,
|
|
200
|
+
)
|
|
201
|
+
ctx.stack = not ctx.keys and not find_in_scope(select, exp.AggFunc)
|
|
202
|
+
if ctx.stack:
|
|
203
|
+
# Each projection over roles is read from every dataset's rows, named like a GROUP BY
|
|
204
|
+
# key, e.g. `customer_birth_year`. _expand_expr rejects the rest, or NULLs metrics.
|
|
205
|
+
for projection in select.expressions:
|
|
206
|
+
expr = projection.unalias()
|
|
207
|
+
paths = self._paths(expr)
|
|
208
|
+
if (
|
|
209
|
+
paths
|
|
210
|
+
and not expr.find(exp.Query)
|
|
211
|
+
and all(self._role_parts(path, ctx) is not None for path in paths)
|
|
212
|
+
):
|
|
213
|
+
parts = self._role_parts(expr, ctx)
|
|
214
|
+
name = (
|
|
215
|
+
"_".join(part.name for part in parts) if parts else projection.alias_or_name
|
|
216
|
+
)
|
|
217
|
+
ctx.keys.setdefault(expr, find_new_name(set(ctx.keys.values()), name))
|
|
218
|
+
# The alias is reused as an identifier, so a quoted name like `"Sales"` stays quoted.
|
|
219
|
+
projections = [
|
|
220
|
+
exp.alias_(
|
|
221
|
+
self._expand_expr(projection.unalias(), ctx),
|
|
222
|
+
alias.copy()
|
|
223
|
+
if isinstance(alias := projection.args.get("alias"), exp.Identifier)
|
|
224
|
+
else projection.alias_or_name,
|
|
225
|
+
copy=False,
|
|
226
|
+
)
|
|
227
|
+
for projection in select.expressions
|
|
228
|
+
]
|
|
229
|
+
if ctx.leaves:
|
|
230
|
+
datasets = list(dict.fromkeys(leaf.dataset for leaf in ctx.leaves.values()))
|
|
231
|
+
elif ctx.keys:
|
|
232
|
+
datasets = self._key_datasets(condition, ctx)
|
|
233
|
+
else:
|
|
234
|
+
self._fail(ctx, "Constellation queries must aggregate a metric or group by roles")
|
|
235
|
+
|
|
236
|
+
if ctx.stack:
|
|
237
|
+
branches = [self._branch(dataset, condition, ctx) for dataset in datasets]
|
|
238
|
+
rows = exp.union(*branches, distinct=False, copy=False) if branches[1:] else branches[0]
|
|
239
|
+
else:
|
|
240
|
+
rows = self._join_branches(datasets, condition, ctx)
|
|
241
|
+
joined = rows.subquery(ctx.alias, copy=False)
|
|
242
|
+
return exp.select(*projections, copy=False).from_(joined, copy=False)
|
|
243
|
+
|
|
244
|
+
def _keys(self, select: exp.Select, ctx: ConstellationContext) -> dict[exp.Expr, str]:
|
|
245
|
+
"""
|
|
246
|
+
Returns the query's GROUP BY expressions, mapped to their column names.
|
|
247
|
+
|
|
248
|
+
A role is named after its path, and any other expression after its output name, e.g.
|
|
249
|
+
`SELECT item.category, customer.birth_year // 10 * 10 AS decade ... GROUP BY 1, 2` gives
|
|
250
|
+
`{item.category: "item_category", customer.birth_year // 10 * 10: "decade"}`.
|
|
251
|
+
GROUP BY ALL groups by every projection that doesn't aggregate.
|
|
252
|
+
"""
|
|
253
|
+
if not isinstance(group := select.args.get("group"), exp.Group):
|
|
254
|
+
return {}
|
|
255
|
+
# TODO: support ROLLUP, CUBE and GROUPING SETS. A branch then has one row per key and
|
|
256
|
+
# grouping set, so a rolled up NULL and a real NULL share a key and the join mixes them
|
|
257
|
+
# up. It needs GROUPING() carried as part of the key.
|
|
258
|
+
grouping_sets = normalize_group(group)
|
|
259
|
+
if len(grouping_sets) != 1:
|
|
260
|
+
self._fail(
|
|
261
|
+
ctx, "GROUPING SETS, CUBE and ROLLUP are not supported on constellations yet"
|
|
262
|
+
)
|
|
263
|
+
groupings = grouping_sets[0]
|
|
264
|
+
# A bare GROUP BY ALL; with expressions, ALL is just the default quantifier.
|
|
265
|
+
if group.args.get("all") and not groupings:
|
|
266
|
+
groupings = tuple(select.expressions[i].unalias() for i in group_all_keys(select))
|
|
267
|
+
|
|
268
|
+
output_names = {
|
|
269
|
+
projection.unalias(): projection.alias_or_name for projection in select.expressions
|
|
270
|
+
}
|
|
271
|
+
keys: dict[exp.Expr, str] = {}
|
|
272
|
+
for grouping in groupings:
|
|
273
|
+
# A GROUP BY expression is computed by every branch, so it can only read roles.
|
|
274
|
+
paths = self._paths(grouping)
|
|
275
|
+
if (
|
|
276
|
+
not paths
|
|
277
|
+
or grouping.find(exp.AggFunc, exp.Window, exp.Query)
|
|
278
|
+
or any(self._role_parts(path, ctx) is None for path in paths)
|
|
279
|
+
):
|
|
280
|
+
self._fail(ctx, "GROUP BY on a constellation must reference roles", grouping)
|
|
281
|
+
if grouping not in keys:
|
|
282
|
+
parts = self._role_parts(grouping, ctx)
|
|
283
|
+
name = (
|
|
284
|
+
"_".join(part.name for part in parts)
|
|
285
|
+
if parts
|
|
286
|
+
else output_names.get(grouping, "key")
|
|
287
|
+
)
|
|
288
|
+
keys[grouping] = find_new_name(set(keys.values()), name)
|
|
289
|
+
return keys
|
|
290
|
+
|
|
291
|
+
def _expand_expr(self, expr: exp.Expr, ctx: ConstellationContext) -> exp.Expr:
|
|
292
|
+
"""
|
|
293
|
+
Rewrites an expression over the constellation in terms of the joined columns, e.g.
|
|
294
|
+
|
|
295
|
+
item.category -> item_category
|
|
296
|
+
customer.birth_year // 10 -> decade (for `customer.birth_year // 10 AS decade`)
|
|
297
|
+
AGG(store_return_rate) -> return_amount / sales_amount
|
|
298
|
+
100 * AGG(store_return_rate) -> 100 * (return_amount / sales_amount)
|
|
299
|
+
"""
|
|
300
|
+
# Any other aggregate or window would run over the joined rows, not the datasets' rows.
|
|
301
|
+
for node in find_all_in_scope(expr, exp.AggFunc, exp.Window):
|
|
302
|
+
if not isinstance(node, exp.Agg):
|
|
303
|
+
self._fail(ctx, "Only AGG(metric) can aggregate over a constellation", node)
|
|
304
|
+
|
|
305
|
+
def rewrite(node: exp.Expr) -> exp.Expr:
|
|
306
|
+
if (key := ctx.keys.get(node)) is not None:
|
|
307
|
+
return exp.column(key, ctx.alias)
|
|
308
|
+
if self._role_parts(node, ctx) is not None:
|
|
309
|
+
self._fail(ctx, "Roles used on a constellation must appear in GROUP BY", node)
|
|
310
|
+
match node:
|
|
311
|
+
case exp.Filter(
|
|
312
|
+
this=exp.Agg(this=exp.Column() as column), expression=exp.Where() as where
|
|
313
|
+
) if isinstance(self._field(column, ctx), Metric):
|
|
314
|
+
self._check_condition(
|
|
315
|
+
where.this, "FILTER on a constellation can only use roles", ctx
|
|
316
|
+
)
|
|
317
|
+
return _replace(node, self._expand_metric(column.this, ctx, where.this))
|
|
318
|
+
case exp.Filter():
|
|
319
|
+
self._fail(ctx, "FILTER on a constellation must be on AGG(metric)", node)
|
|
320
|
+
# A FILTER's subqueries were checked with its condition above.
|
|
321
|
+
case exp.Query():
|
|
322
|
+
self._fail(ctx, "Subqueries are not supported on constellations yet", node)
|
|
323
|
+
case exp.Agg(this=exp.Column() as column) if isinstance(
|
|
324
|
+
self._field(column, ctx), Metric
|
|
325
|
+
):
|
|
326
|
+
return _replace(node, self._expand_metric(column.this, ctx))
|
|
327
|
+
case exp.Agg():
|
|
328
|
+
self._fail(ctx, "AGG on a constellation must reference a metric", node)
|
|
329
|
+
case exp.Column() if isinstance(self._field(node, ctx), Metric):
|
|
330
|
+
# A metric has no value per row, so it's NULL, which lets e.g. a Superset
|
|
331
|
+
# virtual dataset list it as a column. Its leaves still pick the datasets.
|
|
332
|
+
if ctx.stack:
|
|
333
|
+
self._expand_metric(node.this, ctx)
|
|
334
|
+
return exp.null()
|
|
335
|
+
self._fail(ctx, "Metrics must be aggregated with AGG", node)
|
|
336
|
+
case exp.Column():
|
|
337
|
+
self._fail(
|
|
338
|
+
ctx, "Constellation queries can only use roles and AGG(metric)", node
|
|
339
|
+
)
|
|
340
|
+
return node
|
|
341
|
+
|
|
342
|
+
return expr.copy().transform(rewrite, copy=False)
|
|
343
|
+
|
|
344
|
+
def _expand_metric(
|
|
345
|
+
self,
|
|
346
|
+
name: exp.Identifier,
|
|
347
|
+
ctx: ConstellationContext,
|
|
348
|
+
condition: exp.Expr | None = None,
|
|
349
|
+
source: exp.Table | None = None,
|
|
350
|
+
) -> exp.Expr:
|
|
351
|
+
"""
|
|
352
|
+
Unfolds a metric into leaves and returns its expression over them, e.g.
|
|
353
|
+
|
|
354
|
+
store_return_rate
|
|
355
|
+
-> AGG(store_return_amount) / AGG(store_sales_amount)
|
|
356
|
+
-> AGG(store_returns.return_amount) / AGG(store_sales.sales_amount)
|
|
357
|
+
-> return_amount / sales_amount
|
|
358
|
+
|
|
359
|
+
A metric built from other metrics unfolds them by calling this again. `condition` is
|
|
360
|
+
passed down to every leaf, so a filtered ratio filters both its sides.
|
|
361
|
+
|
|
362
|
+
Its parts are looked up in the queried constellation, then in `source`, the
|
|
363
|
+
constellation defining the metric that uses them.
|
|
364
|
+
"""
|
|
365
|
+
metric = resolve_metric(ctx.registry, ctx.constellation, name, source)
|
|
366
|
+
if metric is None:
|
|
367
|
+
self._fail(
|
|
368
|
+
ctx,
|
|
369
|
+
f"Unknown metric: {self._sql(ctx, ctx.constellation.name)}.{self._sql(ctx, name)}",
|
|
370
|
+
)
|
|
371
|
+
key = (metric.constellation, name)
|
|
372
|
+
if key in ctx.expanding:
|
|
373
|
+
self._fail(
|
|
374
|
+
ctx,
|
|
375
|
+
f"Metric references itself: {self._sql(ctx, metric.constellation)}.{self._sql(ctx, name)}",
|
|
376
|
+
)
|
|
377
|
+
ctx.expanding.add(key)
|
|
378
|
+
expr = metric.expr.copy()
|
|
379
|
+
# `revenue.accounts.account_count` reads `accounts.account_count` on `revenue`. As in SQL,
|
|
380
|
+
# a dataset `retail` in the metric's own namespace wins over `retail.store_sales`.
|
|
381
|
+
reads: dict[int, tuple[exp.Table, tuple[exp.Identifier, ...]]] = {}
|
|
382
|
+
for path in self._paths(expr):
|
|
383
|
+
if isinstance(path, exp.Column | exp.Dot) and len(names := path_names(path)) > 1:
|
|
384
|
+
reads[id(path)] = resolve_dataset_path(
|
|
385
|
+
names,
|
|
386
|
+
metric.namespace,
|
|
387
|
+
lambda name: ctx.registry.get_dataset(name) is not None,
|
|
388
|
+
)
|
|
389
|
+
if window := expr.find(exp.Window):
|
|
390
|
+
self._fail(ctx, "Windows in metrics are not supported yet", window)
|
|
391
|
+
|
|
392
|
+
def rewrite(node: exp.Expr) -> exp.Expr:
|
|
393
|
+
match node:
|
|
394
|
+
case exp.Filter(this=exp.Agg()):
|
|
395
|
+
self._fail(ctx, "FILTER on AGG in a metric is not supported yet", node)
|
|
396
|
+
# `AGG(dataset.calculation)` is a leaf; `AGG(metric)` composes another metric.
|
|
397
|
+
case exp.Agg(this=exp.Column() | exp.Dot() as path) if id(path) in reads:
|
|
398
|
+
table, parts = reads[id(path)]
|
|
399
|
+
agg = exp.Agg(this=_column(*parts))
|
|
400
|
+
return self._leaf(table, agg, parts[-1].name, condition, ctx)
|
|
401
|
+
case exp.Agg(this=exp.Column() as column):
|
|
402
|
+
part = self._expand_metric(column.this, ctx, condition, metric.constellation)
|
|
403
|
+
return _replace(node, part)
|
|
404
|
+
case exp.Agg():
|
|
405
|
+
self._fail(
|
|
406
|
+
ctx, "AGG in a metric must reference a metric or a dataset field", node
|
|
407
|
+
)
|
|
408
|
+
# A FILTER belongs to its aggregate, so the branch computes both.
|
|
409
|
+
case exp.AggFunc() | exp.Filter():
|
|
410
|
+
read = {
|
|
411
|
+
reads[id(path)][0] if id(path) in reads else None
|
|
412
|
+
for path in self._paths(node)
|
|
413
|
+
}
|
|
414
|
+
dataset = read.pop() if len(read) == 1 else None
|
|
415
|
+
if dataset is None:
|
|
416
|
+
self._fail(
|
|
417
|
+
ctx, "An aggregate in a metric must read from exactly one dataset", node
|
|
418
|
+
)
|
|
419
|
+
# Inside the copied metric, so its paths are reused.
|
|
420
|
+
local = node.transform(
|
|
421
|
+
lambda n: _column(*reads[id(n)][1]) if id(n) in reads else n,
|
|
422
|
+
copy=False,
|
|
423
|
+
)
|
|
424
|
+
return self._leaf(dataset, local, name.name, condition, ctx)
|
|
425
|
+
# Columns inside aggregates were replaced above, so this one is outside them, e.g.
|
|
426
|
+
# a role in `AGG(m) * item.category` or a metric missing its AGG.
|
|
427
|
+
case exp.Column():
|
|
428
|
+
self._fail(
|
|
429
|
+
ctx, "Columns outside aggregates in metrics are not supported yet", node
|
|
430
|
+
)
|
|
431
|
+
# A leaf is NULL for keys its dataset has no rows for, so like dbt's `safe_add`,
|
|
432
|
+
# `a + b` becomes `COALESCE(a, 0) + COALESCE(b, 0)`. Nested sums aren't wrapped.
|
|
433
|
+
case exp.Add() | exp.Sub():
|
|
434
|
+
for side in ("this", "expression"):
|
|
435
|
+
operand = node.args[side]
|
|
436
|
+
if not isinstance(operand.unnest(), exp.Add | exp.Sub):
|
|
437
|
+
node.set(
|
|
438
|
+
side,
|
|
439
|
+
exp.Coalesce(this=operand, expressions=[exp.Literal.number(0)]),
|
|
440
|
+
)
|
|
441
|
+
# Divisions use `a / NULLIF(b, 0)`, so a denominator of 0 gives NULL instead of an
|
|
442
|
+
# error or infinity, e.g. `store_return_rate` for an item class with store sales of 0.
|
|
443
|
+
case exp.Div():
|
|
444
|
+
node.set("safe", True)
|
|
445
|
+
return node
|
|
446
|
+
|
|
447
|
+
expanded = expr.transform(rewrite, copy=False)
|
|
448
|
+
ctx.expanding.discard(key)
|
|
449
|
+
return expanded
|
|
450
|
+
|
|
451
|
+
def _leaf(
|
|
452
|
+
self,
|
|
453
|
+
dataset: exp.Table,
|
|
454
|
+
expr: exp.Expr,
|
|
455
|
+
name: str,
|
|
456
|
+
condition: exp.Expr | None,
|
|
457
|
+
ctx: ConstellationContext,
|
|
458
|
+
) -> exp.Expr:
|
|
459
|
+
"""
|
|
460
|
+
Registers an aggregate over `dataset` as a leaf and returns its joined column.
|
|
461
|
+
|
|
462
|
+
The same aggregate always gets the same leaf, so `AGG(sales_amount)` on `store_sales` is
|
|
463
|
+
computed once when both `store_sales_amount` and `store_return_rate` are selected. The
|
|
464
|
+
leaf is called `name` when that's free, e.g. `sales_amount`, and gets a numbered suffix
|
|
465
|
+
otherwise, e.g. `sales_amount_2` for a second dataset with a `sales_amount`.
|
|
466
|
+
"""
|
|
467
|
+
if ctx.registry.get_dataset(dataset) is None:
|
|
468
|
+
self._fail(ctx, f"Unknown dataset in metric: {self._sql(ctx, dataset)}")
|
|
469
|
+
leaf_key = (dataset, expr, condition)
|
|
470
|
+
if (leaf := ctx.leaves.get(leaf_key)) is None:
|
|
471
|
+
taken = {*ctx.keys.values(), *(other.name for other in ctx.leaves.values())}
|
|
472
|
+
leaf = Leaf(
|
|
473
|
+
dataset=dataset, expr=expr, name=find_new_name(taken, name), condition=condition
|
|
474
|
+
)
|
|
475
|
+
ctx.leaves[leaf_key] = leaf
|
|
476
|
+
return exp.column(leaf.name, ctx.alias)
|
|
477
|
+
|
|
478
|
+
def _join_branches(
|
|
479
|
+
self, datasets: list[exp.Table], condition: exp.Expr | None, ctx: ConstellationContext
|
|
480
|
+
) -> exp.Select:
|
|
481
|
+
"""
|
|
482
|
+
Builds one branch per dataset and joins them on the keys (step 2 in the class docstring).
|
|
483
|
+
|
|
484
|
+
The result outputs each key once, taken from whichever branch has it, and each leaf from
|
|
485
|
+
the branch that computes it. Branches are aliased by their dataset's name without its
|
|
486
|
+
namespace, numbered when datasets of two namespaces share it, e.g. `sales` and `sales_2`.
|
|
487
|
+
"""
|
|
488
|
+
aliases: dict[exp.Table, str] = {}
|
|
489
|
+
for dataset in datasets:
|
|
490
|
+
aliases[dataset] = find_new_name(set(aliases.values()), dataset.name)
|
|
491
|
+
branches = [
|
|
492
|
+
self._branch(dataset, condition, ctx).subquery(aliases[dataset], copy=False)
|
|
493
|
+
for dataset in datasets
|
|
494
|
+
]
|
|
495
|
+
names = list(aliases.values())
|
|
496
|
+
keys = {name: self._coalesce(name, names) for name in ctx.keys.values()}
|
|
497
|
+
# A NULL key is a proper group, e.g. sales with no customer, but `=` never matches NULL,
|
|
498
|
+
# so with several branches it comes out as one row per branch. Grouping by the keys merges
|
|
499
|
+
# them into one, like GROUP BY does, and MAX picks each column's only non-NULL value.
|
|
500
|
+
# This assumes leaves are values MAX accepts: Postgres, for one, has no MAX(boolean), so
|
|
501
|
+
# a metric like BOOL_OR(...) only merges on engines that do.
|
|
502
|
+
merge = bool(keys) and len(branches) > 1
|
|
503
|
+
outputs = [
|
|
504
|
+
exp.alias_(key, name) if len(datasets) > 1 else key for name, key in keys.items()
|
|
505
|
+
]
|
|
506
|
+
for leaf in ctx.leaves.values():
|
|
507
|
+
column = exp.column(leaf.name, aliases[leaf.dataset])
|
|
508
|
+
outputs.append(exp.alias_(exp.Max(this=column), leaf.name) if merge else column)
|
|
509
|
+
joined = exp.select(*outputs, copy=False).from_(branches[0], copy=False)
|
|
510
|
+
|
|
511
|
+
for n, branch in enumerate(branches[1:], start=1):
|
|
512
|
+
# A key can be missing from any earlier branch, so compare with all of them. Without
|
|
513
|
+
# keys, every branch is a single row, so they all match.
|
|
514
|
+
conditions = [
|
|
515
|
+
exp.column(name, names[n]).eq(self._coalesce(name, names[:n])) for name in keys
|
|
516
|
+
]
|
|
517
|
+
on = exp.and_(*conditions) if conditions else exp.true()
|
|
518
|
+
joined = joined.join(branch, join_type="FULL OUTER", on=on, copy=False)
|
|
519
|
+
|
|
520
|
+
if merge:
|
|
521
|
+
joined = joined.group_by(*keys.values(), copy=False)
|
|
522
|
+
return joined
|
|
523
|
+
|
|
524
|
+
def _key_datasets(
|
|
525
|
+
self, condition: exp.Expr | None, ctx: ConstellationContext
|
|
526
|
+
) -> list[exp.Table]:
|
|
527
|
+
"""
|
|
528
|
+
Returns the datasets a query without metrics reads its keys from: every dataset that
|
|
529
|
+
provides all the roles the query uses.
|
|
530
|
+
|
|
531
|
+
The constellation stands for all its datasets stacked, so e.g.
|
|
532
|
+
`SELECT customer.birth_year FROM universe GROUP BY 1` gives the birth years of the
|
|
533
|
+
customers in `store_sales`, `catalog_sales` and the other datasets with a customer, and
|
|
534
|
+
`ship_date.year` skips `store_sales`, which has no ship date.
|
|
535
|
+
"""
|
|
536
|
+
roles = {
|
|
537
|
+
parts[0]
|
|
538
|
+
for expr in (*ctx.keys, condition)
|
|
539
|
+
if expr is not None
|
|
540
|
+
for path in self._paths(expr)
|
|
541
|
+
if (parts := self._role_parts(path, ctx)) is not None
|
|
542
|
+
}
|
|
543
|
+
datasets = [
|
|
544
|
+
dataset
|
|
545
|
+
for dataset in sorted(
|
|
546
|
+
ctx.constellation.assigned_datasets(), key=lambda name: (name.db, name.name)
|
|
547
|
+
)
|
|
548
|
+
if all(ctx.constellation.get_assignment(dataset, role) is not None for role in roles)
|
|
549
|
+
]
|
|
550
|
+
if not datasets:
|
|
551
|
+
names = ", ".join(sorted(self._sql(ctx, role) for role in roles))
|
|
552
|
+
self._fail(ctx, f"No dataset provides every role the query uses: {names}")
|
|
553
|
+
return datasets
|
|
554
|
+
|
|
555
|
+
def _branch(
|
|
556
|
+
self, dataset: exp.Table, condition: exp.Expr | None, ctx: ConstellationContext
|
|
557
|
+
) -> exp.Select:
|
|
558
|
+
"""
|
|
559
|
+
Builds the query for one dataset: its keys and leaves over the rows that pass `condition`,
|
|
560
|
+
with one row per key, or per row when stacking.
|
|
561
|
+
|
|
562
|
+
For example, `store_sales` by `item.category` gives the sales amount per item category,
|
|
563
|
+
with the category read through `store_sales`' own assignment for the `item` role.
|
|
564
|
+
"""
|
|
565
|
+
translated = {name: self._translate(expr, dataset, ctx) for expr, name in ctx.keys.items()}
|
|
566
|
+
projections = [exp.alias_(expr, name) for name, expr in translated.items()]
|
|
567
|
+
projections += [
|
|
568
|
+
exp.alias_(self._filtered(leaf, ctx), leaf.name)
|
|
569
|
+
for leaf in ctx.leaves.values()
|
|
570
|
+
if leaf.dataset == dataset and not ctx.stack
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
table = exp.table_(dataset.this, db=dataset.args["db"], catalog=ctx.catalog)
|
|
574
|
+
table.meta[TABLE_ARGUMENTS] = ctx.arguments
|
|
575
|
+
branch = exp.select(*projections, copy=False).from_(table, copy=False)
|
|
576
|
+
if condition is not None:
|
|
577
|
+
branch.where(self._translate(condition, dataset, ctx), copy=False)
|
|
578
|
+
# Constant keys like `'store'` don't need grouping, and some engines reject or
|
|
579
|
+
# misread `GROUP BY <literal>`.
|
|
580
|
+
groupings = [expr for expr in translated.values() if expr.find(exp.Column)]
|
|
581
|
+
if groupings and not ctx.stack:
|
|
582
|
+
branch.set("group", exp.Group(expressions=groupings))
|
|
583
|
+
# ExpandDatasetsRule expects qualified SQL. Keys are named after the query's output names,
|
|
584
|
+
# which can match a column of the dataset, so they mustn't be read as alias references.
|
|
585
|
+
# Its aliases are normalized names, which normalizing again could change, so they're quoted.
|
|
586
|
+
qualified = qualify(
|
|
587
|
+
quote_identifiers(branch, dialect=ctx.dialect),
|
|
588
|
+
schema=ctx.cluster,
|
|
589
|
+
dialect=ctx.dialect,
|
|
590
|
+
quote_identifiers=False,
|
|
591
|
+
expand_alias_refs=False,
|
|
592
|
+
)
|
|
593
|
+
# Without leaves or a GROUP BY, e.g. just `'store' AS sales_channel`, the branch has a row
|
|
594
|
+
# per row of the dataset, which the join would multiply by every other branch's rows. The
|
|
595
|
+
# DISTINCT goes outside, since ExpandDatasetsRule rebuilds the dataset's select without it.
|
|
596
|
+
if not ctx.leaves and not groupings and not ctx.stack:
|
|
597
|
+
return (
|
|
598
|
+
exp.select("*")
|
|
599
|
+
.from_(qualified.subquery(dataset.name, copy=False), copy=False)
|
|
600
|
+
.distinct()
|
|
601
|
+
)
|
|
602
|
+
return qualified
|
|
603
|
+
|
|
604
|
+
def _translate(self, expr: exp.Expr, dataset: exp.Table, ctx: ConstellationContext) -> exp.Expr:
|
|
605
|
+
"""
|
|
606
|
+
Rewrites an expression over roles, i.e. a key, the WHERE or a FILTER condition, for
|
|
607
|
+
`dataset`, replacing each role with the dataset's assignment for it, e.g.
|
|
608
|
+
|
|
609
|
+
customer.birth_year on store_sales -> customer.birth_year
|
|
610
|
+
customer.birth_year on catalog_sales -> bill_customer.birth_year
|
|
611
|
+
customer.birth_year // 10 on catalog_sales -> bill_customer.birth_year // 10
|
|
612
|
+
sales_channel on store_sales -> 'store'
|
|
613
|
+
|
|
614
|
+
since `catalog_sales` assigns the role `customer` as its `bill_customer` relationship,
|
|
615
|
+
and `store_sales` assigns `sales_channel` as the constant `'store'`.
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
def rewrite(node: exp.Expr) -> exp.Expr:
|
|
619
|
+
# A subquery's columns are its own tables', e.g. `c.customer`, but not
|
|
620
|
+
# `universe.customer.birth_year`.
|
|
621
|
+
correlated = node.find_ancestor(exp.Query) is not None
|
|
622
|
+
if (correlated and not self._is_outer(node, ctx)) or (
|
|
623
|
+
parts := self._role_parts(node, ctx)
|
|
624
|
+
) is None:
|
|
625
|
+
return node
|
|
626
|
+
role, *path = parts
|
|
627
|
+
if (assignment := ctx.constellation.get_assignment(dataset, role)) is None:
|
|
628
|
+
name = f"{self._sql(ctx, ctx.constellation.name)}.{self._sql(ctx, role)}"
|
|
629
|
+
self._fail(ctx, f"Dataset {self._sql(ctx, dataset)} does not provide role {name}")
|
|
630
|
+
assigned = (
|
|
631
|
+
# The branch reads the dataset by its name.
|
|
632
|
+
assignment.expr.transform(
|
|
633
|
+
lambda n: _prefixed(n, dataset.this) if isinstance(n, exp.Column) else n
|
|
634
|
+
)
|
|
635
|
+
if correlated
|
|
636
|
+
else assignment.expr.copy()
|
|
637
|
+
)
|
|
638
|
+
if not path:
|
|
639
|
+
# A reference role is a key to follow, not a value to group or filter by.
|
|
640
|
+
# TODO: let a bare reference role stand for its key, e.g. `customer IN (SELECT ...)`
|
|
641
|
+
# filtering on `customer.c_customer_sk`, or on `ss_customer_sk` without a join.
|
|
642
|
+
field = ctx.constellation.get_column(role)
|
|
643
|
+
if field is not None and field.datatype and field.datatype.is_type(exp.DType.REF):
|
|
644
|
+
name = self._sql(ctx, role)
|
|
645
|
+
self._fail(
|
|
646
|
+
ctx, f"Use an attribute of the entity role {name}, e.g. {name}.<attribute>"
|
|
647
|
+
)
|
|
648
|
+
return _replace(node, assigned)
|
|
649
|
+
if not isinstance(assigned, exp.Column | exp.Dot):
|
|
650
|
+
self._fail(
|
|
651
|
+
ctx,
|
|
652
|
+
f"Role {self._sql(ctx, role)} is not a relationship on dataset "
|
|
653
|
+
f"{self._sql(ctx, dataset)}",
|
|
654
|
+
assigned,
|
|
655
|
+
)
|
|
656
|
+
return exp.Dot.build([assigned, *path])
|
|
657
|
+
|
|
658
|
+
return expr.transform(rewrite)
|
|
659
|
+
|
|
660
|
+
def _role_parts(
|
|
661
|
+
self, expr: exp.Expr, ctx: ConstellationContext
|
|
662
|
+
) -> tuple[exp.Identifier, ...] | None:
|
|
663
|
+
"""
|
|
664
|
+
Returns the name path of a role reference, or None if `expr` isn't one.
|
|
665
|
+
|
|
666
|
+
For example, `customer.birth_year` gives `("customer", "birth_year")`, and a metric such
|
|
667
|
+
as `store_sales_amount` gives None.
|
|
668
|
+
"""
|
|
669
|
+
if not isinstance(expr, exp.Column | exp.Dot):
|
|
670
|
+
return None
|
|
671
|
+
# qualify writes role references as `universe.item.category`, so drop the alias.
|
|
672
|
+
parts = list(path_names(expr))
|
|
673
|
+
if parts and parts[0].name == ctx.alias:
|
|
674
|
+
parts = parts[1:]
|
|
675
|
+
if not parts or not isinstance(ctx.constellation.get_column(parts[0]), Role):
|
|
676
|
+
return None
|
|
677
|
+
return tuple(parts)
|
|
678
|
+
|
|
679
|
+
def _filtered(self, leaf: Leaf, ctx: ConstellationContext) -> exp.Expr:
|
|
680
|
+
"""
|
|
681
|
+
Returns a leaf's aggregate with its condition translated for its dataset, e.g.
|
|
682
|
+
`customer.birth_year > 1960` on `catalog_sales` gives
|
|
683
|
+
`AGG(sales_amount) FILTER (WHERE bill_customer.birth_year > 1960)`. A filter the metric's
|
|
684
|
+
own definition has keeps applying, combined with `AND`.
|
|
685
|
+
"""
|
|
686
|
+
if leaf.condition is None:
|
|
687
|
+
return leaf.expr
|
|
688
|
+
condition = self._translate(leaf.condition, leaf.dataset, ctx)
|
|
689
|
+
if isinstance(leaf.expr, exp.Filter):
|
|
690
|
+
own = leaf.expr.expression.this
|
|
691
|
+
return exp.Filter(
|
|
692
|
+
this=leaf.expr.this.copy(), expression=exp.Where(this=exp.and_(own, condition))
|
|
693
|
+
)
|
|
694
|
+
return exp.Filter(this=leaf.expr.copy(), expression=exp.Where(this=condition))
|
|
695
|
+
|
|
696
|
+
def _check_condition(
|
|
697
|
+
self, condition: exp.Expr, message: str, ctx: ConstellationContext
|
|
698
|
+
) -> None:
|
|
699
|
+
"""
|
|
700
|
+
Fails unless `condition` only reads roles, since it's translated into each branch. A
|
|
701
|
+
metric only has a value per key, after aggregating, and any other column, e.g. one of an
|
|
702
|
+
outer query, would be read from each branch's own dataset instead.
|
|
703
|
+
"""
|
|
704
|
+
# A subquery that doesn't read the constellation's rows is pushed down as is, e.g.
|
|
705
|
+
# `item.brand IN (SELECT brand FROM ...)`, and each branch runs its own copy.
|
|
706
|
+
for subquery in find_all_in_scope(condition, exp.Query):
|
|
707
|
+
scopes = traverse_scope(subquery)
|
|
708
|
+
own = {name for scope in scopes for name in scope.sources}
|
|
709
|
+
for column in (column for scope in scopes for column in scope.columns):
|
|
710
|
+
path: exp.Column | exp.Dot = column
|
|
711
|
+
while isinstance(path.parent, exp.Dot):
|
|
712
|
+
path = path.parent
|
|
713
|
+
# qualify can read an outer role as a field of the subquery's own table, e.g.
|
|
714
|
+
# `universe.customer.birth_year` as `c.universe.customer.birth_year`.
|
|
715
|
+
# TODO: an unqualified role, e.g. `item.category` read as `r.item.category`,
|
|
716
|
+
# can't be told apart from the subquery's own relationship.
|
|
717
|
+
if ctx.alias in (part.name for part in path.parts[1:-1]):
|
|
718
|
+
self._fail(
|
|
719
|
+
ctx, "Correlated subqueries are not supported on constellations yet", path
|
|
720
|
+
)
|
|
721
|
+
if column.table not in own and (
|
|
722
|
+
column.table != ctx.alias or self._role_parts(path, ctx) is None
|
|
723
|
+
):
|
|
724
|
+
self._fail(
|
|
725
|
+
ctx,
|
|
726
|
+
"A correlated subquery on a constellation can only read its roles",
|
|
727
|
+
path,
|
|
728
|
+
)
|
|
729
|
+
invalid = find_in_scope(condition, exp.AggFunc, exp.Window) or next(
|
|
730
|
+
(path for path in self._paths(condition) if self._role_parts(path, ctx) is None), None
|
|
731
|
+
)
|
|
732
|
+
if invalid:
|
|
733
|
+
self._fail(ctx, message, invalid)
|
|
734
|
+
|
|
735
|
+
def _is_outer(self, path: exp.Expr, ctx: ConstellationContext) -> bool:
|
|
736
|
+
"""
|
|
737
|
+
Whether a path in a subquery reads the constellation, e.g. in
|
|
738
|
+
|
|
739
|
+
WHERE EXISTS (SELECT 1 FROM c WHERE c.year = universe.customer.birth_year)
|
|
740
|
+
|
|
741
|
+
but not in `... FROM (SELECT ...) AS universe WHERE universe.customer = 1`.
|
|
742
|
+
"""
|
|
743
|
+
if not isinstance(path, exp.Column | exp.Dot):
|
|
744
|
+
return False
|
|
745
|
+
names = path_names(path)
|
|
746
|
+
if not names or names[0].name != ctx.alias:
|
|
747
|
+
return False
|
|
748
|
+
select = path.find_ancestor(exp.Select)
|
|
749
|
+
while select is not None:
|
|
750
|
+
sources = [select.args.get("from_"), *select.args.get("joins", [])]
|
|
751
|
+
if any(source and source.this.alias_or_name == ctx.alias for source in sources):
|
|
752
|
+
return False
|
|
753
|
+
select = select.find_ancestor(exp.Select)
|
|
754
|
+
return True
|
|
755
|
+
|
|
756
|
+
def _paths(self, expr: exp.Expr) -> list[exp.Expr]:
|
|
757
|
+
"""
|
|
758
|
+
Returns the column paths in `expr`, each whole: sqlglot parses `customer.birth_year` as
|
|
759
|
+
dots over the `customer` column, so that column gives the full `customer.birth_year`.
|
|
760
|
+
Columns of a subquery belong to its own tables, so they're skipped.
|
|
761
|
+
"""
|
|
762
|
+
paths = []
|
|
763
|
+
for column in find_all_in_scope(expr, exp.Column):
|
|
764
|
+
path: exp.Expr = column
|
|
765
|
+
while isinstance(path.parent, exp.Dot):
|
|
766
|
+
path = path.parent
|
|
767
|
+
paths.append(path)
|
|
768
|
+
return paths
|
|
769
|
+
|
|
770
|
+
def _field(self, column: exp.Column, ctx: ConstellationContext) -> Metric | Role | None:
|
|
771
|
+
"""
|
|
772
|
+
Returns the metric or role a column of the constellation names, e.g. the `Metric` for
|
|
773
|
+
`store_sales_amount`. A column of any other table gives None.
|
|
774
|
+
"""
|
|
775
|
+
if column.table and column.table != ctx.alias:
|
|
776
|
+
return None
|
|
777
|
+
return ctx.constellation.get_column(column.this)
|
|
778
|
+
|
|
779
|
+
def _sql(self, ctx: ConstellationContext, name: exp.Expr) -> str:
|
|
780
|
+
return name.sql(ctx.registry.dialect)
|
|
781
|
+
|
|
782
|
+
def _coalesce(self, name: str, branches: list[str]) -> exp.Expr:
|
|
783
|
+
"""
|
|
784
|
+
Reads a key from the first of the `branches`, by alias, that has it, since the key may be
|
|
785
|
+
missing from any of them. A single branch's column is returned as is.
|
|
786
|
+
"""
|
|
787
|
+
columns = [exp.column(name, branch) for branch in branches]
|
|
788
|
+
if len(columns) == 1:
|
|
789
|
+
return columns[0]
|
|
790
|
+
return exp.Coalesce(this=columns[0], expressions=columns[1:])
|