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,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:])