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