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,1208 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections import deque
5
+ from dataclasses import dataclass, field, replace
6
+ from itertools import count
7
+ from typing import NoReturn
8
+
9
+ from sqlglot import Expr, exp
10
+ from sqlglot.errors import SchemaError
11
+ from sqlglot.helper import find_new_name
12
+ from sqlglot.optimizer.helpers import projection_has_aggregate
13
+ from sqlglot.optimizer.optimize_joins import other_table_names
14
+ from sqlglot.optimizer.simplify import simplify, simplify_parens
15
+
16
+ from metricflow_sql.compiler.catalog.cluster import Cluster
17
+ from metricflow_sql.compiler.table_arguments import TABLE_ARGUMENTS
18
+ from metricflow_sql.objects.base import Id
19
+ from metricflow_sql.objects.dataset import (
20
+ Calculation,
21
+ Cardinality,
22
+ Column,
23
+ Relationship,
24
+ argument,
25
+ parameter_value,
26
+ )
27
+ from metricflow_sql.objects.entity import Attribute, Source
28
+ from metricflow_sql.registry.reference_resolution import (
29
+ resolve_attribute_reference,
30
+ resolve_attribute_references,
31
+ )
32
+ from metricflow_sql.registry.registry import (
33
+ DatasetProxy,
34
+ DatasetTarget,
35
+ EntityProxy,
36
+ EntityTarget,
37
+ Registry,
38
+ RelationshipTarget,
39
+ relationship_cardinality,
40
+ relationship_target,
41
+ )
42
+ from metricflow_sql.utils.normalize import resolve_name
43
+ from metricflow_sql.utils.sql import (
44
+ FINAL,
45
+ group_all_keys,
46
+ is_aggregate_expression,
47
+ is_shadowed,
48
+ normalize_group,
49
+ path_names,
50
+ table_scopes,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ @dataclass
57
+ class ExpandContext:
58
+ registry: Registry
59
+ dataset: DatasetProxy
60
+ alias: str
61
+ root_alias: str
62
+ datasets_by_alias: dict[str, tuple[Registry, DatasetProxy]] = field(default_factory=dict)
63
+ # Each source's own table function arguments, e.g. a user join's `customer(x => 1)`.
64
+ arguments_by_alias: dict[str, dict[str, exp.Expr]] = field(default_factory=dict)
65
+ used_aliases: set[str] = field(default_factory=set)
66
+ # Join aliases by (parent alias, relationship name, dataset the join reads).
67
+ join_aliases: dict[tuple[str, exp.Identifier, exp.Table], str] = field(default_factory=dict)
68
+ joins: dict[str, exp.Join] = field(default_factory=dict)
69
+ join_parents: dict[str, str] = field(default_factory=dict)
70
+ # The dataset each generated join reads, keyed by join alias.
71
+ join_datasets: dict[str, DatasetProxy] = field(default_factory=dict)
72
+ # The entity source identifying the rows of a generated join, for a dataset without a key.
73
+ grain_sources: dict[str, Source] = field(default_factory=dict)
74
+ join_depths: dict[str, int] = field(default_factory=dict)
75
+ fanout_grain_by_alias: dict[str, str] = field(default_factory=dict)
76
+ # One-to-many joins, keyed by alias, mapped to the alias of the join they fan out from.
77
+ fanout_parents: dict[str, str] = field(default_factory=dict)
78
+ aggregate_grains: dict[int, str] = field(default_factory=dict)
79
+ dialect: str | None = None
80
+ # The root table's table function arguments, passed to every dataset its relationships reach.
81
+ arguments: dict[str, exp.Expr] = field(default_factory=dict)
82
+
83
+
84
+ class ExpandDatasetsRule:
85
+ """
86
+ Sqlglot optimizer rule that replaces calculations, relationships, etc. with
87
+ their physical representations.
88
+ """
89
+
90
+ def __init__(self, strict: bool = False):
91
+ self.strict = strict
92
+
93
+ def _fail(self, ctx: ExpandContext, message: str, expr: exp.Expr | None = None) -> NoReturn:
94
+ if expr is not None:
95
+ message = f"{message}: {expr.sql(dialect=ctx.dialect)}"
96
+ raise ValueError(message)
97
+
98
+ def __call__(self, expression: Expr, cluster: Cluster, dialect: str | None = None) -> Expr:
99
+ for scope, select, table in table_scopes(expression):
100
+ registry_and_dataset = cluster.get_dataset(table)
101
+ if not registry_and_dataset:
102
+ continue
103
+ registry, dataset = registry_and_dataset
104
+
105
+ ctx = ExpandContext(
106
+ registry=registry,
107
+ dataset=dataset,
108
+ alias=table.alias_or_name,
109
+ root_alias=table.alias_or_name,
110
+ dialect=dialect,
111
+ arguments=table.meta.get(TABLE_ARGUMENTS, {}),
112
+ )
113
+ for alias, (_, source) in scope.selected_sources.items():
114
+ ctx.used_aliases.add(alias)
115
+ if isinstance(source, exp.Table) and not source.meta.get(FINAL):
116
+ joined = cluster.get_dataset(source)
117
+ if joined:
118
+ ctx.datasets_by_alias[alias] = joined
119
+ ctx.arguments_by_alias[alias] = source.meta.get(TABLE_ARGUMENTS, {})
120
+
121
+ query = self._expand_scope(select, ctx)
122
+
123
+ scope.expression.replace(query)
124
+
125
+ if not scope.parent:
126
+ return query
127
+
128
+ return expression
129
+
130
+ def _expand_scope(self, select: exp.Select, ctx: ExpandContext) -> exp.Query:
131
+ projections = [self._expand_expr(projection, ctx) for projection in select.expressions]
132
+ user_joins: list[exp.Join] = select.args.get("joins") or []
133
+ prior = {ctx.root_alias: (ctx.registry, ctx.dataset)}
134
+ for join in user_joins:
135
+ on = join.args.get("on")
136
+ if on is not None:
137
+ join.set("on", self._expand_expr(on, ctx))
138
+
139
+ table = join.this
140
+ if isinstance(table, exp.Table):
141
+ joined = ctx.datasets_by_alias.get(table.alias_or_name)
142
+ if joined:
143
+ self._warn_unverified_join(ctx, join, prior, table.alias_or_name, joined)
144
+ join.set(
145
+ "this",
146
+ exp.alias_(
147
+ joined[1].from_(ctx.arguments_by_alias[table.alias_or_name]),
148
+ table.alias_or_name,
149
+ copy=False,
150
+ ),
151
+ )
152
+ prior[table.alias_or_name] = joined
153
+
154
+ where = select.args.get("where")
155
+ if where is not None:
156
+ where = self._expand_expr(where, ctx)
157
+
158
+ group = select.args.get("group")
159
+ requested_groups: list[tuple[exp.Expr, ...]] = [()]
160
+ if isinstance(group, exp.Group):
161
+ requested_groups = [
162
+ tuple(self._expand_expr(grouping, ctx) for grouping in grouping_set)
163
+ for grouping_set in normalize_group(group)
164
+ ]
165
+ if group.args.get("all") and requested_groups == [()]:
166
+ expanded = exp.select(*(projection.copy() for projection in projections))
167
+ requested_groups = [
168
+ tuple(projections[i].unalias().copy() for i in group_all_keys(expanded))
169
+ ]
170
+
171
+ self._resolve_partition_by_excluding(projections, requested_groups, ctx)
172
+
173
+ # An aggregate defined on a joined dataset counts that dataset's rows, so the root's
174
+ # rows can't be grouped directly, e.g. AGG(store.total_employees) FROM store_sales.
175
+ # Without aggregates, a one-to-many join's rows are the query's, fanned out.
176
+ aggregates = isinstance(group, exp.Group) or bool(ctx.aggregate_grains)
177
+ if aggregates and (
178
+ ctx.fanout_parents or any(alias in ctx.joins for alias in ctx.aggregate_grains.values())
179
+ ):
180
+ return self._expand_fanout_grouping(
181
+ projections, requested_groups, where, user_joins, ctx
182
+ )
183
+
184
+ if isinstance(group, exp.Group):
185
+ if len(requested_groups) == 1:
186
+ group = (
187
+ exp.Group(expressions=list(requested_groups[0]))
188
+ if requested_groups[0]
189
+ else None
190
+ )
191
+ else:
192
+ group = exp.Group(
193
+ expressions=[
194
+ exp.GroupingSets(
195
+ expressions=[
196
+ exp.tuple_(*grouping_set, copy=False)
197
+ for grouping_set in requested_groups
198
+ ]
199
+ )
200
+ ]
201
+ )
202
+
203
+ new_select = exp.select(*projections, copy=False).from_(
204
+ exp.alias_(ctx.dataset.from_(ctx.arguments), ctx.alias, copy=False),
205
+ copy=False,
206
+ )
207
+ new_select.set("joins", self._ordered_joins(user_joins, ctx))
208
+ if where is not None:
209
+ new_select.set("where", where)
210
+ if group:
211
+ new_select.set("group", group)
212
+ return new_select
213
+
214
+ def _resolve_partition_by_excluding(
215
+ self,
216
+ projections: list[exp.Expr],
217
+ requested_groups: list[tuple[exp.Expr, ...]],
218
+ ctx: ExpandContext,
219
+ ) -> None:
220
+ exclusions = [
221
+ exclusion
222
+ for projection in projections
223
+ for exclusion in projection.find_all(exp.PartitionByExcluding)
224
+ ]
225
+ if not exclusions:
226
+ return
227
+ if len(requested_groups) != 1:
228
+ self._fail(
229
+ ctx,
230
+ "Unsupported query - window calculations containing PARTITION BY EXCLUDING cannot be grouped by GROUPING SETS",
231
+ exclusions[0].find_ancestor(exp.Window),
232
+ )
233
+
234
+ group = requested_groups[0]
235
+ grouped_expressions = frozenset(group)
236
+ for exclusion in exclusions:
237
+ for expr in exclusion.expressions:
238
+ if expr not in grouped_expressions:
239
+ self._fail(
240
+ ctx,
241
+ "Unsupported query - GROUP BY must include all dimensions in PARTITION BY EXCLUDING clauses",
242
+ expr.find_ancestor(exp.Window),
243
+ )
244
+ excluded = frozenset(exclusion.expressions)
245
+ exclusion.replace(
246
+ [dimension.copy() for dimension in group if dimension not in excluded]
247
+ )
248
+
249
+ def _ordered_joins(self, user_joins: list[exp.Join], ctx: ExpandContext) -> list[exp.Join]:
250
+ ordered: list[exp.Join] = []
251
+ available = {ctx.root_alias}
252
+
253
+ def add_generated(alias: str) -> None:
254
+ pending: list[str] = []
255
+ while alias in ctx.joins and alias not in available:
256
+ pending.append(alias)
257
+ alias = ctx.join_parents[alias]
258
+ if alias not in available:
259
+ self._fail(ctx, f"JOIN ON needs {pending[0]} before {alias} is joined")
260
+ for needed in reversed(pending):
261
+ # A key can read other joins, e.g. `_item.i_item_id` in `_item_as_sold`'s.
262
+ for dependency in sorted(other_table_names(ctx.joins[needed]) & ctx.joins.keys()):
263
+ add_generated(dependency)
264
+ ordered.append(ctx.joins[needed])
265
+ available.add(needed)
266
+
267
+ for join in user_joins:
268
+ for alias in sorted(other_table_names(join) & ctx.joins.keys()):
269
+ add_generated(alias)
270
+ ordered.append(join)
271
+ available.add(join.alias_or_name)
272
+
273
+ for alias in ctx.joins:
274
+ add_generated(alias)
275
+ return ordered
276
+
277
+ def _join_terms(self, expression: exp.Expr, dialect: str | None) -> set[exp.Expr]:
278
+ normalized = simplify(expression, dialect=dialect).unnest()
279
+ if isinstance(normalized, exp.And):
280
+ return set(normalized.flatten())
281
+ return {normalized}
282
+
283
+ def _warn_unverified_join(
284
+ self,
285
+ ctx: ExpandContext,
286
+ join: exp.Join,
287
+ prior: dict[str, tuple[Registry, DatasetProxy]],
288
+ alias: str,
289
+ joined: tuple[Registry, DatasetProxy],
290
+ ) -> None:
291
+ joined_registry, joined_dataset = joined
292
+ on = join.args.get("on")
293
+ actual_terms = self._join_terms(on.copy(), ctx.dialect) if on is not None else set()
294
+ dialect = joined_registry.dialect
295
+ names: set[str] = set()
296
+ sources: set[str] = set()
297
+ for previous_alias, (registry, previous_dataset) in prior.items():
298
+ if registry is not joined_registry:
299
+ continue
300
+ for parent_alias, parent_dataset, source_alias, source_dataset in (
301
+ (previous_alias, previous_dataset, alias, joined_dataset),
302
+ (alias, joined_dataset, previous_alias, previous_dataset),
303
+ ):
304
+ for name in parent_dataset.column_names():
305
+ relationship = parent_dataset.get_column(name)
306
+ if not isinstance(relationship, Relationship):
307
+ continue
308
+ try:
309
+ target = relationship_target(registry, relationship)
310
+ except SchemaError:
311
+ continue
312
+ values = {
313
+ name: self._qualify_key(expr, parent_alias)
314
+ for name, expr in relationship.key.items()
315
+ }
316
+ match target:
317
+ case EntityTarget(entity=entity):
318
+ source = entity.get_source(source_dataset.name)
319
+ if source is None:
320
+ continue
321
+ condition = self._bind_join_keys(ctx, source.on, values, source_alias)
322
+ case DatasetTarget(dataset=target_dataset):
323
+ if target_dataset.name != source_dataset.name:
324
+ continue
325
+ condition = self._dataset_join_on(
326
+ ctx, relationship, target_dataset, source_alias, values
327
+ )
328
+ names.add(
329
+ f"{relationship.dataset.sql(dialect)}.{relationship.name.sql(dialect)}"
330
+ )
331
+ sources.add(previous_alias)
332
+ if self._join_terms(condition, ctx.dialect) <= actual_terms:
333
+ return
334
+
335
+ if not names:
336
+ return
337
+ relationship_names = sorted(names)
338
+ description = (
339
+ f"relationship {relationship_names[0]}"
340
+ if len(relationship_names) == 1
341
+ else f"relationships {', '.join(relationship_names)}"
342
+ )
343
+ logger.warning(
344
+ "Join %s -> %s could not be verified as a subset of %s; "
345
+ "results may have missing or duplicate rows",
346
+ ", ".join(sorted(sources)),
347
+ alias,
348
+ description,
349
+ )
350
+
351
+ def _expand_fanout_grouping(
352
+ self,
353
+ projections: list[exp.Expr],
354
+ requested_groups: list[tuple[exp.Expr, ...]],
355
+ where: exp.Expr | None,
356
+ user_joins: list[exp.Join],
357
+ ctx: ExpandContext,
358
+ ) -> exp.Query:
359
+ used_aliases = {projection.alias for projection in projections}
360
+ alias_numbers = count()
361
+
362
+ def internal_alias(preferred: str) -> str:
363
+ alias = preferred
364
+ while alias in used_aliases:
365
+ alias = f"_metricflow_{next(alias_numbers)}"
366
+ used_aliases.add(alias)
367
+ return alias
368
+
369
+ grouped_expressions = frozenset(
370
+ expression for grouping_set in requested_groups for expression in grouping_set
371
+ )
372
+ dimensions: dict[exp.Expr, exp.Expr] = {}
373
+ for projection in projections:
374
+ if projection.this in grouped_expressions:
375
+ dimensions.setdefault(projection.this, projection)
376
+
377
+ aggregate_grain_by_id: dict[int, str] = {}
378
+ determined_by_grain: dict[str, dict[str, bool]] = {}
379
+ for projection in projections:
380
+ if projection.this in grouped_expressions:
381
+ continue
382
+ for aggregate in projection.this.find_all(exp.AggFunc):
383
+ referenced_aliases = {
384
+ column.table for column in aggregate.find_all(exp.Column) if column.table
385
+ }
386
+ fanout_aliases = {
387
+ ctx.fanout_grain_by_alias[alias]
388
+ for alias in referenced_aliases
389
+ if alias in ctx.fanout_grain_by_alias
390
+ }
391
+ grain_alias = (
392
+ max(fanout_aliases, key=lambda alias: ctx.join_depths[alias])
393
+ if fanout_aliases
394
+ else ctx.aggregate_grains.get(id(aggregate), ctx.root_alias)
395
+ )
396
+ if referenced_aliases:
397
+ self._reject_fanout_aggregate(
398
+ ctx,
399
+ aggregate,
400
+ grain_alias,
401
+ referenced_aliases,
402
+ determined_by_grain,
403
+ )
404
+ aggregate_grain_by_id[id(aggregate)] = grain_alias
405
+ aggregate_grains = set(aggregate_grain_by_id.values())
406
+ grains: dict[str, tuple[exp.Expr, ...]] = {}
407
+ if ctx.root_alias in aggregate_grains:
408
+ grains[ctx.root_alias] = self._grain(ctx, ctx.root_alias)
409
+ for alias, parent_alias in ctx.fanout_parents.items():
410
+ if parent_alias in aggregate_grains and parent_alias not in grains:
411
+ grains[parent_alias] = self._grain(ctx, parent_alias)
412
+ grains[alias] = self._grain(ctx, alias)
413
+ for alias in aggregate_grain_by_id.values():
414
+ if alias not in grains:
415
+ grains[alias] = self._grain(ctx, alias)
416
+
417
+ if len(requested_groups) > 1 and any(
418
+ key in grouped_expressions for grain in grains.values() for key in grain
419
+ ):
420
+ self._fail(ctx, "GROUPING SETS cannot overlap entity keys")
421
+
422
+ dimension_exprs = list(
423
+ dict.fromkeys(
424
+ expression for grouping_set in requested_groups for expression in grouping_set
425
+ )
426
+ )
427
+ calculated_groups = [
428
+ exp.tuple_(*dict.fromkeys((*requested_group, *grain)))
429
+ for requested_group in requested_groups
430
+ for grain in grains.values()
431
+ ]
432
+ inner_group = exp.Group(expressions=[exp.GroupingSets(expressions=calculated_groups)])
433
+
434
+ # Prefer a key outside the dimensions so GROUPING can identify its grain.
435
+ markers = [
436
+ next((key for key in grain if key not in grouped_expressions), grain[0])
437
+ for grain in grains.values()
438
+ ]
439
+ weights = [1 << (len(markers) - index - 1) for index in range(len(markers))]
440
+ group_id_expr = self._grouping_id_expression(markers, copy=False)
441
+
442
+ marker_weights = dict(zip(markers, weights, strict=True))
443
+ all_mask = (1 << len(markers)) - 1
444
+ dimension_mask = 0
445
+ for dimension in grouped_expressions:
446
+ dimension_mask |= marker_weights.get(dimension, 0)
447
+ group_ids: dict[str, int] = {}
448
+ for alias, grain in grains.items():
449
+ included_mask = dimension_mask
450
+ for key in grain:
451
+ included_mask |= marker_weights.get(key, 0)
452
+ group_ids[alias] = all_mask ^ included_mask
453
+ if len(set(group_ids.values())) != len(group_ids):
454
+ self._fail(ctx, "Grouping sets cannot distinguish entity grains")
455
+
456
+ group_id_alias = internal_alias("_group_id")
457
+ dimension_group_id_alias = (
458
+ internal_alias("_dimension_group_id") if len(requested_groups) > 1 else None
459
+ )
460
+ inner_projections: list[exp.Expr] = []
461
+ dimension_aliases: dict[exp.Expr, str] = {}
462
+ inner_aliases: set[str] = set()
463
+ for index, dimension_expr in enumerate(dimension_exprs):
464
+ dimension_projection = dimensions.get(dimension_expr)
465
+ if dimension_projection is None or dimension_projection.alias in inner_aliases:
466
+ alias = internal_alias(f"_group_dimension_{index}")
467
+ inner_projections.append(exp.alias_(dimension_expr, alias))
468
+ else:
469
+ alias = dimension_projection.alias
470
+ inner_projections.append(dimension_projection)
471
+ dimension_aliases[dimension_expr] = alias
472
+ inner_aliases.add(alias)
473
+ inner_projections.append(exp.alias_(group_id_expr, group_id_alias, copy=False))
474
+ inner_aliases.add(group_id_alias)
475
+ if dimension_group_id_alias is not None:
476
+ inner_projections.append(
477
+ exp.alias_(
478
+ self._grouping_id_expression(dimension_exprs, copy=True),
479
+ dimension_group_id_alias,
480
+ copy=False,
481
+ )
482
+ )
483
+ inner_aliases.add(dimension_group_id_alias)
484
+ outer_projections: list[exp.Expr] = []
485
+ for projection in projections:
486
+ if projection.this in grouped_expressions:
487
+ outer_projections.append(
488
+ exp.alias_(
489
+ exp.column(dimension_aliases[projection.this], table="grouped"),
490
+ projection.alias,
491
+ copy=False,
492
+ )
493
+ )
494
+ continue
495
+
496
+ aggregates = list(projection.this.find_all(exp.AggFunc))
497
+ if not aggregates:
498
+ self._fail(ctx, "Expected aggregate projection", projection)
499
+
500
+ def lower(
501
+ node: exp.Expr,
502
+ metric_aggregate_count: int = len(aggregates),
503
+ metric_alias: str = projection.alias,
504
+ ) -> exp.Expr:
505
+ if node in dimension_aliases:
506
+ return exp.column(dimension_aliases[node], table="grouped")
507
+ # The condition reads the joined rows, so it filters each grain's value in the
508
+ # inner query, e.g. SUM(x) FILTER (WHERE y > 1) keeps MAX(x) FILTER (WHERE y > 1).
509
+ condition = None
510
+ if isinstance(node, exp.Filter) and isinstance(node.this, exp.AggFunc):
511
+ condition = node.expression.this
512
+ node = node.this
513
+ if not isinstance(node, exp.AggFunc):
514
+ return node
515
+
516
+ grain_alias = aggregate_grain_by_id[id(node)]
517
+
518
+ group_test = exp.column(group_id_alias, table="grouped").eq(group_ids[grain_alias])
519
+ distinct = False
520
+ match node:
521
+ case exp.Count(this=exp.Star()) if (
522
+ grain_alias == ctx.alias and condition is None
523
+ ):
524
+ return exp.func(
525
+ "COUNT",
526
+ exp.case().when(group_test, then=1, copy=False),
527
+ copy=False,
528
+ )
529
+ case exp.Count(this=exp.Star()):
530
+ value = grains[grain_alias][0].copy()
531
+ case exp.AggFunc(this=exp.Distinct(expressions=[value])):
532
+ distinct = True
533
+ case exp.AggFunc(this=exp.Distinct()):
534
+ self._fail(ctx, "Expected one DISTINCT expression", node)
535
+ case _:
536
+ value = node.this
537
+
538
+ slot = (
539
+ metric_alias
540
+ if metric_aggregate_count == 1 and metric_alias not in inner_aliases
541
+ else internal_alias(f"_metric_{len(inner_projections)}")
542
+ )
543
+ inner_aliases.add(slot)
544
+ outer_value = exp.case().when(
545
+ group_test,
546
+ then=exp.column(slot, table="grouped"),
547
+ copy=False,
548
+ )
549
+ node.set(
550
+ "this",
551
+ exp.Distinct(expressions=[outer_value]) if distinct else outer_value,
552
+ )
553
+ if grain_alias != ctx.root_alias and not (
554
+ isinstance(value, exp.Column) and value.table == grain_alias
555
+ ):
556
+ value = exp.case().when(
557
+ grains[grain_alias][0].is_(exp.null()).not_(copy=False),
558
+ then=value,
559
+ copy=False,
560
+ )
561
+ inner_value: exp.Expr = exp.func("MAX", value, copy=False)
562
+ if condition is not None:
563
+ inner_value = exp.Filter(this=inner_value, expression=exp.Where(this=condition))
564
+ inner_projections.append(exp.alias_(inner_value, slot, copy=False))
565
+ return node
566
+
567
+ outer_expr = projection.this.transform(lower, copy=False)
568
+ outer_projections.append(exp.alias_(outer_expr, projection.alias, copy=False))
569
+
570
+ inner = exp.select(*inner_projections, copy=False).from_(
571
+ exp.alias_(ctx.dataset.from_(ctx.arguments), ctx.alias, copy=False),
572
+ copy=False,
573
+ )
574
+ inner.set("joins", self._ordered_joins(user_joins, ctx))
575
+ if where is not None:
576
+ inner.set("where", where)
577
+ inner.set("group", inner_group)
578
+
579
+ outer = exp.select(*outer_projections, copy=False).from_(
580
+ inner.subquery(alias="grouped", copy=False), copy=False
581
+ )
582
+ outer_group = [
583
+ exp.column(dimension_aliases[dimension_expr], table="grouped")
584
+ for dimension_expr in dimension_exprs
585
+ ]
586
+ if dimension_group_id_alias is not None:
587
+ outer_group.append(exp.column(dimension_group_id_alias, table="grouped"))
588
+ if outer_group:
589
+ outer.group_by(
590
+ *outer_group,
591
+ copy=False,
592
+ )
593
+ return outer
594
+
595
+ def _reject_fanout_aggregate(
596
+ self,
597
+ ctx: ExpandContext,
598
+ aggregate: exp.AggFunc,
599
+ grain_alias: str,
600
+ referenced_aliases: set[str],
601
+ determined_by_grain: dict[str, dict[str, bool]],
602
+ ) -> None:
603
+ """Reject values that are not single-valued at the aggregate grain."""
604
+ determined = determined_by_grain.get(grain_alias)
605
+ if determined is None:
606
+ determined = {grain_alias: True}
607
+ alias = grain_alias
608
+ while alias in ctx.fanout_parents:
609
+ alias = ctx.fanout_parents[alias]
610
+ determined[alias] = True
611
+ determined_by_grain[grain_alias] = determined
612
+ for referenced_alias in referenced_aliases:
613
+ alias = referenced_alias
614
+ path: list[str] = []
615
+ while alias not in determined:
616
+ path.append(alias)
617
+ if alias in ctx.fanout_parents:
618
+ determined[alias] = False
619
+ break
620
+ parent = ctx.join_parents.get(alias)
621
+ if parent is None:
622
+ determined[alias] = False
623
+ break
624
+ alias = parent
625
+ is_determined = determined[alias]
626
+ for path_alias in path:
627
+ determined[path_alias] = is_determined
628
+ if not is_determined:
629
+ self._fail(ctx, "Aggregate crosses a fan-out join", aggregate)
630
+
631
+ def _grouping_id_expression(self, expressions: list[exp.Expr], copy: bool) -> exp.Expr:
632
+ result: exp.Expr | None = None
633
+ for index, expression in enumerate(expressions):
634
+ term: exp.Expr = exp.func("GROUPING", expression, copy=copy)
635
+ weight = 1 << (len(expressions) - index - 1)
636
+ if weight != 1:
637
+ term = exp.Mul(this=term, expression=exp.Literal.number(weight))
638
+ result = term if result is None else exp.Add(this=result, expression=term)
639
+ if result is None:
640
+ raise ValueError("GROUPING ID requires at least one expression")
641
+ return result
642
+
643
+ def _context_for_alias(self, ctx: ExpandContext, alias: str) -> ExpandContext:
644
+ if alias == ctx.alias:
645
+ return ctx
646
+ joined = ctx.datasets_by_alias.get(alias)
647
+ if joined is None:
648
+ return ctx
649
+ return replace(
650
+ ctx,
651
+ registry=joined[0],
652
+ dataset=joined[1],
653
+ alias=alias,
654
+ arguments=ctx.arguments_by_alias[alias],
655
+ )
656
+
657
+ def _expand_expr(
658
+ self,
659
+ expr: exp.Expr,
660
+ ctx: ExpandContext,
661
+ source_qualifier: exp.Identifier | None = None,
662
+ *,
663
+ calculation: bool = False,
664
+ agg: bool = False,
665
+ ) -> exp.Expr:
666
+ def recurse(node: exp.Expr) -> exp.Expr:
667
+ # `AGG(calc) FILTER (WHERE c)` filters every aggregate the calculation has, e.g.
668
+ # `SUM(a) - SUM(b)` becomes `SUM(a) FILTER (WHERE c) - SUM(b) FILTER (WHERE c)`, and
669
+ # an aggregate that already has a FILTER keeps it, ANDed with `c`.
670
+ if isinstance(node, exp.Filter) and isinstance(node.this, exp.Agg):
671
+ aggfunc = recurse(node.this)
672
+ condition = self._expand_expr(
673
+ node.expression.this, ctx, source_qualifier, calculation=calculation, agg=agg
674
+ )
675
+
676
+ def filter_aggregate(inner: exp.Expr) -> exp.Expr:
677
+ if isinstance(inner, exp.Filter):
678
+ both = exp.and_(inner.expression.this, condition)
679
+ return exp.Filter(this=inner.this, expression=exp.Where(this=both))
680
+ if isinstance(inner, exp.AggFunc):
681
+ return exp.Filter(this=inner, expression=exp.Where(this=condition.copy()))
682
+ return inner
683
+
684
+ return aggfunc.transform(filter_aggregate, copy=False)
685
+ if isinstance(node, exp.Agg):
686
+ match node.this:
687
+ case exp.Dot() as path:
688
+ path_ctx = self._context_for_alias(ctx, path.parts[0].name)
689
+ aggfunc = self._expand_relationship_path(path, path_ctx)
690
+ case exp.Column() as column:
691
+ column_ctx = self._context_for_alias(ctx, column.table)
692
+ aggfunc = self._expand_col(column.this, column_ctx, agg=True)
693
+ case _:
694
+ self._fail(ctx, "Unsupported AGG argument", node)
695
+ if not is_aggregate_expression(aggfunc):
696
+ self._fail(ctx, "AGG must be used on an aggregate calculation", node)
697
+ return aggfunc
698
+ if isinstance(node, exp.AggFunc):
699
+ ctx.aggregate_grains[id(node)] = ctx.alias
700
+ if calculation and isinstance(node, exp.Placeholder):
701
+ parameters = {name.name: name for name in ctx.dataset.parameters}
702
+ resolved = resolve_name(node.name, parameters)
703
+ if resolved is None:
704
+ dataset = self._sql(ctx, ctx.dataset.name)
705
+ self._fail(ctx, f"Dataset {dataset} has no parameter {node.name}")
706
+ name = parameters[resolved]
707
+ parameter = ctx.dataset.parameters[name]
708
+ return parameter_value(parameter, argument(ctx.arguments, name)).copy()
709
+ if isinstance(node, exp.Dot | exp.Column) and is_shadowed(node, node.parts[0].name):
710
+ return node
711
+ # A calculation's path from its dataset, e.g. `UPPER(customer.current_address.state)`.
712
+ # A query's paths start at an alias instead, which qualification adds.
713
+ if (
714
+ calculation
715
+ and isinstance(node, exp.Dot | exp.Column)
716
+ and isinstance(ctx.dataset.get_column(path_names(node)[0]), Relationship)
717
+ ):
718
+ return self._expand_path(list(path_names(node)), node, ctx)
719
+ # A field of a struct expression, e.g. `{'n': x}.n`, isn't a path, nor is a qualified
720
+ # function, e.g. `udfs.f(x)`.
721
+ if (
722
+ isinstance(node, exp.Dot)
723
+ and isinstance(node.this, exp.Column | exp.Dot)
724
+ and not isinstance(node.expression, exp.Func)
725
+ ):
726
+ path_ctx = self._context_for_alias(ctx, node.parts[0].name)
727
+ return self._expand_relationship_path(node, path_ctx)
728
+ if isinstance(node, exp.Column):
729
+ if not node.table or node.args["table"] == source_qualifier:
730
+ return self._expand_col(node.this, ctx, agg=agg)
731
+ if node.table == ctx.alias or node.table in ctx.datasets_by_alias:
732
+ column_ctx = self._context_for_alias(ctx, node.table)
733
+ return self._expand_col(node.this, column_ctx, agg=agg)
734
+ return node
735
+
736
+ def expand(node: exp.Expr) -> exp.Expr:
737
+ expanded = recurse(node)
738
+ if expanded is node:
739
+ return node
740
+
741
+ # Like SQLGlot alias expansion, preserve grouping (c * 2 -> (a + b) * 2)
742
+ # and let its simplifier remove parentheses that the surrounding SQL doesn't need.
743
+ wrapped = node.replace(exp.paren(expanded, copy=False))
744
+ return simplify_parens(wrapped, ctx.dialect)
745
+
746
+ return expr.transform(expand)
747
+
748
+ def _sql(self, ctx: ExpandContext, name: exp.Expr) -> str:
749
+ return name.sql(ctx.registry.dialect)
750
+
751
+ def _expand_col(self, name: exp.Identifier, ctx: ExpandContext, *, agg: bool) -> exp.Expr:
752
+ field = ctx.dataset.get_column(name)
753
+
754
+ if not field and self.strict:
755
+ dataset = self._sql(ctx, ctx.dataset.name)
756
+ self._fail(ctx, f"Unknown field of dataset: {dataset}.{self._sql(ctx, name)}")
757
+
758
+ if isinstance(field, Column):
759
+ return self._physical_column(name, ctx.alias, field.datatype)
760
+ if isinstance(field, Calculation):
761
+ field_expr = field.expr
762
+ if not agg and projection_has_aggregate(field_expr):
763
+ return exp.null()
764
+ expanded = self._expand_expr(field_expr, ctx, calculation=True, agg=True)
765
+ return exp.null() if not agg and projection_has_aggregate(expanded) else expanded
766
+ if isinstance(field, Relationship):
767
+ return exp.null()
768
+ if field is None:
769
+ return exp.column(col=name, table=ctx.alias)
770
+ else:
771
+ self._fail(ctx, f"Unknown field type: {type(field).__name__}")
772
+
773
+ def _expand_relationship_path(self, dot: exp.Dot, ctx: ExpandContext) -> exp.Expr:
774
+ head, *tail = path_names(dot)
775
+ if head.name != ctx.alias:
776
+ self._fail(ctx, "Unsupported relationship path", dot)
777
+ return self._expand_path(tail, dot, ctx)
778
+
779
+ def _expand_path(
780
+ self, parts: list[exp.Identifier], expr: exp.Expr, ctx: ExpandContext
781
+ ) -> exp.Expr:
782
+ # The walk alternates namespaces: a segment resolves against a dataset's
783
+ # fields, or while a relationship is pending, against that entity's
784
+ # attributes, whose source-qualified expressions continue the walk.
785
+ dataset = ctx.dataset
786
+ dataset_alias = ctx.alias
787
+ relationship: Relationship | None = None
788
+ entity: EntityProxy | None = None
789
+ cardinality = Cardinality.MANY_TO_ONE
790
+ remaining = deque(parts)
791
+ while remaining:
792
+ name = remaining.popleft()
793
+ if relationship is None or entity is None:
794
+ match dataset.get_column(name):
795
+ case Relationship() as field:
796
+ if not remaining:
797
+ return exp.null()
798
+ match self._relationship_target(ctx, field):
799
+ case EntityTarget(entity=target_entity), target_cardinality:
800
+ relationship, entity = field, target_entity
801
+ cardinality = target_cardinality
802
+ case DatasetTarget(dataset=target), cardinality:
803
+ # Like an entity's key, a many-to-one key's field needs no join.
804
+ if (
805
+ cardinality is Cardinality.MANY_TO_ONE
806
+ and len(remaining) == 1
807
+ and remaining[0] in field.key
808
+ and target.get_column(remaining[0]) is not None
809
+ ):
810
+ value = field.key[remaining[0]]
811
+ return self._key_value(ctx, value, dataset, dataset_alias)
812
+ dataset_alias = self._register_dataset_join(
813
+ ctx, field, target, cardinality, dataset, dataset_alias
814
+ )
815
+ dataset = target
816
+ case Column(datatype=datatype):
817
+ if remaining:
818
+ self._fail(ctx, "Unsupported relationship path", expr)
819
+ return self._physical_column(name, dataset_alias, datatype)
820
+ case Calculation() as calc:
821
+ return self._expand_expr(
822
+ calc.expr,
823
+ replace(ctx, dataset=dataset, alias=dataset_alias),
824
+ calculation=True,
825
+ agg=True,
826
+ )
827
+ case _:
828
+ self._fail(ctx, "Unsupported relationship path", expr)
829
+ continue
830
+ # Like a dataset's, a many-to-one key's column needs no join; a one-to-many one's rows do.
831
+ if name in relationship.key and cardinality is Cardinality.MANY_TO_ONE:
832
+ if remaining:
833
+ self._fail(ctx, "Unsupported relationship path", expr)
834
+ return self._key_value(ctx, relationship.key[name], dataset, dataset_alias)
835
+ attribute = entity.get_column(name)
836
+ if attribute is None:
837
+ entity_name = self._sql(ctx, entity.name)
838
+ self._fail(ctx, f"Unknown attribute: {entity_name}.{self._sql(ctx, name)}")
839
+ attr_expr = attribute.expr
840
+ if isinstance(attr_expr, exp.Column | exp.Dot):
841
+ if len(attr_expr.parts) < 2:
842
+ self._fail(ctx, "Unsupported attribute expression", attr_expr)
843
+ source, attr_path = self._attribute_source(ctx, entity, attr_expr)
844
+ dataset_alias = self._register_join(
845
+ ctx, relationship, entity, cardinality, source, dataset, dataset_alias
846
+ )
847
+ relationship, entity = None, None
848
+ dataset = self._dataset_for(ctx, source.dataset)
849
+ remaining.extendleft(reversed(attr_path))
850
+ continue
851
+ # A general expression over one source dataset's columns.
852
+ source = self._single_source(ctx, entity, attr_expr, "Unsupported attribute expression")
853
+ if remaining:
854
+ self._fail(ctx, "Unsupported relationship path", expr)
855
+ join_alias = self._register_join(
856
+ ctx, relationship, entity, cardinality, source, dataset, dataset_alias
857
+ )
858
+ return self._expand_expr(
859
+ attr_expr,
860
+ replace(ctx, dataset=self._dataset_for(ctx, source.dataset), alias=join_alias),
861
+ source_qualifier=source.dataset.this,
862
+ agg=True,
863
+ )
864
+ self._fail(ctx, "Unsupported relationship path", expr)
865
+
866
+ def _physical_column(
867
+ self, name: exp.Identifier, alias: str, datatype: exp.DataType | None
868
+ ) -> exp.Expr:
869
+ column = exp.column(col=name, table=alias)
870
+ if datatype:
871
+ column.type = datatype.copy()
872
+ return column
873
+
874
+ def _dataset_for(self, ctx: ExpandContext, name: exp.Table) -> DatasetProxy:
875
+ dataset = ctx.registry.get_dataset(name)
876
+ if dataset is None:
877
+ self._fail(ctx, f"Unknown dataset: {self._sql(ctx, name)}")
878
+ return dataset
879
+
880
+ def _entity(self, ctx: ExpandContext, name: exp.Table) -> EntityProxy:
881
+ entity = ctx.registry.get_entity(name)
882
+ if entity is None:
883
+ self._fail(ctx, f"Unknown entity: {self._sql(ctx, name)}")
884
+ return entity
885
+
886
+ def _attribute_source(
887
+ self, ctx: ExpandContext, entity: EntityProxy, column: exp.Column | exp.Dot
888
+ ) -> tuple[Source, tuple[exp.Identifier, ...]]:
889
+ reference = resolve_attribute_reference(entity, column)
890
+ source = reference.source or self._entity_source(ctx, entity, reference.dataset)
891
+ return source, reference.path
892
+
893
+ def _single_source(
894
+ self, ctx: ExpandContext, entity: EntityProxy, expr: exp.Expr, message: str
895
+ ) -> Source:
896
+ sources: dict[Id, Source] = {}
897
+ for reference in resolve_attribute_references(entity, expr):
898
+ source = reference.source or self._entity_source(ctx, entity, reference.dataset)
899
+ sources[source.id] = source
900
+ if len(sources) != 1:
901
+ self._fail(ctx, message, expr)
902
+ return next(iter(sources.values()))
903
+
904
+ def _relationship_target(
905
+ self, ctx: ExpandContext, relationship: Relationship
906
+ ) -> tuple[RelationshipTarget, Cardinality]:
907
+ """What `relationship` targets, and its cardinality, checked against a declared one."""
908
+ try:
909
+ target = relationship_target(ctx.registry, relationship)
910
+ return target, relationship_cardinality(relationship, target, ctx.registry.dialect)
911
+ except SchemaError as error:
912
+ self._fail(ctx, str(error))
913
+
914
+ def _entity_source(self, ctx: ExpandContext, entity: EntityProxy, dataset: exp.Table) -> Source:
915
+ source = entity.get_source(dataset)
916
+ if source is None:
917
+ entity_name, dataset_name = self._sql(ctx, entity.name), self._sql(ctx, dataset)
918
+ self._fail(ctx, f"No source for {entity_name} on {dataset_name}")
919
+ return source
920
+
921
+ def _grain(self, ctx: ExpandContext, alias: str) -> tuple[exp.Expr, ...]:
922
+ """
923
+ Expressions identifying the rows of the source `alias`: its dataset's key, else the key of
924
+ the entity source it was joined through, else of its dataset's only entity source.
925
+ """
926
+ dataset = self._dataset_at(ctx, alias)
927
+ key = dataset.key
928
+ # A condition's parameters aren't fields of the rows, so they can't identify them.
929
+ if key is not None and not key.conditions():
930
+ alias_ctx = replace(ctx, dataset=dataset, alias=alias)
931
+ return tuple(self._expand_col(name, alias_ctx, agg=False) for name in key.fields())
932
+ source = ctx.grain_sources.get(alias)
933
+ if source is not None:
934
+ return self._entity_keys_from_on(ctx, source, alias)
935
+ grains = {
936
+ frozenset(keys): keys
937
+ for keys in (
938
+ self._entity_keys_from_on(ctx, entity_source, alias)
939
+ for entity_source in dataset.get_entity_sources().values()
940
+ )
941
+ }
942
+ if len(grains) != 1:
943
+ reason = "its key has a condition" if key is not None else "give it a key"
944
+ self._fail(ctx, f"Cannot determine the grain of {dataset.name}: {reason}")
945
+ return next(iter(grains.values()))
946
+
947
+ def _dataset_at(self, ctx: ExpandContext, alias: str) -> DatasetProxy:
948
+ """The dataset the source `alias` reads: the root, a generated join, or a user join."""
949
+ if alias == ctx.root_alias:
950
+ return ctx.dataset
951
+ if alias in ctx.join_datasets:
952
+ return ctx.join_datasets[alias]
953
+ return ctx.datasets_by_alias[alias][1]
954
+
955
+ def _entity_keys_from_on(
956
+ self, ctx: ExpandContext, source: Source, alias: str
957
+ ) -> tuple[exp.Expr, ...]:
958
+ entity = self._entity(ctx, source.entity)
959
+ keys = {name.name: name for name in entity.key}
960
+ pending = [source.on]
961
+ bindings: dict[exp.Identifier, exp.Expr] = {}
962
+ while pending:
963
+ clause = pending.pop().unnest()
964
+ match clause:
965
+ case exp.And(this=left, expression=right):
966
+ pending.extend((right, left))
967
+ continue
968
+ case (
969
+ exp.EQ(this=exp.Placeholder() as key, expression=value)
970
+ | exp.EQ(this=value, expression=exp.Placeholder() as key)
971
+ ):
972
+ resolved = resolve_name(key.name, keys)
973
+ if resolved is None:
974
+ entity_name = self._sql(ctx, source.entity)
975
+ self._fail(ctx, f"Cannot derive {entity_name} key from source", source.on)
976
+ name = keys[resolved]
977
+ if name in bindings or value.find(exp.Placeholder) is not None:
978
+ self._fail(ctx, "Ambiguous source key binding", clause)
979
+ bindings[name] = self._qualify_key(value, alias)
980
+ case _:
981
+ continue
982
+
983
+ if not entity.key or bindings.keys() != entity.key.keys():
984
+ entity_name = self._sql(ctx, source.entity)
985
+ self._fail(ctx, f"Cannot derive {entity_name} key from source", source.on)
986
+ return tuple(bindings[name] for name in entity.key)
987
+
988
+ def _join_alias_for_path(
989
+ self, ctx: ExpandContext, parent_alias: str, name: exp.Identifier, dataset: exp.Table
990
+ ) -> str:
991
+ path = (parent_alias, name, dataset)
992
+ if path in ctx.join_aliases:
993
+ return ctx.join_aliases[path]
994
+
995
+ base = name.name if parent_alias == ctx.root_alias else f"{parent_alias}_{name.name}"
996
+ alias = find_new_name(ctx.used_aliases, base)
997
+ ctx.used_aliases.add(alias)
998
+ ctx.join_aliases[path] = alias
999
+ ctx.join_parents[alias] = parent_alias
1000
+ ctx.join_depths[alias] = ctx.join_depths.get(parent_alias, 0) + 1
1001
+ return alias
1002
+
1003
+ def _register_join(
1004
+ self,
1005
+ ctx: ExpandContext,
1006
+ relationship: Relationship,
1007
+ entity: EntityProxy,
1008
+ cardinality: Cardinality,
1009
+ source: Source,
1010
+ parent: DatasetProxy,
1011
+ parent_alias: str,
1012
+ ) -> str:
1013
+ """
1014
+ Joins the entity `source` a path reads, through `relationship` from `parent`: on its `on`
1015
+ if many-to-one, else on the fields the key binds, fanning out.
1016
+ """
1017
+ alias = self._join_alias_for_path(ctx, parent_alias, relationship.name, source.dataset)
1018
+ if cardinality is Cardinality.ONE_TO_MANY:
1019
+ ctx.fanout_grain_by_alias[alias] = alias
1020
+ ctx.fanout_parents[alias] = parent_alias
1021
+ elif parent_alias in ctx.fanout_grain_by_alias:
1022
+ ctx.fanout_grain_by_alias[alias] = ctx.fanout_grain_by_alias[parent_alias]
1023
+ if alias in ctx.joins:
1024
+ return alias
1025
+ dataset = self._dataset_for(ctx, source.dataset)
1026
+ ctx.join_datasets[alias] = dataset
1027
+ # The entity's key, from the source's `on`, is the grain of the rows it joins.
1028
+ ctx.grain_sources[alias] = source
1029
+ values = self._key_values(ctx, relationship.key, parent, parent_alias)
1030
+ on = (
1031
+ self._bind_join_keys(ctx, source.on, values, alias)
1032
+ if cardinality is Cardinality.MANY_TO_ONE
1033
+ else self._entity_join_on(ctx, relationship, entity, source, dataset, alias, values)
1034
+ )
1035
+ ctx.joins[alias] = exp.Join(
1036
+ this=exp.alias_(dataset.from_(ctx.arguments), alias, copy=False), on=on, side="LEFT"
1037
+ )
1038
+ return alias
1039
+
1040
+ def _entity_join_on(
1041
+ self,
1042
+ ctx: ExpandContext,
1043
+ relationship: Relationship,
1044
+ entity: EntityProxy,
1045
+ source: Source,
1046
+ dataset: DatasetProxy,
1047
+ alias: str,
1048
+ values: dict[exp.Identifier, exp.Expr],
1049
+ ) -> exp.Expr:
1050
+ """
1051
+ The condition joining the entity `source`, its `dataset` as `alias`, to the `values` of a
1052
+ one-to-many relationship's key.
1053
+ """
1054
+ keys = dict(zip(entity.key, self._entity_keys_from_on(ctx, source, alias), strict=True))
1055
+ source_ctx = replace(ctx, dataset=dataset, alias=alias)
1056
+ terms: list[exp.Expr] = []
1057
+ for name, value in values.items():
1058
+ if name in keys:
1059
+ field = keys[name]
1060
+ else:
1061
+ match entity.get_column(name):
1062
+ case Attribute(expr=exp.Column() as column) if (
1063
+ self._attribute_source(ctx, entity, column)[0] == source
1064
+ ):
1065
+ field = self._expand_col(column.this, source_ctx, agg=False)
1066
+ case _:
1067
+ where = (
1068
+ f"{self._sql(ctx, relationship.dataset)}."
1069
+ f"{self._sql(ctx, relationship.name)}"
1070
+ )
1071
+ self._fail(
1072
+ ctx,
1073
+ f"Relationship {where} binds {self._sql(ctx, entity.name)}."
1074
+ f"{self._sql(ctx, name)}, which isn't a column of the source it "
1075
+ f"reads, {self._sql(ctx, source.dataset)}",
1076
+ )
1077
+ terms.append(field.eq(value.copy()))
1078
+ return exp.and_(*terms, copy=False)
1079
+
1080
+ def _register_dataset_join(
1081
+ self,
1082
+ ctx: ExpandContext,
1083
+ relationship: Relationship,
1084
+ target: DatasetProxy,
1085
+ cardinality: Cardinality,
1086
+ parent: DatasetProxy,
1087
+ parent_alias: str,
1088
+ ) -> str:
1089
+ """Joins `target` through `relationship` from `parent`, fanning out if one-to-many."""
1090
+ alias = self._join_alias_for_path(ctx, parent_alias, relationship.name, target.name)
1091
+ if cardinality is Cardinality.ONE_TO_MANY:
1092
+ ctx.fanout_grain_by_alias[alias] = alias
1093
+ ctx.fanout_parents[alias] = parent_alias
1094
+ elif parent_alias in ctx.fanout_grain_by_alias:
1095
+ ctx.fanout_grain_by_alias[alias] = ctx.fanout_grain_by_alias[parent_alias]
1096
+ if alias in ctx.joins:
1097
+ return alias
1098
+ ctx.join_datasets[alias] = target
1099
+ values = self._key_values(ctx, relationship.key, parent, parent_alias)
1100
+ ctx.joins[alias] = exp.Join(
1101
+ this=exp.alias_(target.from_(ctx.arguments), alias, copy=False),
1102
+ on=self._dataset_join_on(ctx, relationship, target, alias, values),
1103
+ side="LEFT",
1104
+ )
1105
+ return alias
1106
+
1107
+ def _dataset_join_on(
1108
+ self,
1109
+ ctx: ExpandContext,
1110
+ relationship: Relationship,
1111
+ target: DatasetProxy,
1112
+ alias: str,
1113
+ values: dict[exp.Identifier, exp.Expr],
1114
+ ) -> exp.Expr:
1115
+ """
1116
+ The condition joining `target` as `alias` to the `values` of a relationship's key: each
1117
+ field it binds equal to its value, in the order of the target's key, then the
1118
+ relationship's, and each condition of the target's key, its placeholders bound, e.g.
1119
+ `sr.sr_item_sk = ss.ss_item_sk AND sr.sr_ticket_number = ss.ss_ticket_number`.
1120
+ """
1121
+ where = (
1122
+ f"Relationship {self._sql(ctx, relationship.dataset)}."
1123
+ f"{self._sql(ctx, relationship.name)}"
1124
+ )
1125
+ target_name = self._sql(ctx, target.name)
1126
+ target_ctx = replace(ctx, dataset=target, alias=alias)
1127
+ key = target.key
1128
+ conditions = key.conditions() if key is not None else ()
1129
+ parameters = {name for condition in conditions for name in condition.parameters}
1130
+ fields = dict.fromkeys((*(key.fields() if key is not None else ()), *relationship.key))
1131
+ terms: list[exp.Expr] = [
1132
+ self._expand_col(name, target_ctx, agg=False).eq(values[name].copy())
1133
+ for name in fields
1134
+ if name in values and name not in parameters
1135
+ ]
1136
+ for condition in conditions:
1137
+ bound = condition.parameters.keys() & values.keys()
1138
+ if not bound:
1139
+ continue
1140
+ if bound != condition.parameters.keys():
1141
+ self._fail(
1142
+ ctx,
1143
+ f"{where} binds {sorted(name.name for name in bound)} but not all "
1144
+ f"placeholders of a key condition of {target_name}, "
1145
+ f"{sorted(name.name for name in condition.parameters)}",
1146
+ )
1147
+ # The condition's own columns are the target's; its placeholders are bound after.
1148
+ on = self._expand_expr(condition.on, target_ctx)
1149
+ terms.append(self._bind_join_keys(ctx, on, values, alias))
1150
+ if not terms:
1151
+ self._fail(ctx, f"{where} binds nothing of {target_name}")
1152
+ return exp.and_(*terms, copy=False)
1153
+
1154
+ def _key_values(
1155
+ self,
1156
+ ctx: ExpandContext,
1157
+ key: dict[exp.Identifier, exp.Expr],
1158
+ dataset: DatasetProxy,
1159
+ alias: str,
1160
+ ) -> dict[exp.Identifier, exp.Expr]:
1161
+ """A relationship's key, read from `dataset` joined as `alias`."""
1162
+ return {name: self._key_value(ctx, expr, dataset, alias) for name, expr in key.items()}
1163
+
1164
+ def _key_value(
1165
+ self, ctx: ExpandContext, expr: exp.Expr, dataset: DatasetProxy, alias: str
1166
+ ) -> exp.Expr:
1167
+ """
1168
+ A value of a relationship's key, read from `dataset` joined as `alias` like a calculation
1169
+ is, so it can read other fields and paths, e.g. `_item.i_item_id`.
1170
+ """
1171
+ return self._expand_expr(
1172
+ expr, replace(ctx, dataset=dataset, alias=alias), calculation=True, agg=False
1173
+ )
1174
+
1175
+ def _bind_join_keys(
1176
+ self,
1177
+ ctx: ExpandContext,
1178
+ on: exp.Expr,
1179
+ values: dict[exp.Identifier, exp.Expr],
1180
+ source_alias: str,
1181
+ ) -> exp.Expr:
1182
+ """
1183
+ Binds the `:name` placeholders of `on` to `values`, and qualifies its own columns with
1184
+ `source_alias`, e.g. `c_customer_sk = :sk` gives `customer.c_customer_sk = ss_customer_sk`.
1185
+ """
1186
+
1187
+ names = {name.name: name for name in values}
1188
+
1189
+ def bind(node: exp.Expr) -> exp.Expr:
1190
+ if isinstance(node, exp.Placeholder):
1191
+ resolved = resolve_name(node.name, names)
1192
+ if resolved is None:
1193
+ self._fail(ctx, f"Unbound join key: :{node.name}")
1194
+ return values[names[resolved]].copy()
1195
+ if isinstance(node, exp.Column) and not node.table:
1196
+ return exp.column(node.this, table=source_alias)
1197
+ return node
1198
+
1199
+ return on.transform(bind)
1200
+
1201
+ def _qualify_key(self, key_expr: exp.Expr, alias: str) -> exp.Expr:
1202
+ return key_expr.transform(
1203
+ lambda node: (
1204
+ exp.column(node.this, table=alias)
1205
+ if isinstance(node, exp.Column) and not node.table
1206
+ else node
1207
+ ),
1208
+ )