ripple-sql 0.1.0__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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""Multi-part column reference resolution.
|
|
2
|
+
|
|
3
|
+
BigQuery (and increasingly Databricks) allow `alias.struct_col.field.subfield`.
|
|
4
|
+
sqlglot parses the leading identifiers into the Column node's catalog/db/table
|
|
5
|
+
slots, so naively reading `col.table` turns a struct root into a phantom
|
|
6
|
+
source table. This module decides, against the statement's known relations,
|
|
7
|
+
which identifier is the relation and which is the column.
|
|
8
|
+
|
|
9
|
+
Rules, in order:
|
|
10
|
+
1. one or two parts: classic (alias, column), unchanged.
|
|
11
|
+
2. leading part is a known relation alias/table: relation-anchored struct
|
|
12
|
+
access. `o.customer.name` -> relation o, column customer, field .name.
|
|
13
|
+
3. the tail (everything but the last part, or just the second-to-last part)
|
|
14
|
+
is a known relation: fully qualified table.column. `ds.orders.customer`
|
|
15
|
+
-> relation orders, column customer.
|
|
16
|
+
4. nothing matches: assume struct access on an unresolved relation, but say
|
|
17
|
+
so (certain=False) so the caller downgrades trust instead of fabricating
|
|
18
|
+
a confident edge.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from sqlglot import exp
|
|
26
|
+
|
|
27
|
+
from ripple.engine.preprocess import JINJA_DOT_SENTINEL, restore_jinja_dots
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def column_parts(col: exp.Column) -> list[str]:
|
|
31
|
+
"""All identifier parts, leading to trailing: [o, customer, name].
|
|
32
|
+
|
|
33
|
+
Parts come back with the preprocess jinja-dot marker restored, so every
|
|
34
|
+
consumer compares and emits the as-written spelling (cycle-12, F19)."""
|
|
35
|
+
parts = []
|
|
36
|
+
for key in ("catalog", "db", "table"):
|
|
37
|
+
node = col.args.get(key)
|
|
38
|
+
if node is not None:
|
|
39
|
+
name = getattr(node, "name", None) or str(node)
|
|
40
|
+
if name:
|
|
41
|
+
parts.append(restore_jinja_dots(name))
|
|
42
|
+
parts.append(restore_jinja_dots(col.name))
|
|
43
|
+
return parts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def table_and_column(col: exp.Column) -> tuple[str | None, str]:
|
|
47
|
+
"""Relation-and-column read for filter/join dependencies, struct-aware.
|
|
48
|
+
|
|
49
|
+
`o.customer.tier` yields (o, customer): the filter depends on the struct
|
|
50
|
+
column, and the relation is the leading identifier, never the struct root.
|
|
51
|
+
"""
|
|
52
|
+
parts = column_parts(col)
|
|
53
|
+
if len(parts) <= 2:
|
|
54
|
+
return (parts[0] if len(parts) == 2 else None, parts[-1])
|
|
55
|
+
return parts[0], parts[1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_column_ref(
|
|
59
|
+
col: exp.Column, known_relations: set[str]
|
|
60
|
+
) -> tuple[str, str, str | None, bool]:
|
|
61
|
+
"""Return (relation_or_alias, column, field_path, certain).
|
|
62
|
+
|
|
63
|
+
known_relations must be lowercase: query aliases, CTE names, table names
|
|
64
|
+
(bare and qualified), and array-expansion aliases.
|
|
65
|
+
"""
|
|
66
|
+
parts = column_parts(col)
|
|
67
|
+
if len(parts) == 1:
|
|
68
|
+
return "", parts[0], None, True
|
|
69
|
+
|
|
70
|
+
leading = parts[0].lower()
|
|
71
|
+
if leading in known_relations:
|
|
72
|
+
if len(parts) == 2:
|
|
73
|
+
return parts[0], parts[1], None, True
|
|
74
|
+
field_path = ".".join(parts[2:]) or None
|
|
75
|
+
return parts[0], parts[1], field_path, True
|
|
76
|
+
|
|
77
|
+
if len(parts) >= 3:
|
|
78
|
+
full_qualification = ".".join(parts[:-1]).lower()
|
|
79
|
+
bare_table = parts[-2].lower()
|
|
80
|
+
if full_qualification in known_relations or bare_table in known_relations:
|
|
81
|
+
return parts[-2], parts[-1], None, True
|
|
82
|
+
|
|
83
|
+
# Unknown leading identifier: in warehouse SQL the likelier read is a
|
|
84
|
+
# struct root column (ecommerce.total_item_quantity on the sole FROM
|
|
85
|
+
# table; protopayload.auth.email inside a CTE). Return it unanchored;
|
|
86
|
+
# the caller anchors to the sole relation or downgrades trust. Never a
|
|
87
|
+
# confident phantom table.
|
|
88
|
+
field_path = ".".join(parts[1:]) or None
|
|
89
|
+
return "", parts[0], field_path, False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def table_function_kind(table) -> str | None:
|
|
93
|
+
"""Classify a Table wrapping a function call.
|
|
94
|
+
|
|
95
|
+
"srf": a set-returning function over COLUMN arguments (CROSS JOIN
|
|
96
|
+
jsonb_array_elements_text(roles) AS role) whose alias is an expansion of
|
|
97
|
+
those columns, never a relation. "relation": an opaque table function
|
|
98
|
+
over literals (read_parquet('f.parquet') rp), a real unknown-schema
|
|
99
|
+
relation that must keep blocking unique-ownership claims (review
|
|
100
|
+
of cycle 8). None: not a function table."""
|
|
101
|
+
func = table.this if isinstance(table, exp.Table) else None
|
|
102
|
+
if not isinstance(func, exp.Func):
|
|
103
|
+
return None
|
|
104
|
+
args = list(getattr(func, "expressions", None) or [])
|
|
105
|
+
if not args and isinstance(func.args.get("this"), exp.Expression):
|
|
106
|
+
args = [func.args["this"]]
|
|
107
|
+
return (
|
|
108
|
+
"srf"
|
|
109
|
+
if any(a.find(exp.Column) is not None for a in args if isinstance(a, exp.Expression))
|
|
110
|
+
else "relation"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def table_function_relation_name(table) -> str:
|
|
115
|
+
"""The written qualified name of an opaque table-function relation.
|
|
116
|
+
|
|
117
|
+
sys.fn_xe_file_target_read_file(...) is a catalog object whose output
|
|
118
|
+
columns exist independent of its arguments, so lineage cites the
|
|
119
|
+
function's own name instead of surfacing the query-local alias as a
|
|
120
|
+
relation (sqlserver_kit DarkQueries, holdout round 8). A bare function
|
|
121
|
+
like read_parquet('f.parquet') has no catalog identity (the file does);
|
|
122
|
+
'' keeps the alias-named relation for those."""
|
|
123
|
+
func = table.this if isinstance(table, exp.Table) else None
|
|
124
|
+
if not isinstance(func, exp.Anonymous) or not func.name:
|
|
125
|
+
return ""
|
|
126
|
+
qualifiers = [p for p in (table.catalog, table.db) if p and "{{" not in p]
|
|
127
|
+
if not qualifiers:
|
|
128
|
+
return ""
|
|
129
|
+
return ".".join([*qualifiers, func.name])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def colliding_function_relations(
|
|
133
|
+
parsed, real_tables: set[str], function_relations: set[str] | None = None
|
|
134
|
+
) -> dict[str, str]:
|
|
135
|
+
"""Written name (lowered) -> degraded citation, for every opaque
|
|
136
|
+
table-function whose written name could bind a project relation.
|
|
137
|
+
|
|
138
|
+
PostgreSQL separates functions from relations by kind, so a function
|
|
139
|
+
analytics.events() may coexist with a relation analytics.events; a
|
|
140
|
+
citation by the shared spelling would confidently bind a relation the
|
|
141
|
+
query never reads. Such a name degrades to the query-local alias, the
|
|
142
|
+
unbindable citation these functions had before written names ('' when
|
|
143
|
+
the alias itself collides). Cycle-9 review, F2.
|
|
144
|
+
|
|
145
|
+
function_relations exempts names whose colliding relation IS the
|
|
146
|
+
function's own minted model (a CREATE FUNCTION traced by the TVF
|
|
147
|
+
path): there the call site should bind confidently, not degrade
|
|
148
|
+
(cycle-13 review, F11)."""
|
|
149
|
+
lowered = {t.lower() for t in real_tables}
|
|
150
|
+
exempt = {f.lower() for f in (function_relations or ())}
|
|
151
|
+
|
|
152
|
+
def _collides(name: str) -> bool:
|
|
153
|
+
low = name.lower()
|
|
154
|
+
return low in lowered or low.split(".")[-1] in lowered
|
|
155
|
+
|
|
156
|
+
out: dict[str, str] = {}
|
|
157
|
+
for table in parsed.find_all(exp.Table):
|
|
158
|
+
if table_function_kind(table) != "relation":
|
|
159
|
+
continue
|
|
160
|
+
written = table_function_relation_name(table)
|
|
161
|
+
if not written or written.lower() in exempt or not _collides(written):
|
|
162
|
+
continue
|
|
163
|
+
alias = table.alias or ""
|
|
164
|
+
out[written.lower()] = "" if alias and _collides(alias) else alias
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def temp_marked_name(node) -> str:
|
|
169
|
+
"""An identifier's spelling WITH its T-SQL temp marker.
|
|
170
|
+
|
|
171
|
+
sqlglot parses #stats_agg into Identifier(temporary=True) whose .name is
|
|
172
|
+
the bare "stats_agg". The prefix is namespace identity, not decoration
|
|
173
|
+
(#x and x are different tables, temps are connection-scoped): dropping it
|
|
174
|
+
let sp_Blitz's #dm_exec_query_stats mirror eclipse every repo-wide read
|
|
175
|
+
of the real sys.dm_exec_query_stats DMV (holdout round 6)."""
|
|
176
|
+
ident = node.this if isinstance(node, exp.Table) else node
|
|
177
|
+
name = node.name
|
|
178
|
+
if isinstance(ident, exp.Parameter):
|
|
179
|
+
# @tablevar: procedure-local scratch; the spelling keeps the marker
|
|
180
|
+
# so it can never eclipse (or pose as) a real relation
|
|
181
|
+
return f"@{name}" if name and not name.startswith("@") else name
|
|
182
|
+
if isinstance(ident, exp.Identifier) and name and not name.startswith("#"):
|
|
183
|
+
if ident.args.get("temporary"):
|
|
184
|
+
return f"#{name}"
|
|
185
|
+
if ident.args.get("global_"):
|
|
186
|
+
return f"##{name}"
|
|
187
|
+
return name
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def qualified_table_name(table: exp.Table) -> str:
|
|
191
|
+
"""The relation's dotted name, with unrendered jinja placeholders dropped
|
|
192
|
+
from the QUALIFIER parts only.
|
|
193
|
+
|
|
194
|
+
A raw-SQL repo writing CREATE TABLE `{{reporting_db}}`.x deploys x into a
|
|
195
|
+
configured database; the placeholder is deploy config, not identity, and
|
|
196
|
+
keeping it made even the bare spelling unresolvable (patentsview, holdout
|
|
197
|
+
round 5). Partial spellings like `db_{{ env }}` are config too. The table
|
|
198
|
+
part itself is never dropped: stripping schema.`{{ params.table }}` down
|
|
199
|
+
to `schema` fabricated a relation named after the schema (review of
|
|
200
|
+
PR #28); a templated table name stays as written and stays unresolved.
|
|
201
|
+
|
|
202
|
+
When the table part ITSELF is templated, dropping qualifiers gains no
|
|
203
|
+
resolvable spelling; the whole relation round-trips as written
|
|
204
|
+
({{params.dataset}}.blocks{{params.postfix}}, polygon_etl, round 11).
|
|
205
|
+
In-chain dots come back from the preprocess encoding here.
|
|
206
|
+
|
|
207
|
+
A qualifier that is a whole DOTTED chain ({{params.dataset_name_raw}},
|
|
208
|
+
the sentinel-encoded shape) is the round-trip convention's spelling of
|
|
209
|
+
an unrenderable relation path, not deploy config; dropping it emitted
|
|
210
|
+
bare `blocks` that resolved into the model itself and vanished
|
|
211
|
+
(bitcoin_etl, round 12). Single-name and partial placeholders keep the
|
|
212
|
+
round-5 drop."""
|
|
213
|
+
raw = [p for p in (table.catalog, table.db, temp_marked_name(table)) if p]
|
|
214
|
+
if not raw:
|
|
215
|
+
return ""
|
|
216
|
+
parts = [restore_jinja_dots(p) for p in raw]
|
|
217
|
+
if "{{" in parts[-1]:
|
|
218
|
+
return ".".join(parts)
|
|
219
|
+
qualifiers = [
|
|
220
|
+
restored
|
|
221
|
+
for encoded, restored in zip(raw[:-1], parts[:-1], strict=True)
|
|
222
|
+
if "{{" not in encoded or JINJA_DOT_SENTINEL in encoded
|
|
223
|
+
]
|
|
224
|
+
return ".".join([*qualifiers, parts[-1]])
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def qualifier_is_quoted(node: exp.Expression) -> bool:
|
|
228
|
+
"""True when the node's table qualifier was written quoted.
|
|
229
|
+
|
|
230
|
+
Postgres treats "Q" and q as different identifiers; a quoted reference
|
|
231
|
+
may only match an alias exactly, while an unquoted one folds case
|
|
232
|
+
(the cycle-6 review)."""
|
|
233
|
+
ident = node.args.get("table")
|
|
234
|
+
return bool(getattr(ident, "quoted", False))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def alias_is_foldable(aliased: exp.Expression) -> bool:
|
|
238
|
+
"""True when the node's alias can be matched case-insensitively: written
|
|
239
|
+
unquoted, or quoted but already lowercase."""
|
|
240
|
+
ident = getattr(aliased.args.get("alias"), "this", None)
|
|
241
|
+
alias = getattr(aliased, "alias", "") or ""
|
|
242
|
+
return not getattr(ident, "quoted", False) or alias == alias.lower()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def owning_select(node: exp.Expression) -> exp.Expression | None:
|
|
246
|
+
"""The nearest enclosing SELECT, or None at the statement root."""
|
|
247
|
+
parent = node.parent
|
|
248
|
+
while parent is not None and not isinstance(parent, exp.Select):
|
|
249
|
+
parent = parent.parent
|
|
250
|
+
return parent
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def nested_scope_sole_table(col: exp.Column, outer: exp.Expression) -> str | None:
|
|
254
|
+
"""For a column inside a subselect below outer: the one relation its own
|
|
255
|
+
FROM names, "" when its scope has none or several, None when the column
|
|
256
|
+
belongs to outer itself (or has no FROM, i.e. is correlated to outer).
|
|
257
|
+
|
|
258
|
+
An unqualified column resolves in ITS select's scope. Anchoring
|
|
259
|
+
`(select max(payload) from source_rows s where ...)` to the outer sole
|
|
260
|
+
table claimed the outer table wrote payload (the review of PR #28)."""
|
|
261
|
+
owner = owning_select(col)
|
|
262
|
+
if owner is None or owner is outer:
|
|
263
|
+
return None
|
|
264
|
+
from_clause = owner.args.get("from_") or owner.args.get("from")
|
|
265
|
+
if from_clause is None:
|
|
266
|
+
return None
|
|
267
|
+
tables = [t for t in from_clause.find_all(exp.Table) if owning_select(t) is owner]
|
|
268
|
+
for join in owner.args.get("joins") or []:
|
|
269
|
+
if isinstance(join.this, exp.Table):
|
|
270
|
+
tables.append(join.this)
|
|
271
|
+
if len(tables) != 1:
|
|
272
|
+
return ""
|
|
273
|
+
return qualified_table_name(tables[0])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def unwrap_select(node: exp.Expression | None) -> exp.Expression | None:
|
|
277
|
+
"""Peel Subquery/Paren wrappers off a CTE or derived-table body.
|
|
278
|
+
|
|
279
|
+
dbt macros that emit '(select ...)' produce WITH c AS ((select ...)):
|
|
280
|
+
the same derivation one wrapper deeper. Every consumer that guards on
|
|
281
|
+
isinstance(body, Select) silently dropped the whole CTE for that shape
|
|
282
|
+
(make-open-data, holdout round 2)."""
|
|
283
|
+
while isinstance(node, (exp.Subquery, exp.Paren)):
|
|
284
|
+
node = node.this
|
|
285
|
+
return node
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def under_subquery_where(col: exp.Expression, root: exp.Expression) -> bool:
|
|
289
|
+
"""True when col sits in a nested SELECT's WHERE, JOIN ON, or GROUP BY
|
|
290
|
+
below root.
|
|
291
|
+
|
|
292
|
+
Such a column selects, matches, or groups rows inside the subquery; it
|
|
293
|
+
never feeds the produced value, so it must not surface as a value
|
|
294
|
+
source. The exception is EXISTS: there the WHERE is the value, so it
|
|
295
|
+
stays.
|
|
296
|
+
"""
|
|
297
|
+
prev: exp.Expression = col
|
|
298
|
+
node = col.parent
|
|
299
|
+
while node is not None and node is not root:
|
|
300
|
+
if isinstance(node, exp.Group) and isinstance(node.parent, exp.Select):
|
|
301
|
+
return True
|
|
302
|
+
if isinstance(node, exp.Where) and isinstance(node.parent, exp.Select):
|
|
303
|
+
outer = node.parent.parent
|
|
304
|
+
while isinstance(outer, (exp.Subquery, exp.Paren)):
|
|
305
|
+
outer = outer.parent
|
|
306
|
+
if not isinstance(outer, exp.Exists):
|
|
307
|
+
return True
|
|
308
|
+
if isinstance(node, exp.Join) and prev is node.args.get("on"):
|
|
309
|
+
return True
|
|
310
|
+
prev = node
|
|
311
|
+
node = node.parent
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def in_window_ordering(col: exp.Expression, root: exp.Expression) -> bool:
|
|
316
|
+
"""True when col sits in a window's PARTITION BY or ORDER BY below root.
|
|
317
|
+
|
|
318
|
+
`lag(mrr) over (partition by customer_id order by date_month)` produces a
|
|
319
|
+
value made of mrr alone. The other two decide which rows the frame spans
|
|
320
|
+
and in what order, which is the same C_ref role WHERE and JOIN ON play, so
|
|
321
|
+
they are collected as window keys instead of value sources. A column inside
|
|
322
|
+
the function's own arguments is untouched.
|
|
323
|
+
"""
|
|
324
|
+
prev: exp.Expression = col
|
|
325
|
+
node = col.parent
|
|
326
|
+
while node is not None:
|
|
327
|
+
if isinstance(node, exp.Window):
|
|
328
|
+
if prev is node.args.get("order"):
|
|
329
|
+
return True
|
|
330
|
+
if any(prev is part for part in (node.args.get("partition_by") or [])):
|
|
331
|
+
return True
|
|
332
|
+
if node is root:
|
|
333
|
+
# root itself can be the window (`select lag(x) over (...)` with no
|
|
334
|
+
# wrapping expression), so it is tested before the walk stops
|
|
335
|
+
break
|
|
336
|
+
prev = node
|
|
337
|
+
node = node.parent
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def in_selector_argument(col: exp.Expression, root: exp.Expression) -> bool:
|
|
342
|
+
"""True when col sits in the selector argument of an arg_max/arg_min below root.
|
|
343
|
+
|
|
344
|
+
`arg_max(stage_key, stage_rank)` produces a value made of stage_key alone.
|
|
345
|
+
The selector only picks the row, the same C_ref role a window's ORDER BY
|
|
346
|
+
plays, so it is collected as a window key instead of a value source. A
|
|
347
|
+
column inside the value argument is untouched, even nested. duckdb's
|
|
348
|
+
3-arg form puts the result count in a third argument; a column there
|
|
349
|
+
also frames the result rather than feeding it.
|
|
350
|
+
"""
|
|
351
|
+
prev: exp.Expression = col
|
|
352
|
+
node = col.parent
|
|
353
|
+
while node is not None:
|
|
354
|
+
if isinstance(node, (exp.ArgMax, exp.ArgMin)) and prev is not node.this:
|
|
355
|
+
return True
|
|
356
|
+
if node is root:
|
|
357
|
+
break
|
|
358
|
+
prev = node
|
|
359
|
+
node = node.parent
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def register_subquery_from_aliases(
|
|
364
|
+
selects: list[exp.Expression], alias_map: dict[str, str]
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Add FROM aliases of selects nested inside select items to alias_map.
|
|
367
|
+
|
|
368
|
+
The outer FROM sweep never sees a scalar subquery's own FROM, so its
|
|
369
|
+
projected column (f.title) would otherwise read as a struct root on a
|
|
370
|
+
phantom relation. Outer aliases win on collision. Qualifiers are kept in
|
|
371
|
+
the value for the same reason as scope.register_table: stripping them
|
|
372
|
+
lets a model named after the table it wraps swallow the reference.
|
|
373
|
+
"""
|
|
374
|
+
for item in selects:
|
|
375
|
+
for sub in item.find_all(exp.Select):
|
|
376
|
+
sub_from = sub.args.get("from_") or sub.args.get("from")
|
|
377
|
+
if sub_from is None:
|
|
378
|
+
continue
|
|
379
|
+
for table in sub_from.find_all(exp.Table):
|
|
380
|
+
if table.name:
|
|
381
|
+
alias_map.setdefault(table.alias or table.name, qualified_table_name(table))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def extract_array_source(source_expr: exp.Expression) -> dict[str, Any] | None:
|
|
385
|
+
"""Extract source table and column from array expansion expressions.
|
|
386
|
+
|
|
387
|
+
Handles patterns like:
|
|
388
|
+
- FLATTEN(orders.items) -> {source_table: "orders", source_column: "items"}
|
|
389
|
+
- UNNEST(arr) -> {source_table: "", source_column: "arr"}
|
|
390
|
+
- FLATTEN(input => t.data:items) -> {source_table: "t", source_column: "data", json_path: "items"}
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Dict with source_table, source_column, and optional json_path,
|
|
394
|
+
or None if cannot extract source.
|
|
395
|
+
"""
|
|
396
|
+
if source_expr is None:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
if isinstance(source_expr, exp.Column):
|
|
400
|
+
info = {
|
|
401
|
+
"source_table": source_expr.table or "",
|
|
402
|
+
"source_column": source_expr.name or "",
|
|
403
|
+
}
|
|
404
|
+
parts = [p.name for p in source_expr.parts if getattr(p, "name", "")]
|
|
405
|
+
if len(parts) > 2:
|
|
406
|
+
# a.b.c is a struct path, not proof that b is a table: which
|
|
407
|
+
# identifier is the root column depends on what a resolves to,
|
|
408
|
+
# and only the scope knows its aliases (crux
|
|
409
|
+
# UNNEST(first_contentful_paint.histogram.bin), holdout round 4)
|
|
410
|
+
info["struct_parts"] = parts
|
|
411
|
+
return info
|
|
412
|
+
|
|
413
|
+
# data:items (snowflake) and data['items'] both parse as Bracket/Dot
|
|
414
|
+
if isinstance(source_expr, (exp.Bracket, exp.Dot)):
|
|
415
|
+
base = source_expr
|
|
416
|
+
json_path_parts = []
|
|
417
|
+
while isinstance(base, (exp.Bracket, exp.Dot)):
|
|
418
|
+
if isinstance(base, exp.Bracket) and base.expressions:
|
|
419
|
+
key = base.expressions[0]
|
|
420
|
+
if hasattr(key, "this"):
|
|
421
|
+
json_path_parts.insert(0, str(key.this))
|
|
422
|
+
elif isinstance(base, exp.Dot) and hasattr(base, "expression"):
|
|
423
|
+
json_path_parts.insert(
|
|
424
|
+
0,
|
|
425
|
+
base.expression.name
|
|
426
|
+
if hasattr(base.expression, "name")
|
|
427
|
+
else str(base.expression),
|
|
428
|
+
)
|
|
429
|
+
base = base.this
|
|
430
|
+
|
|
431
|
+
if isinstance(base, exp.Column):
|
|
432
|
+
result = {
|
|
433
|
+
"source_table": base.table or "",
|
|
434
|
+
"source_column": base.name or "",
|
|
435
|
+
}
|
|
436
|
+
if json_path_parts:
|
|
437
|
+
result["json_path"] = ".".join(json_path_parts)
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
if isinstance(source_expr, exp.JSONExtract):
|
|
441
|
+
base_col = source_expr.this
|
|
442
|
+
if isinstance(base_col, exp.Column):
|
|
443
|
+
result = {
|
|
444
|
+
"source_table": base_col.table or "",
|
|
445
|
+
"source_column": base_col.name or "",
|
|
446
|
+
}
|
|
447
|
+
if source_expr.expression:
|
|
448
|
+
path_expr = source_expr.expression
|
|
449
|
+
if hasattr(path_expr, "this"):
|
|
450
|
+
result["json_path"] = str(path_expr.this).strip("$.")
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
for json_type in (exp.JSONExtract, exp.JSONExtractScalar):
|
|
454
|
+
if (
|
|
455
|
+
isinstance(source_expr, json_type)
|
|
456
|
+
and hasattr(source_expr, "this")
|
|
457
|
+
and isinstance(source_expr.this, exp.Column)
|
|
458
|
+
):
|
|
459
|
+
col = source_expr.this
|
|
460
|
+
return {
|
|
461
|
+
"source_table": col.table or "",
|
|
462
|
+
"source_column": col.name or "",
|
|
463
|
+
"json_path": str(source_expr.expression)
|
|
464
|
+
if hasattr(source_expr, "expression")
|
|
465
|
+
else "",
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# An ARRAY of named STRUCTs is a melt: each element field is built from
|
|
469
|
+
# exactly the expressions aliased AS that field, so a read of
|
|
470
|
+
# element.<field> must resolve to those and never to sibling fields
|
|
471
|
+
# (rj_smtr integracao, round 11: 55 extra sources per case). The
|
|
472
|
+
# every-column fallback below stays for whole-element reads. Nested
|
|
473
|
+
# struct fields register under their dotted path too (nested.x), and a
|
|
474
|
+
# Cast(Struct) element unwraps to its struct (cycle-12, F15/F18).
|
|
475
|
+
if isinstance(source_expr, exp.Array) and source_expr.expressions:
|
|
476
|
+
fields: dict[str, list[dict[str, str]]] = {}
|
|
477
|
+
all_structs = True
|
|
478
|
+
|
|
479
|
+
def collect_fields(prefix: str, struct: exp.Struct) -> None:
|
|
480
|
+
for item in struct.expressions:
|
|
481
|
+
if isinstance(item, exp.PropertyEQ):
|
|
482
|
+
fname, value = item.name, item.expression
|
|
483
|
+
elif isinstance(item, exp.Alias):
|
|
484
|
+
fname, value = item.alias, item.this
|
|
485
|
+
else:
|
|
486
|
+
continue
|
|
487
|
+
if not fname or value is None:
|
|
488
|
+
continue
|
|
489
|
+
while isinstance(value, exp.Cast):
|
|
490
|
+
value = value.this
|
|
491
|
+
path = f"{prefix}{fname}".lower()
|
|
492
|
+
bucket = fields.setdefault(path, [])
|
|
493
|
+
for col in value.find_all(exp.Column):
|
|
494
|
+
src = {"source_table": col.table or "", "source_column": col.name or ""}
|
|
495
|
+
if src["source_column"] and src not in bucket:
|
|
496
|
+
bucket.append(src)
|
|
497
|
+
if isinstance(value, exp.Struct):
|
|
498
|
+
collect_fields(f"{path}.", value)
|
|
499
|
+
|
|
500
|
+
for element in source_expr.expressions:
|
|
501
|
+
while isinstance(element, exp.Cast):
|
|
502
|
+
element = element.this
|
|
503
|
+
if not isinstance(element, exp.Struct):
|
|
504
|
+
all_structs = False
|
|
505
|
+
break
|
|
506
|
+
collect_fields("", element)
|
|
507
|
+
if all_structs and fields:
|
|
508
|
+
result = _array_fallback_sources(source_expr)
|
|
509
|
+
if result is None:
|
|
510
|
+
# literal-only named structs: the alias still owns the field
|
|
511
|
+
# names, and registering nothing let element reads fabricate
|
|
512
|
+
# a struct guess on the outer relation (cycle-12, F17)
|
|
513
|
+
result = {"source_table": "", "source_column": ""}
|
|
514
|
+
result["field_sources"] = fields
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
# Fallback: a computed array expression (GENERATE_DATE_ARRAY(a, b), ...)
|
|
518
|
+
# contributes every column it reads; the first is the primary, the rest
|
|
519
|
+
# ride along as extra_columns.
|
|
520
|
+
return _array_fallback_sources(source_expr)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _array_fallback_sources(source_expr: exp.Expression) -> dict[str, Any] | None:
|
|
524
|
+
cols = list(source_expr.find_all(exp.Column))
|
|
525
|
+
if cols:
|
|
526
|
+
result = {
|
|
527
|
+
"source_table": cols[0].table or "",
|
|
528
|
+
"source_column": cols[0].name or "",
|
|
529
|
+
}
|
|
530
|
+
seen = {(result["source_table"], result["source_column"])}
|
|
531
|
+
extras = []
|
|
532
|
+
for col in cols[1:]:
|
|
533
|
+
key = (col.table or "", col.name or "")
|
|
534
|
+
if key[1] and key not in seen:
|
|
535
|
+
seen.add(key)
|
|
536
|
+
extras.append({"source_table": key[0], "source_column": key[1]})
|
|
537
|
+
if extras:
|
|
538
|
+
result["extra_columns"] = extras
|
|
539
|
+
return result
|
|
540
|
+
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def resolve_expansion_extras(
|
|
545
|
+
info: dict[str, Any],
|
|
546
|
+
alias_map: dict[str, str],
|
|
547
|
+
all_tables: list[str],
|
|
548
|
+
) -> list[dict[str, str]]:
|
|
549
|
+
"""Resolve extra_columns of a computed array expression through the
|
|
550
|
+
select's alias map, like the primary source column."""
|
|
551
|
+
extras = []
|
|
552
|
+
for extra in info.get("extra_columns", []):
|
|
553
|
+
src_table = extra.get("source_table", "")
|
|
554
|
+
resolved = alias_map.get(src_table, src_table)
|
|
555
|
+
if not resolved:
|
|
556
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
557
|
+
if len(real) == 1:
|
|
558
|
+
resolved = real[0]
|
|
559
|
+
extras.append({"source_table": resolved, "source_column": extra.get("source_column", "")})
|
|
560
|
+
return extras
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def resolve_field_sources(
|
|
564
|
+
info: dict[str, Any],
|
|
565
|
+
alias_map: dict[str, str],
|
|
566
|
+
all_tables: list[str],
|
|
567
|
+
) -> dict[str, list[dict[str, str]]] | None:
|
|
568
|
+
"""The array-of-structs field map with each source resolved through the
|
|
569
|
+
select's alias map, or None when the expansion carries no field map."""
|
|
570
|
+
fields = info.get("field_sources")
|
|
571
|
+
if not fields:
|
|
572
|
+
return None
|
|
573
|
+
return {
|
|
574
|
+
fname: resolve_expansion_extras({"extra_columns": srcs}, alias_map, all_tables)
|
|
575
|
+
for fname, srcs in fields.items()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def field_selective_reads(entry: dict[str, Any], field_name: str) -> list[dict[str, str]] | None:
|
|
580
|
+
"""Sources for a read of one named STRUCT field through an
|
|
581
|
+
array-of-structs expansion. None when the entry has no field map or the
|
|
582
|
+
read is not one of the declared field names (callers fall back to the
|
|
583
|
+
every-column behavior rather than guessing)."""
|
|
584
|
+
fields = entry.get("field_sources")
|
|
585
|
+
if not fields or not field_name:
|
|
586
|
+
return None
|
|
587
|
+
return fields.get(field_name.lower())
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def expansion_field_reads(
|
|
591
|
+
entry: dict[str, Any], col: exp.Column, column: str, field_path: str | None = None
|
|
592
|
+
) -> list[dict[str, str]] | None:
|
|
593
|
+
"""field_selective_reads by the resolved column name, or by the Dot
|
|
594
|
+
field when qualify() rewrote the element read into alias.element.field
|
|
595
|
+
(Column(_1.im) wrapped in Dot(data_transacao)).
|
|
596
|
+
|
|
597
|
+
A remaining field path is consumed first: im.nested.x looks up
|
|
598
|
+
nested.x before falling back to the whole nested subtree (cycle-12,
|
|
599
|
+
F15)."""
|
|
600
|
+
if field_path:
|
|
601
|
+
deep = field_selective_reads(entry, f"{column}.{field_path}")
|
|
602
|
+
if deep is not None:
|
|
603
|
+
return deep
|
|
604
|
+
selective = field_selective_reads(entry, column)
|
|
605
|
+
if selective is None:
|
|
606
|
+
parent = col.parent
|
|
607
|
+
if isinstance(parent, exp.Dot) and parent.this is col:
|
|
608
|
+
field = getattr(parent.expression, "name", "")
|
|
609
|
+
if field:
|
|
610
|
+
selective = field_selective_reads(entry, field)
|
|
611
|
+
return selective
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def collect_unnest_expansions(
|
|
615
|
+
select_node: exp.Expression,
|
|
616
|
+
alias_map: dict[str, str],
|
|
617
|
+
all_tables: list[str],
|
|
618
|
+
) -> dict[str, dict[str, Any]]:
|
|
619
|
+
"""UNNEST element names in this select's own FROM scope -> array root.
|
|
620
|
+
|
|
621
|
+
Keys are every name a reference could use for the expanded element: the
|
|
622
|
+
table alias, alias columns (including sqlglot's synthetic _0-style names
|
|
623
|
+
that qualify() invents), or '__anonymous_unnest__'. UNNESTs belonging to
|
|
624
|
+
nested subqueries are excluded; their scope is their own select.
|
|
625
|
+
"""
|
|
626
|
+
expansions: dict[str, dict[str, Any]] = {}
|
|
627
|
+
# a set-returning function in table position (CROSS JOIN
|
|
628
|
+
# jsonb_array_elements_text(roles) AS role, no LATERAL keyword) parses
|
|
629
|
+
# as Table(this=Func); its alias is an expansion of the argument, not a
|
|
630
|
+
# relation (kingfisher-summarize, holdout round 7)
|
|
631
|
+
for table in select_node.find_all(exp.Table):
|
|
632
|
+
if owning_select(table) is not select_node:
|
|
633
|
+
continue
|
|
634
|
+
if table_function_kind(table) != "srf":
|
|
635
|
+
continue
|
|
636
|
+
func = table.this
|
|
637
|
+
args = list(getattr(func, "expressions", None) or [])
|
|
638
|
+
if not args and isinstance(func.args.get("this"), exp.Expression):
|
|
639
|
+
args = [func.args["this"]]
|
|
640
|
+
if not args:
|
|
641
|
+
continue
|
|
642
|
+
info = extract_array_source(args[0])
|
|
643
|
+
if not info or not info.get("source_column"):
|
|
644
|
+
continue
|
|
645
|
+
src_table = info.get("source_table", "")
|
|
646
|
+
resolved = alias_map.get(src_table, src_table)
|
|
647
|
+
if not resolved:
|
|
648
|
+
real = [t for t in all_tables if t and not t.startswith("(")]
|
|
649
|
+
if len(real) == 1:
|
|
650
|
+
resolved = real[0]
|
|
651
|
+
entry: dict[str, Any] = {
|
|
652
|
+
"source_table": resolved,
|
|
653
|
+
"source_column": info["source_column"],
|
|
654
|
+
"expansion_type": "TABLE_FUNCTION",
|
|
655
|
+
}
|
|
656
|
+
# every argument counts: regexp_split_to_table(body, delimiter)
|
|
657
|
+
# reads both (the cycle-8 review)
|
|
658
|
+
extra_args = []
|
|
659
|
+
for arg in args[1:]:
|
|
660
|
+
extra_info = extract_array_source(arg)
|
|
661
|
+
if extra_info and extra_info.get("source_column"):
|
|
662
|
+
extra_args.append(
|
|
663
|
+
{
|
|
664
|
+
"source_table": extra_info.get("source_table", ""),
|
|
665
|
+
"source_column": extra_info["source_column"],
|
|
666
|
+
}
|
|
667
|
+
)
|
|
668
|
+
if extra_args:
|
|
669
|
+
entry["extra_columns"] = resolve_expansion_extras(
|
|
670
|
+
{"extra_columns": extra_args}, alias_map, all_tables
|
|
671
|
+
)
|
|
672
|
+
names: list[str] = []
|
|
673
|
+
table_alias = table.args.get("alias")
|
|
674
|
+
if table_alias is not None:
|
|
675
|
+
if table_alias.this is not None:
|
|
676
|
+
names.append(table_alias.this.name)
|
|
677
|
+
names.extend(c.name for c in table_alias.columns or [])
|
|
678
|
+
for name in names:
|
|
679
|
+
expansions.setdefault(name, entry)
|
|
680
|
+
# LATERAL FLATTEN/EXPLODE joined into this select (snowflake's
|
|
681
|
+
# `, lateral flatten(input => x) dep` parses as Lateral(Explode)):
|
|
682
|
+
# the statement-level scope registers it, but a CTE body's element
|
|
683
|
+
# reads went through this map only, so `dep.value` degraded to a
|
|
684
|
+
# struct guess on the sole relation and the flatten never composed
|
|
685
|
+
# with set-op branch merging (dbt_artifacts dim_dbt__lineage_edges,
|
|
686
|
+
# holdout round 8).
|
|
687
|
+
explode_types: tuple[type, ...] = (exp.Explode,)
|
|
688
|
+
for extra in ("ExplodeOuter", "Inline"):
|
|
689
|
+
if hasattr(exp, extra):
|
|
690
|
+
explode_types = (*explode_types, getattr(exp, extra))
|
|
691
|
+
for lateral in select_node.find_all(exp.Lateral):
|
|
692
|
+
if owning_select(lateral) is not select_node:
|
|
693
|
+
continue
|
|
694
|
+
inner = lateral.this
|
|
695
|
+
if not isinstance(inner, explode_types) or inner.this is None:
|
|
696
|
+
continue
|
|
697
|
+
info = extract_array_source(inner.this)
|
|
698
|
+
if not info or not info.get("source_column"):
|
|
699
|
+
continue
|
|
700
|
+
src_table = info.get("source_table", "")
|
|
701
|
+
prior = expansions.get(src_table) or (
|
|
702
|
+
expansions.get(info["source_column"]) if not src_table else None
|
|
703
|
+
)
|
|
704
|
+
if prior is not None and prior.get("source_column"):
|
|
705
|
+
# chained flatten: the input is an earlier expansion's element,
|
|
706
|
+
# so the true source is that expansion's own array root
|
|
707
|
+
# (cycle-9 review, F4)
|
|
708
|
+
path_bits = [p for p in (prior.get("json_path"), info.get("json_path")) if p]
|
|
709
|
+
entry: dict[str, Any] = {
|
|
710
|
+
"source_table": prior.get("source_table", ""),
|
|
711
|
+
"source_column": prior["source_column"],
|
|
712
|
+
"expansion_type": "FLATTEN",
|
|
713
|
+
}
|
|
714
|
+
if path_bits:
|
|
715
|
+
entry["json_path"] = ".".join(path_bits)
|
|
716
|
+
if prior.get("extra_columns"):
|
|
717
|
+
entry["extra_columns"] = [dict(e) for e in prior["extra_columns"]]
|
|
718
|
+
else:
|
|
719
|
+
resolved = alias_map.get(src_table, src_table)
|
|
720
|
+
if not resolved:
|
|
721
|
+
real = [t for t in all_tables if t and not t.startswith("(")]
|
|
722
|
+
if len(real) == 1:
|
|
723
|
+
resolved = real[0]
|
|
724
|
+
entry = {
|
|
725
|
+
"source_table": resolved,
|
|
726
|
+
"source_column": info["source_column"],
|
|
727
|
+
"expansion_type": "FLATTEN",
|
|
728
|
+
}
|
|
729
|
+
if info.get("json_path"):
|
|
730
|
+
entry["json_path"] = info["json_path"]
|
|
731
|
+
extras = resolve_expansion_extras(info, alias_map, all_tables)
|
|
732
|
+
if extras:
|
|
733
|
+
entry["extra_columns"] = extras
|
|
734
|
+
names: list[str] = []
|
|
735
|
+
alias_node = lateral.args.get("alias")
|
|
736
|
+
if alias_node is not None:
|
|
737
|
+
if alias_node.this is not None:
|
|
738
|
+
names.append(alias_node.this.name)
|
|
739
|
+
names.extend(c.name for c in alias_node.columns or [])
|
|
740
|
+
for name in names:
|
|
741
|
+
expansions.setdefault(name, entry)
|
|
742
|
+
for unnest in select_node.find_all(exp.Unnest):
|
|
743
|
+
if owning_select(unnest) is not select_node or not unnest.expressions:
|
|
744
|
+
continue
|
|
745
|
+
info = extract_array_source(unnest.expressions[0])
|
|
746
|
+
# a literal-only named-struct array reads no outer column but still
|
|
747
|
+
# owns its aliases; dropping it fabricated struct guesses (F17)
|
|
748
|
+
if not info or not (info.get("source_column") or info.get("field_sources")):
|
|
749
|
+
continue
|
|
750
|
+
src_table = info.get("source_table", "")
|
|
751
|
+
resolved = alias_map.get(src_table, src_table)
|
|
752
|
+
if not resolved and info.get("source_column"):
|
|
753
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
754
|
+
if len(real) == 1:
|
|
755
|
+
resolved = real[0]
|
|
756
|
+
entry: dict[str, Any] = {
|
|
757
|
+
"source_table": resolved,
|
|
758
|
+
"source_column": info["source_column"],
|
|
759
|
+
"expansion_type": "UNNEST",
|
|
760
|
+
}
|
|
761
|
+
extras = resolve_expansion_extras(info, alias_map, all_tables)
|
|
762
|
+
if extras:
|
|
763
|
+
entry["extra_columns"] = extras
|
|
764
|
+
field_map = resolve_field_sources(info, alias_map, all_tables)
|
|
765
|
+
if field_map:
|
|
766
|
+
entry["field_sources"] = field_map
|
|
767
|
+
names: list[str] = []
|
|
768
|
+
table_alias = unnest.args.get("alias")
|
|
769
|
+
if table_alias is not None:
|
|
770
|
+
if table_alias.this is not None:
|
|
771
|
+
names.append(table_alias.this.name)
|
|
772
|
+
names.extend(c.name for c in table_alias.columns or [])
|
|
773
|
+
if names:
|
|
774
|
+
for name in names:
|
|
775
|
+
expansions.setdefault(name, entry)
|
|
776
|
+
else:
|
|
777
|
+
expansions.setdefault("__anonymous_unnest__", entry)
|
|
778
|
+
return expansions
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def left_join_anchor(select_node: exp.Expression) -> str:
|
|
782
|
+
"""The FROM table of a lookup-shaped scope: exactly one FROM table and
|
|
783
|
+
every join this select owns is a LEFT JOIN onto it, so unqualified reads
|
|
784
|
+
anchor to the table the joins hang off. Returns '' when the shape does
|
|
785
|
+
not hold."""
|
|
786
|
+
from_clause = select_node.args.get("from_") or select_node.args.get("from")
|
|
787
|
+
if from_clause is None or not isinstance(from_clause.this, exp.Table):
|
|
788
|
+
return ""
|
|
789
|
+
joins = [j for j in select_node.find_all(exp.Join) if owning_select(j) is select_node]
|
|
790
|
+
if joins and all(j.side == "LEFT" for j in joins):
|
|
791
|
+
return from_clause.this.name or ""
|
|
792
|
+
return ""
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def scoped_unnest_entry(
|
|
796
|
+
col: exp.Column,
|
|
797
|
+
root: exp.Expression,
|
|
798
|
+
alias_map: dict[str, str],
|
|
799
|
+
all_tables: list[str],
|
|
800
|
+
) -> dict[str, Any] | None:
|
|
801
|
+
"""The UNNEST owning the column's own subquery scope below root,
|
|
802
|
+
innermost first. A select-item subquery's UNNEST anchors its own
|
|
803
|
+
element reads (including qualify()'s synthetic _0 alias) but never
|
|
804
|
+
sibling items."""
|
|
805
|
+
node = col.parent
|
|
806
|
+
while node is not None and node is not root:
|
|
807
|
+
if isinstance(node, exp.Select):
|
|
808
|
+
scoped = collect_unnest_expansions(node, alias_map, all_tables)
|
|
809
|
+
if scoped:
|
|
810
|
+
lowered = {k.lower(): v for k, v in scoped.items()}
|
|
811
|
+
leading = column_parts(col)[0].lower()
|
|
812
|
+
return (
|
|
813
|
+
lowered.get(leading)
|
|
814
|
+
or lowered.get("__anonymous_unnest__")
|
|
815
|
+
or next(iter(scoped.values()))
|
|
816
|
+
)
|
|
817
|
+
node = node.parent
|
|
818
|
+
return None
|