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
@@ -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