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.
Files changed (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. 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