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,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
|
+
)
|