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
ripple/engine/scope.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""FROM-scope registration for a single statement.
|
|
2
|
+
|
|
3
|
+
Builds, for the select that owns the FROM clause:
|
|
4
|
+
- alias_map: alias -> actual table name
|
|
5
|
+
- all_tables: every relation the scope reads
|
|
6
|
+
- array_expansion_sources: FLATTEN/UNNEST/EXPLODE alias -> source array info
|
|
7
|
+
|
|
8
|
+
Covers plain FROM tables, JOINed tables, LATERAL FLATTEN/EXPLODE (including
|
|
9
|
+
Spark's LATERAL VIEW slot), BigQuery UNNEST, and the JOIN-side variants
|
|
10
|
+
(joined subqueries, CROSS JOIN LATERAL, CROSS JOIN UNNEST AS t(item)).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from sqlglot import exp
|
|
14
|
+
|
|
15
|
+
from ripple.engine.column_ref import (
|
|
16
|
+
extract_array_source as _extract_array_source,
|
|
17
|
+
)
|
|
18
|
+
from ripple.engine.column_ref import (
|
|
19
|
+
owning_select,
|
|
20
|
+
qualified_table_name,
|
|
21
|
+
resolve_expansion_extras,
|
|
22
|
+
resolve_field_sources,
|
|
23
|
+
table_function_relation_name,
|
|
24
|
+
)
|
|
25
|
+
from ripple.engine.column_ref import table_function_kind as _table_function_kind
|
|
26
|
+
|
|
27
|
+
# TODO: the UNNEST sweep below near-duplicates column_ref.collect_unnest_expansions but also mutates alias_map/all_tables; merging needs judgment.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_table(table: "exp.Table", alias_map: dict[str, str], all_tables: list[str]) -> None:
|
|
31
|
+
"""Enter a FROM/JOIN table into scope under every name a column reference
|
|
32
|
+
could anchor to, while keeping the value the SQL actually wrote.
|
|
33
|
+
|
|
34
|
+
A Table wrapping a function call (CROSS JOIN jsonb_array_elements_text(x)
|
|
35
|
+
AS e) is a set-returning function, not a relation: registering it
|
|
36
|
+
polluted the scope with an empty-named table and broke unqualified
|
|
37
|
+
resolution (kingfisher-summarize, holdout round 7). Those are handled
|
|
38
|
+
by the expansion machinery.
|
|
39
|
+
|
|
40
|
+
The value keeps its qualifiers (pg_catalog.pg_attribute, not pg_attribute):
|
|
41
|
+
stripping them here is what let a model named after the table it wraps
|
|
42
|
+
swallow the external relation into itself and lose all lineage
|
|
43
|
+
(dbt-labs/redshift, holdout round 2). The bare name stays a lookup KEY,
|
|
44
|
+
because column references anchor by the name part (pg_attribute.attname)
|
|
45
|
+
even when the FROM clause is qualified.
|
|
46
|
+
"""
|
|
47
|
+
kind = _table_function_kind(table)
|
|
48
|
+
if kind == "srf":
|
|
49
|
+
return # its alias is an expansion, handled by the expansion machinery
|
|
50
|
+
if kind == "relation":
|
|
51
|
+
# opaque table function: a real unknown-schema relation. A
|
|
52
|
+
# qualified one (sys.fn_xe_file_target_read_file) is a catalog
|
|
53
|
+
# object cited by its written name; a bare one (read_parquet) is
|
|
54
|
+
# known only by its alias. Either way it must keep blocking
|
|
55
|
+
# unique-ownership claims (the cycle-8 review)
|
|
56
|
+
written = table_function_relation_name(table)
|
|
57
|
+
name = written or table.alias
|
|
58
|
+
if not name:
|
|
59
|
+
return
|
|
60
|
+
if table.alias:
|
|
61
|
+
alias_map.setdefault(table.alias, name)
|
|
62
|
+
if written:
|
|
63
|
+
alias_map.setdefault(written, written)
|
|
64
|
+
all_tables.append(name)
|
|
65
|
+
return
|
|
66
|
+
qualified = qualified_table_name(table)
|
|
67
|
+
if table.alias:
|
|
68
|
+
alias_map[table.alias] = qualified
|
|
69
|
+
else:
|
|
70
|
+
claimed = alias_map.get(table.name)
|
|
71
|
+
if claimed is not None and claimed not in (qualified, table.name):
|
|
72
|
+
# two qualified relations share this bare name in one scope
|
|
73
|
+
# (raw.orders JOIN staging.orders): neither may own the bare key,
|
|
74
|
+
# so it degrades to the ambiguous merged name and resolves at
|
|
75
|
+
# review trust instead of silently binding the later table
|
|
76
|
+
alias_map[table.name] = table.name
|
|
77
|
+
else:
|
|
78
|
+
alias_map[table.name] = qualified
|
|
79
|
+
if qualified != table.name:
|
|
80
|
+
alias_map[qualified] = qualified
|
|
81
|
+
all_tables.append(qualified)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fold_struct_parts(source_info: dict, alias_map: dict[str, str]) -> dict:
|
|
85
|
+
"""Decide which identifiers of a dotted expansion argument name the table.
|
|
86
|
+
|
|
87
|
+
extract_array_source sees a.b.c.d without scope knowledge. The longest
|
|
88
|
+
dotted prefix that names a relation in THIS scope (case-insensitively,
|
|
89
|
+
since SQL identifiers fold) is the table; the next part is the column
|
|
90
|
+
and the remainder is the path inside it. When no prefix matches, the
|
|
91
|
+
first part is the root column; reading it any other way invented a
|
|
92
|
+
`histogram.bin` relation out of
|
|
93
|
+
UNNEST(first_contentful_paint.histogram.bin) (crux, holdout round 4),
|
|
94
|
+
and a fully qualified `proj.ds.events`.items prefix must fold to items,
|
|
95
|
+
not to a phantom `proj` column (review).
|
|
96
|
+
"""
|
|
97
|
+
parts = source_info.get("struct_parts")
|
|
98
|
+
if not parts:
|
|
99
|
+
return source_info
|
|
100
|
+
lower_keys = {k.lower(): k for k in alias_map}
|
|
101
|
+
folded = None
|
|
102
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
103
|
+
prefix = ".".join(parts[:i]).lower()
|
|
104
|
+
if prefix in lower_keys:
|
|
105
|
+
folded = {"source_table": lower_keys[prefix], "source_column": parts[i]}
|
|
106
|
+
rest = parts[i + 1 :]
|
|
107
|
+
break
|
|
108
|
+
if folded is None:
|
|
109
|
+
folded = {"source_table": "", "source_column": parts[0]}
|
|
110
|
+
rest = parts[1:]
|
|
111
|
+
if rest:
|
|
112
|
+
folded["json_path"] = ".".join(rest)
|
|
113
|
+
return folded
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _register_function_expansion(
|
|
117
|
+
lateral_alias: str,
|
|
118
|
+
func_args: "list[exp.Expression]",
|
|
119
|
+
alias_map: dict[str, str],
|
|
120
|
+
all_tables: list[str],
|
|
121
|
+
array_expansion_sources: dict[str, dict[str, str]],
|
|
122
|
+
alias_columns: list[str] | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""A LATERAL over a set-returning function (jsonb_array_elements,
|
|
125
|
+
regexp_split_to_table, ...) expands its argument columns; every
|
|
126
|
+
reference to the alias (or to its declared output columns) derives
|
|
127
|
+
from them. FAC's findings_text scored three empty cases because this
|
|
128
|
+
shape registered nothing (holdout round 4). the review added: chain
|
|
129
|
+
through a prior expansion when the argument is itself an expansion
|
|
130
|
+
alias, keep EVERY argument (regexp_split_to_table reads two), and
|
|
131
|
+
register AS f(col) output-column names."""
|
|
132
|
+
if not lateral_alias or not func_args:
|
|
133
|
+
return
|
|
134
|
+
source_info = _extract_array_source(func_args[0])
|
|
135
|
+
if not source_info:
|
|
136
|
+
return
|
|
137
|
+
source_info = _fold_struct_parts(source_info, alias_map)
|
|
138
|
+
src_table = source_info.get("source_table", "")
|
|
139
|
+
src_col = source_info.get("source_column", "")
|
|
140
|
+
prior = array_expansion_sources.get(src_table) or (
|
|
141
|
+
array_expansion_sources.get(src_col) if not src_table else None
|
|
142
|
+
)
|
|
143
|
+
if prior is not None:
|
|
144
|
+
# nested lateral: the argument is a previous expansion's output, so
|
|
145
|
+
# the true source is that expansion's own source
|
|
146
|
+
path_bits = [p for p in (prior.get("json_path"), source_info.get("json_path")) if p]
|
|
147
|
+
entry = {
|
|
148
|
+
"source_table": prior.get("source_table", ""),
|
|
149
|
+
"source_column": prior.get("source_column", ""),
|
|
150
|
+
"expansion_type": "LATERAL",
|
|
151
|
+
}
|
|
152
|
+
if path_bits:
|
|
153
|
+
entry["json_path"] = ".".join(path_bits)
|
|
154
|
+
resolved_table = entry["source_table"]
|
|
155
|
+
else:
|
|
156
|
+
resolved_table = alias_map.get(src_table, src_table)
|
|
157
|
+
if not resolved_table:
|
|
158
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
159
|
+
if len(real) == 1:
|
|
160
|
+
resolved_table = real[0]
|
|
161
|
+
entry = {
|
|
162
|
+
"source_table": resolved_table,
|
|
163
|
+
"source_column": src_col,
|
|
164
|
+
"expansion_type": "LATERAL",
|
|
165
|
+
}
|
|
166
|
+
if source_info.get("json_path"):
|
|
167
|
+
entry["json_path"] = source_info["json_path"]
|
|
168
|
+
extras = []
|
|
169
|
+
for arg in func_args[1:]:
|
|
170
|
+
extra_info = _extract_array_source(arg)
|
|
171
|
+
if not extra_info:
|
|
172
|
+
continue
|
|
173
|
+
extra_info = _fold_struct_parts(extra_info, alias_map)
|
|
174
|
+
if extra_info.get("source_column"):
|
|
175
|
+
extras.append(
|
|
176
|
+
{
|
|
177
|
+
"source_table": extra_info.get("source_table", ""),
|
|
178
|
+
"source_column": extra_info["source_column"],
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
if extras:
|
|
182
|
+
entry["extra_columns"] = resolve_expansion_extras(
|
|
183
|
+
{"extra_columns": extras}, alias_map, all_tables
|
|
184
|
+
)
|
|
185
|
+
array_expansion_sources[lateral_alias] = entry
|
|
186
|
+
for col_name in alias_columns or []:
|
|
187
|
+
# AS f(elem): the declared output column is how the value is read
|
|
188
|
+
array_expansion_sources.setdefault(col_name, {**entry, "is_unnest_column": True})
|
|
189
|
+
alias_map[lateral_alias] = resolved_table
|
|
190
|
+
if resolved_table and resolved_table not in all_tables:
|
|
191
|
+
all_tables.append(resolved_table)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _lateral_function_args(inner: "exp.Expression") -> "list[exp.Expression]":
|
|
195
|
+
"""The arguments of a lateral set-returning function call, if that is
|
|
196
|
+
what the LATERAL wraps (subqueries and explode-family nodes are handled
|
|
197
|
+
by their own branches)."""
|
|
198
|
+
if isinstance(inner, (exp.Subquery, exp.Select, exp.Explode, exp.Unnest)):
|
|
199
|
+
return []
|
|
200
|
+
if isinstance(inner, exp.Func):
|
|
201
|
+
if getattr(inner, "expressions", None):
|
|
202
|
+
return list(inner.expressions)
|
|
203
|
+
this = inner.args.get("this")
|
|
204
|
+
if isinstance(this, exp.Expression):
|
|
205
|
+
return [this]
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _alias_column_names(alias_node) -> list[str]:
|
|
210
|
+
"""Output-column names from a TableAlias like f(elem, idx)."""
|
|
211
|
+
if alias_node is None:
|
|
212
|
+
return []
|
|
213
|
+
return [c.name for c in getattr(alias_node, "columns", []) or [] if getattr(c, "name", "")]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_scope(
|
|
217
|
+
select_for_from: "exp.Expression",
|
|
218
|
+
) -> tuple[dict[str, str], list[str], dict[str, dict[str, str]]]:
|
|
219
|
+
"""Register this select's FROM/JOIN/LATERAL/UNNEST relations."""
|
|
220
|
+
alias_map: dict[str, str] = {}
|
|
221
|
+
all_tables: list[str] = []
|
|
222
|
+
|
|
223
|
+
# Track array expansion sources (FLATTEN, UNNEST, EXPLODE)
|
|
224
|
+
# Maps alias -> {source_table, source_column, expansion_type}
|
|
225
|
+
array_expansion_sources: dict[str, dict[str, str]] = {}
|
|
226
|
+
|
|
227
|
+
from_clause = select_for_from.args.get("from_") or select_for_from.args.get("from")
|
|
228
|
+
if from_clause:
|
|
229
|
+
for table in from_clause.find_all(exp.Table):
|
|
230
|
+
register_table(table, alias_map, all_tables)
|
|
231
|
+
|
|
232
|
+
# Joins inside CTE bodies or nested subqueries belong to their own
|
|
233
|
+
# select; letting them in shadows this scope's aliases.
|
|
234
|
+
for join in select_for_from.find_all(exp.Join):
|
|
235
|
+
if owning_select(join) is not select_for_from:
|
|
236
|
+
continue
|
|
237
|
+
join_table = join.this
|
|
238
|
+
if isinstance(join_table, exp.Table):
|
|
239
|
+
register_table(join_table, alias_map, all_tables)
|
|
240
|
+
|
|
241
|
+
# Set-returning functions in table position (CROSS JOIN
|
|
242
|
+
# jsonb_array_elements_text(roles) AS role, or the FROM-comma form):
|
|
243
|
+
# register_table skips them; their alias is an expansion of the
|
|
244
|
+
# argument (kingfisher-summarize, holdout round 7). Runs after the
|
|
245
|
+
# relation loops so argument aliases resolve.
|
|
246
|
+
for table in select_for_from.find_all(exp.Table):
|
|
247
|
+
if owning_select(table) is not select_for_from:
|
|
248
|
+
continue
|
|
249
|
+
if _table_function_kind(table) != "srf":
|
|
250
|
+
continue
|
|
251
|
+
func = table.this
|
|
252
|
+
func_args = list(getattr(func, "expressions", None) or [])
|
|
253
|
+
if not func_args and isinstance(func.args.get("this"), exp.Expression):
|
|
254
|
+
func_args = [func.args["this"]]
|
|
255
|
+
alias = table.alias
|
|
256
|
+
if alias and func_args:
|
|
257
|
+
_register_function_expansion(
|
|
258
|
+
alias,
|
|
259
|
+
func_args,
|
|
260
|
+
alias_map,
|
|
261
|
+
all_tables,
|
|
262
|
+
array_expansion_sources,
|
|
263
|
+
alias_columns=_alias_column_names(table.args.get("alias")),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Runs after alias_map is populated so source table aliases resolve.
|
|
267
|
+
# Spark/Databricks LATERAL VIEW lives in select.args["laterals"], not
|
|
268
|
+
# under FROM, so both locations are swept.
|
|
269
|
+
laterals: list[exp.Lateral] = []
|
|
270
|
+
if from_clause:
|
|
271
|
+
laterals.extend(
|
|
272
|
+
lat
|
|
273
|
+
for lat in from_clause.find_all(exp.Lateral)
|
|
274
|
+
if owning_select(lat) is select_for_from
|
|
275
|
+
)
|
|
276
|
+
laterals.extend(select_for_from.args.get("laterals") or [])
|
|
277
|
+
if laterals:
|
|
278
|
+
for lateral in laterals:
|
|
279
|
+
lateral_alias = lateral.alias
|
|
280
|
+
inner = lateral.this
|
|
281
|
+
|
|
282
|
+
# FLATTEN/EXPLODE/POSEXPLODE parse as Lateral containing
|
|
283
|
+
# Explode; OUTER and INLINE variants are separate classes
|
|
284
|
+
expansion_types = (exp.Explode,)
|
|
285
|
+
for extra in ("ExplodeOuter", "Inline"):
|
|
286
|
+
if hasattr(exp, extra):
|
|
287
|
+
expansion_types = (*expansion_types, getattr(exp, extra))
|
|
288
|
+
if isinstance(inner, expansion_types):
|
|
289
|
+
source_col = inner.this
|
|
290
|
+
if source_col and lateral_alias:
|
|
291
|
+
source_info = _extract_array_source(source_col)
|
|
292
|
+
if source_info:
|
|
293
|
+
source_info = _fold_struct_parts(source_info, alias_map)
|
|
294
|
+
# Resolve source table through alias_map; a source
|
|
295
|
+
# that is itself an expansion alias chains through
|
|
296
|
+
src_table = source_info.get("source_table", "")
|
|
297
|
+
if src_table in array_expansion_sources:
|
|
298
|
+
resolved_table = array_expansion_sources[src_table].get(
|
|
299
|
+
"source_table", ""
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
resolved_table = alias_map.get(src_table, src_table)
|
|
303
|
+
if not resolved_table:
|
|
304
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
305
|
+
if len(real) == 1:
|
|
306
|
+
resolved_table = real[0]
|
|
307
|
+
expansion_entry = {
|
|
308
|
+
"source_table": resolved_table,
|
|
309
|
+
"source_column": source_info.get("source_column", ""),
|
|
310
|
+
"expansion_type": "FLATTEN",
|
|
311
|
+
}
|
|
312
|
+
if source_info.get("json_path"):
|
|
313
|
+
expansion_entry["json_path"] = source_info["json_path"]
|
|
314
|
+
array_expansion_sources[lateral_alias] = expansion_entry
|
|
315
|
+
# Spark allows the exploded output columns bare
|
|
316
|
+
# (item, not just ex.item): register each alias
|
|
317
|
+
# column name too
|
|
318
|
+
table_alias = lateral.args.get("alias")
|
|
319
|
+
for alias_col in table_alias.columns if table_alias else []:
|
|
320
|
+
array_expansion_sources.setdefault(
|
|
321
|
+
alias_col.name,
|
|
322
|
+
{**expansion_entry, "is_unnest_column": True},
|
|
323
|
+
)
|
|
324
|
+
alias_map[lateral_alias] = resolved_table
|
|
325
|
+
if resolved_table and resolved_table not in all_tables:
|
|
326
|
+
all_tables.append(resolved_table)
|
|
327
|
+
else:
|
|
328
|
+
func_args = _lateral_function_args(inner)
|
|
329
|
+
if func_args:
|
|
330
|
+
_register_function_expansion(
|
|
331
|
+
lateral_alias,
|
|
332
|
+
func_args,
|
|
333
|
+
alias_map,
|
|
334
|
+
all_tables,
|
|
335
|
+
array_expansion_sources,
|
|
336
|
+
alias_columns=_alias_column_names(lateral.args.get("alias")),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# BigQuery UNNEST, scoped to this select's own FROM:
|
|
340
|
+
# an UNNEST inside a select-item subquery anchors only that
|
|
341
|
+
# subquery's references (resolved per column below), never sibling
|
|
342
|
+
# select items. Anonymous UNNESTs anchor unknown struct-root
|
|
343
|
+
# references to their source array.
|
|
344
|
+
for unnest in select_for_from.find_all(exp.Unnest):
|
|
345
|
+
if owning_select(unnest) is not select_for_from:
|
|
346
|
+
continue
|
|
347
|
+
unnest_alias = unnest.alias
|
|
348
|
+
if unnest.expressions:
|
|
349
|
+
source_col = unnest.expressions[0]
|
|
350
|
+
source_info = _extract_array_source(source_col)
|
|
351
|
+
if source_info:
|
|
352
|
+
source_info = _fold_struct_parts(source_info, alias_map)
|
|
353
|
+
src_table = source_info.get("source_table", "")
|
|
354
|
+
resolved_table = alias_map.get(src_table, src_table)
|
|
355
|
+
# a literal-only named-struct array reads nothing from the
|
|
356
|
+
# outer relation; anchoring it to the sole table fabricated
|
|
357
|
+
# struct-guess edges (cycle-12, F17)
|
|
358
|
+
if not resolved_table and source_info.get("source_column"):
|
|
359
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
360
|
+
if len(real) == 1:
|
|
361
|
+
resolved_table = real[0]
|
|
362
|
+
entry = {
|
|
363
|
+
"source_table": resolved_table,
|
|
364
|
+
"source_column": source_info.get("source_column", ""),
|
|
365
|
+
"expansion_type": "UNNEST",
|
|
366
|
+
}
|
|
367
|
+
extras = resolve_expansion_extras(source_info, alias_map, all_tables)
|
|
368
|
+
if extras:
|
|
369
|
+
entry["extra_columns"] = extras
|
|
370
|
+
field_map = resolve_field_sources(source_info, alias_map, all_tables)
|
|
371
|
+
if field_map:
|
|
372
|
+
entry["field_sources"] = field_map
|
|
373
|
+
if unnest_alias:
|
|
374
|
+
array_expansion_sources[unnest_alias] = entry
|
|
375
|
+
alias_map[unnest_alias] = resolved_table
|
|
376
|
+
else:
|
|
377
|
+
array_expansion_sources.setdefault("__anonymous_unnest__", entry)
|
|
378
|
+
if resolved_table and resolved_table not in all_tables:
|
|
379
|
+
all_tables.append(resolved_table)
|
|
380
|
+
|
|
381
|
+
# plain joined tables were registered above; only the exotic join shapes remain
|
|
382
|
+
for join in select_for_from.find_all(exp.Join):
|
|
383
|
+
if owning_select(join) is not select_for_from:
|
|
384
|
+
continue
|
|
385
|
+
join_table = join.this
|
|
386
|
+
if isinstance(join_table, exp.Subquery) and join_table.alias:
|
|
387
|
+
alias_map[join_table.alias] = f"({join_table.alias})"
|
|
388
|
+
all_tables.append(f"({join_table.alias})")
|
|
389
|
+
elif isinstance(join_table, exp.Lateral):
|
|
390
|
+
lateral_alias = join_table.alias
|
|
391
|
+
inner = join_table.this
|
|
392
|
+
if isinstance(inner, exp.Explode) and lateral_alias:
|
|
393
|
+
source_col = inner.this
|
|
394
|
+
source_info = _extract_array_source(source_col)
|
|
395
|
+
if source_info:
|
|
396
|
+
source_info = _fold_struct_parts(source_info, alias_map)
|
|
397
|
+
# Resolve source table through alias_map
|
|
398
|
+
src_table = source_info.get("source_table", "")
|
|
399
|
+
resolved_table = alias_map.get(src_table, src_table)
|
|
400
|
+
array_expansion_sources[lateral_alias] = {
|
|
401
|
+
"source_table": resolved_table,
|
|
402
|
+
"source_column": source_info.get("source_column", ""),
|
|
403
|
+
"expansion_type": "FLATTEN",
|
|
404
|
+
}
|
|
405
|
+
if source_info.get("json_path"):
|
|
406
|
+
array_expansion_sources[lateral_alias]["json_path"] = source_info[
|
|
407
|
+
"json_path"
|
|
408
|
+
]
|
|
409
|
+
alias_map[lateral_alias] = resolved_table
|
|
410
|
+
if resolved_table and resolved_table not in all_tables:
|
|
411
|
+
all_tables.append(resolved_table)
|
|
412
|
+
elif lateral_alias:
|
|
413
|
+
func_args = _lateral_function_args(inner)
|
|
414
|
+
if func_args:
|
|
415
|
+
_register_function_expansion(
|
|
416
|
+
lateral_alias,
|
|
417
|
+
func_args,
|
|
418
|
+
alias_map,
|
|
419
|
+
all_tables,
|
|
420
|
+
array_expansion_sources,
|
|
421
|
+
alias_columns=_alias_column_names(join_table.args.get("alias")),
|
|
422
|
+
)
|
|
423
|
+
elif isinstance(join_table, exp.Unnest):
|
|
424
|
+
unnest_alias_node = join_table.args.get("alias")
|
|
425
|
+
if unnest_alias_node:
|
|
426
|
+
if hasattr(unnest_alias_node, "this"):
|
|
427
|
+
unnest_alias = (
|
|
428
|
+
unnest_alias_node.this.name
|
|
429
|
+
if hasattr(unnest_alias_node.this, "name")
|
|
430
|
+
else str(unnest_alias_node.this)
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
unnest_alias = str(unnest_alias_node)
|
|
434
|
+
|
|
435
|
+
# UNNEST(...) AS t(item): t(item) declares the output columns
|
|
436
|
+
column_aliases = []
|
|
437
|
+
if hasattr(unnest_alias_node, "columns"):
|
|
438
|
+
column_aliases = [
|
|
439
|
+
c.name if hasattr(c, "name") else str(c) for c in unnest_alias_node.columns
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
if join_table.expressions:
|
|
443
|
+
source_col = join_table.expressions[0]
|
|
444
|
+
source_info = _extract_array_source(source_col)
|
|
445
|
+
if source_info:
|
|
446
|
+
source_info = _fold_struct_parts(source_info, alias_map)
|
|
447
|
+
# Resolve the source table through alias_map; the
|
|
448
|
+
# sole-table fallback is skipped for literal-only
|
|
449
|
+
# struct arrays, same reason as above (F17)
|
|
450
|
+
src_table = source_info.get("source_table", "")
|
|
451
|
+
resolved_table = alias_map.get(src_table, src_table)
|
|
452
|
+
if not resolved_table and source_info.get("source_column"):
|
|
453
|
+
real = [t for t in all_tables if not t.startswith("(")]
|
|
454
|
+
if len(real) == 1:
|
|
455
|
+
resolved_table = real[0]
|
|
456
|
+
|
|
457
|
+
join_extras = resolve_expansion_extras(source_info, alias_map, all_tables)
|
|
458
|
+
join_fields = resolve_field_sources(source_info, alias_map, all_tables)
|
|
459
|
+
array_expansion_sources[unnest_alias] = {
|
|
460
|
+
"source_table": resolved_table,
|
|
461
|
+
"source_column": source_info.get("source_column", ""),
|
|
462
|
+
"expansion_type": "UNNEST",
|
|
463
|
+
"column_aliases": column_aliases,
|
|
464
|
+
}
|
|
465
|
+
if join_extras:
|
|
466
|
+
array_expansion_sources[unnest_alias]["extra_columns"] = join_extras
|
|
467
|
+
if join_fields:
|
|
468
|
+
array_expansion_sources[unnest_alias]["field_sources"] = join_fields
|
|
469
|
+
if source_info.get("json_path"):
|
|
470
|
+
array_expansion_sources[unnest_alias]["json_path"] = source_info[
|
|
471
|
+
"json_path"
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
alias_map[unnest_alias] = resolved_table
|
|
475
|
+
# Also map each column alias to the source
|
|
476
|
+
for col_alias in column_aliases:
|
|
477
|
+
array_expansion_sources[col_alias] = {
|
|
478
|
+
"source_table": resolved_table,
|
|
479
|
+
"source_column": source_info.get("source_column", ""),
|
|
480
|
+
"expansion_type": "UNNEST",
|
|
481
|
+
"is_unnest_column": True,
|
|
482
|
+
}
|
|
483
|
+
if join_extras:
|
|
484
|
+
array_expansion_sources[col_alias]["extra_columns"] = join_extras
|
|
485
|
+
if join_fields:
|
|
486
|
+
array_expansion_sources[col_alias]["field_sources"] = join_fields
|
|
487
|
+
|
|
488
|
+
return alias_map, all_tables, array_expansion_sources
|