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,1309 @@
1
+ """CTE column lineage extraction and tracing.
2
+
3
+ This module handles Common Table Expressions (CTEs) for column lineage:
4
+ - Extract column mappings from CTEs
5
+ - Detect CTE name collisions with real tables
6
+ - Recursively trace columns through nested CTEs
7
+
8
+ CTE Collision Detection:
9
+ If a CTE name shadows a real table name (e.g., WITH orders AS ...),
10
+ lineage would incorrectly trace to the CTE instead of the real table.
11
+ This module detects and warns about such collisions.
12
+
13
+ Key Functions:
14
+ - extract_cte_column_lineage: Per-CTE column sources
15
+ - extract_cte_column_mappings: CTE name -> column mappings
16
+ - trace_through_ctes: Recursive column tracing
17
+ """
18
+
19
+ import logging
20
+ from collections.abc import Callable
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from ripple.engine.column_ref import (
24
+ alias_is_foldable,
25
+ collect_unnest_expansions,
26
+ column_parts,
27
+ expansion_field_reads,
28
+ in_selector_argument,
29
+ in_window_ordering,
30
+ left_join_anchor,
31
+ nested_scope_sole_table,
32
+ owning_select,
33
+ qualified_table_name,
34
+ qualifier_is_quoted,
35
+ register_subquery_from_aliases,
36
+ resolve_column_ref,
37
+ scoped_unnest_entry,
38
+ table_function_kind,
39
+ under_subquery_where,
40
+ unwrap_select,
41
+ )
42
+ from ripple.engine.preprocess import restore_jinja_dots
43
+ from ripple.engine.safe_gen import safe_sql
44
+ from ripple.engine.scope import register_table
45
+ from ripple.engine.tsql_catalog import system_catalog_owner
46
+ from ripple.engine.types import (
47
+ LATERAL_ALIAS_DIALECTS,
48
+ META_CTE_COLLISIONS,
49
+ META_CTE_RECURSIVE,
50
+ META_CTE_RELATIONS,
51
+ META_CTE_SHADOWED,
52
+ META_PREFIX,
53
+ )
54
+
55
+ if TYPE_CHECKING:
56
+ from sqlglot import exp
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+ def extract_cte_column_lineage(
62
+ raw_sql: str,
63
+ dialect: str = "snowflake",
64
+ clean_jinja_func: Callable[[str], str] | None = None,
65
+ real_tables: set[str] | None = None,
66
+ self_names: set[str] | None = None,
67
+ ) -> dict[str, dict[str, list[dict]] | list[dict]]:
68
+ """Extract column lineage for each CTE in the SQL.
69
+
70
+ For SQL like:
71
+ WITH enriched AS (
72
+ SELECT c.name AS customer_name
73
+ FROM orders o
74
+ LEFT JOIN {{ ref('customers') }} AS c ON o.customer_id = c.id
75
+ )
76
+ SELECT * FROM enriched
77
+
78
+ Returns:
79
+ {
80
+ "enriched": {
81
+ "customer_name": [{"table": "c", "column": "name"}]
82
+ },
83
+ META_CTE_COLLISIONS: [...] # Only if collisions detected
84
+ }
85
+
86
+ CTE Collision Detection:
87
+ If `real_tables` is provided, this function checks whether any CTE name
88
+ shadows a real table name. This is critical for lineage correctness:
89
+ a CTE named "orders" would shadow the real "orders" table, causing
90
+ lineage to trace to the wrong source.
91
+
92
+ This enables tracing THROUGH CTEs to find the actual column sources.
93
+
94
+ Args:
95
+ raw_sql: Raw SQL with Jinja templates
96
+ dialect: SQL dialect (snowflake, bigquery, databricks, etc.)
97
+ clean_jinja_func: Function to clean Jinja from SQL (injected dependency)
98
+ real_tables: Set of real table names (for collision detection)
99
+ """
100
+ import sqlglot
101
+ from sqlglot import exp
102
+
103
+ # Use injected cleaner or identity function
104
+ if clean_jinja_func is None:
105
+
106
+ def clean_jinja_func(sql):
107
+ return sql
108
+
109
+ cte_lineage: dict[str, dict[str, list[dict]]] = {}
110
+ cte_collisions: list[dict] = []
111
+ real_tables_lower = {t.lower() for t in (real_tables or set())}
112
+ # a CTE named after the model being analyzed shadows only itself: the CTE
113
+ # always wins within its statement and a genuine self-read would resolve
114
+ # elsewhere, so it is not a collision (jaffle-shop's house style flagged
115
+ # 17 correct pass-through edges this way)
116
+ real_tables_lower -= {t.lower() for t in (self_names or set())}
117
+
118
+ try:
119
+ from ripple.engine.preprocess import prepare_sql_for_parse
120
+
121
+ cleaned_sql, _ = prepare_sql_for_parse(raw_sql, dialect, clean_jinja_func)
122
+ parsed = sqlglot.parse_one(cleaned_sql, dialect=dialect)
123
+ if (dialect or "").lower() == "tsql":
124
+ from ripple.engine.tsql_xml import rewrite_xml_method_calls
125
+
126
+ parsed = rewrite_xml_method_calls(parsed)
127
+
128
+ def extract_select_columns(select_node: exp.Select) -> dict[str, list[dict]]:
129
+ """Extract column lineage from a SELECT node."""
130
+ alias_map: dict[str, str] = {}
131
+ all_tables: list[str] = []
132
+ subquery_columns_by_alias: dict[str, dict[str, list[dict]]] = {}
133
+
134
+ from_clause = select_node.args.get("from_") or select_node.args.get("from")
135
+ if from_clause:
136
+ for subquery in from_clause.find_all(exp.Subquery):
137
+ sub_inner = unwrap_select(subquery.this)
138
+ if subquery.alias and isinstance(sub_inner, exp.Select):
139
+ subquery_columns_by_alias[subquery.alias] = extract_select_columns(
140
+ sub_inner
141
+ )
142
+ for table in from_clause.find_all(exp.Table):
143
+ register_table(table, alias_map, all_tables)
144
+
145
+ for join in select_node.find_all(exp.Join):
146
+ join_table = join.this
147
+ if isinstance(join_table, exp.Subquery) and join_table.alias:
148
+ join_inner = unwrap_select(join_table.this)
149
+ if isinstance(join_inner, exp.Select):
150
+ subquery_columns_by_alias[join_table.alias] = extract_select_columns(
151
+ join_inner
152
+ )
153
+ if isinstance(join_table, exp.Table):
154
+ register_table(join_table, alias_map, all_tables)
155
+
156
+ default_table = all_tables[0] if len(all_tables) == 1 else ""
157
+
158
+ columns: dict[str, list[dict]] = {}
159
+ for select_expr in select_node.selects:
160
+ if isinstance(select_expr, exp.Column) and isinstance(select_expr.this, exp.Star):
161
+ # vv.* over an inline subquery: absorb the subquery's own
162
+ # column map, star chain included, instead of letting the
163
+ # alias surface as a phantom relation (pensjon's dedup
164
+ # wrapper, holdout round 5: the round's only
165
+ # wrong-confident predictions)
166
+ qualifier = select_expr.table or ""
167
+ sub_map = subquery_columns_by_alias.get(qualifier)
168
+ if sub_map is not None:
169
+ for name, srcs in sub_map.items():
170
+ columns.setdefault(name, list(srcs))
171
+ continue
172
+ rel = alias_map.get(qualifier, qualifier)
173
+ if rel:
174
+ columns.setdefault("*", []).append(
175
+ {"table": rel, "column": "*", "confidence": 0.5}
176
+ )
177
+ continue
178
+ if isinstance(select_expr, exp.Alias):
179
+ col_name = select_expr.alias
180
+ source_expr = select_expr.this
181
+ elif isinstance(select_expr, exp.Column):
182
+ col_name = select_expr.name
183
+ source_expr = select_expr
184
+ elif isinstance(select_expr, exp.Star):
185
+ col_name = "*"
186
+ source_expr = select_expr
187
+ else:
188
+ continue
189
+
190
+ if not col_name:
191
+ continue
192
+
193
+ sources: list[dict] = []
194
+ seen: set[tuple[str, str]] = set()
195
+ _known = (
196
+ {str(k).lower() for k in alias_map}
197
+ | {str(v).lower() for v in alias_map.values()}
198
+ | {str(k).lower() for k in subquery_columns_by_alias}
199
+ )
200
+ for col in source_expr.find_all(exp.Column):
201
+ if in_window_ordering(col, source_expr):
202
+ continue
203
+ if in_selector_argument(col, source_expr):
204
+ continue
205
+ alias, column, _field_path, _certain = resolve_column_ref(col, _known)
206
+ if "{" in column or (not _certain and any("{" in p for p in column_parts(col))):
207
+ # a {placeholder} or jinja chain in column/qualifier
208
+ # position has no citable identity (cycle-12, F11)
209
+ continue
210
+ if not _certain:
211
+ # unknown leading identifier reads as a struct root;
212
+ # anchor to the sole relation instead of fabricating
213
+ alias = "" if default_table else alias
214
+
215
+ if alias and alias in subquery_columns_by_alias:
216
+ sub_sources = subquery_columns_by_alias[alias].get(column, [])
217
+ for src in sub_sources:
218
+ key = (src.get("table"), src.get("column"))
219
+ if key not in seen and (src.get("table") or src.get("column")):
220
+ seen.add(key)
221
+ sources.append(src)
222
+ if sub_sources:
223
+ continue
224
+
225
+ if not alias and not default_table and len(subquery_columns_by_alias) == 1:
226
+ sub_alias = next(iter(subquery_columns_by_alias))
227
+ sub_sources = subquery_columns_by_alias[sub_alias].get(column, [])
228
+ for src in sub_sources:
229
+ key = (src.get("table"), src.get("column"))
230
+ if key not in seen and (src.get("table") or src.get("column")):
231
+ seen.add(key)
232
+ sources.append(src)
233
+ if sub_sources:
234
+ continue
235
+
236
+ if alias:
237
+ table = alias_map.get(alias, alias)
238
+ elif default_table:
239
+ table = default_table
240
+ else:
241
+ table = ""
242
+
243
+ key = (table, column)
244
+ if key not in seen and (table or column):
245
+ seen.add(key)
246
+ sources.append({"table": table, "column": column})
247
+
248
+ if sources:
249
+ columns[col_name] = sources
250
+
251
+ return columns
252
+
253
+ # Find all CTEs
254
+ for cte in parsed.find_all(exp.CTE):
255
+ cte_name = restore_jinja_dots(cte.alias.lower()) if cte.alias else None
256
+ if not cte_name:
257
+ continue
258
+
259
+ # CTE collision detection: check if CTE name shadows a real table
260
+ if real_tables_lower and cte_name in real_tables_lower:
261
+ cte_collisions.append(
262
+ {
263
+ "cte_name": cte_name,
264
+ "collision_type": "shadows_real_table",
265
+ "warning": f"CTE '{cte_name}' shadows real table with same name. "
266
+ "Lineage may be incorrect.",
267
+ "trust_level": "review_required",
268
+ }
269
+ )
270
+
271
+ cte_select = unwrap_select(cte.this)
272
+ columns: dict[str, list[dict]] = {}
273
+
274
+ if isinstance(cte_select, exp.Select):
275
+ columns = extract_select_columns(cte_select)
276
+ elif isinstance(cte_select, exp.SetOperation):
277
+ # value branches only (EXCEPT/INTERSECT right sides filter,
278
+ # never surface), mapped positionally onto the left branch's
279
+ # output names: 'a AS x UNION ALL b AS y' exposes one column
280
+ # x fed by both branches, not a phantom y. UNION BY NAME is
281
+ # the exception and merges by name.
282
+ branches = set_op_value_branches(cte_select)
283
+ by_name = any(
284
+ n.args.get("by_name")
285
+ for n in cte_select.find_all(exp.Union, exp.Except, exp.Intersect)
286
+ )
287
+ if branches:
288
+ columns = extract_select_columns(branches[0])
289
+ out_names = [n for n in columns if n != "*"]
290
+ for branch in branches[1:]:
291
+ branch_columns = extract_select_columns(branch)
292
+ branch_names = [n for n in branch_columns if n != "*"]
293
+ for i, bname in enumerate(branch_names):
294
+ if by_name:
295
+ target = bname
296
+ else:
297
+ target = out_names[i] if i < len(out_names) else bname
298
+ existing = columns.setdefault(target, [])
299
+ seen = {(s.get("table"), s.get("column")) for s in existing}
300
+ for src in branch_columns[bname]:
301
+ key = (src.get("table"), src.get("column"))
302
+ if key not in seen:
303
+ seen.add(key)
304
+ existing.append(src)
305
+ for src in branch_columns.get("*", []):
306
+ existing = columns.setdefault("*", [])
307
+ key = (src.get("table"), src.get("column"))
308
+ if key not in {(s.get("table"), s.get("column")) for s in existing}:
309
+ existing.append(src)
310
+
311
+ if columns:
312
+ cte_lineage[cte_name] = columns
313
+
314
+ except Exception as e:
315
+ logger.debug(f"Failed to extract CTE column lineage: {e}")
316
+
317
+ # Include CTE collisions in result if any were detected
318
+ result: dict[str, Any] = dict(cte_lineage)
319
+ if cte_collisions:
320
+ result[META_CTE_COLLISIONS] = cte_collisions
321
+
322
+ return result
323
+
324
+
325
+ def set_op_value_branches(node: "exp.Expression") -> list["exp.Select"]:
326
+ """Selects whose rows can appear in a set operation's output. EXCEPT and
327
+ INTERSECT right sides only filter; their values never surface."""
328
+ from sqlglot import exp
329
+
330
+ if isinstance(node, (exp.Subquery, exp.Paren)):
331
+ return set_op_value_branches(node.this)
332
+ if isinstance(node, exp.Union):
333
+ return set_op_value_branches(node.left) + set_op_value_branches(node.right)
334
+ if isinstance(node, (exp.Except, exp.Intersect)):
335
+ return set_op_value_branches(node.left)
336
+ if isinstance(node, exp.Select):
337
+ return [node]
338
+ return []
339
+
340
+
341
+ def extract_set_op_column_sources(
342
+ set_op: "exp.Expression",
343
+ dialect: str,
344
+ correlated_aliases: dict[str, str] | None = None,
345
+ correlated_maps: dict[str, dict[str, list[dict]]] | None = None,
346
+ correlated_tables: list[str] | None = None,
347
+ ) -> dict[str, list[dict]]:
348
+ """Column map for a set-operation body: every value branch contributes
349
+ to its positionally or by-name matched output column.
350
+
351
+ Output names come from the leftmost branch. A star branch adds its
352
+ relations under '*' and pass-through entries for output columns its own
353
+ select list does not name; trace_through_ctes resolves both later. A
354
+ set operation inside an APPLY is as correlated as a plain SELECT, so
355
+ the enclosing scope's names forward to every branch (cycle-9
356
+ review, F9).
357
+ """
358
+ from sqlglot import exp
359
+
360
+ branches = set_op_value_branches(set_op)
361
+ if not branches:
362
+ return {}
363
+
364
+ def _branch_columns(branch: "exp.Select") -> dict[str, list[dict]]:
365
+ return extract_select_column_sources(
366
+ branch,
367
+ dialect,
368
+ correlated_aliases=correlated_aliases,
369
+ correlated_maps=correlated_maps,
370
+ correlated_tables=correlated_tables,
371
+ )
372
+
373
+ columns = _branch_columns(branches[0])
374
+ by_name = any(
375
+ n.args.get("by_name") for n in set_op.find_all(exp.Union, exp.Except, exp.Intersect)
376
+ )
377
+ output_names = [k for k in columns if k != "*"]
378
+ left_has_star = "*" in columns
379
+ lower_names = {n.lower(): n for n in output_names}
380
+
381
+ def append(target: str, entries: list[dict]) -> None:
382
+ existing = columns.setdefault(target, [])
383
+ seen = {(s.get("table"), s.get("column")) for s in existing}
384
+ for src in entries:
385
+ key = (src.get("table"), src.get("column"))
386
+ if key not in seen and (key[0] or key[1]):
387
+ seen.add(key)
388
+ existing.append(src)
389
+
390
+ for branch in branches[1:]:
391
+ branch_cols = _branch_columns(branch)
392
+ named_items = [(n, s) for n, s in branch_cols.items() if n != "*"]
393
+ star_sources = branch_cols.get("*") or []
394
+ use_positional = (
395
+ not by_name
396
+ and not left_has_star
397
+ and not star_sources
398
+ and len(named_items) == len(output_names)
399
+ )
400
+ covered: set[str] = set()
401
+ for idx, (name, srcs) in enumerate(named_items):
402
+ target = output_names[idx] if use_positional else lower_names.get(name.lower())
403
+ if target is None:
404
+ continue
405
+ covered.add(target)
406
+ append(target, srcs)
407
+ star_relations = [s.get("table") for s in star_sources if s.get("table")]
408
+ for rel in star_relations:
409
+ for target in output_names:
410
+ if target in covered:
411
+ continue
412
+ append(target, [{"table": rel, "column": target, "confidence": 0.8}])
413
+ if star_sources:
414
+ append("*", star_sources)
415
+ return columns
416
+
417
+
418
+ def subquery_column_entries(sub_map: dict[str, list[dict]], column: str) -> list[dict]:
419
+ """An inline subquery's own sources for one of its output columns: the
420
+ named entry, or the column carried through the subquery's star
421
+ passthrough. Used wherever an alias-qualified column's qualifier turns
422
+ out to be a subquery alias, so the alias never surfaces as a relation
423
+ (pensjon under qualify(), the gap named in PR #28)."""
424
+ entries = sub_map.get(column)
425
+ if entries is None:
426
+ entries = next((v for k, v in sub_map.items() if k.lower() == column.lower()), None)
427
+ if entries is None:
428
+ entries = [{**src, "column": column} for src in sub_map.get("*", []) if src.get("table")]
429
+ return entries or []
430
+
431
+
432
+ def rename_derived_columns(
433
+ columns: dict[str, list[dict]], names: list[str]
434
+ ) -> dict[str, list[dict]]:
435
+ """Apply a derived table's column-list alias (AS x (a, b)) positionally.
436
+
437
+ The list renames the subquery's outputs in order, so an unaliased
438
+ select item (a bare CAST) is read by its declared name, never by its
439
+ SQL spelling (DarkQueries' event_file_value_xml ([xml]), holdout
440
+ round 8)."""
441
+ if not names:
442
+ return columns
443
+ renamed: dict[str, list[dict]] = {}
444
+ idx = 0
445
+ for key, value in columns.items():
446
+ if key == "*" or str(key).startswith(META_PREFIX):
447
+ renamed[key] = value
448
+ continue
449
+ renamed[names[idx] if idx < len(names) else key] = value
450
+ idx += 1
451
+ return renamed
452
+
453
+
454
+ def alias_select_items(inner: "exp.Select", names: list[str]) -> "exp.Select":
455
+ """A copy with the column-list alias applied to the select items in
456
+ place, so two items spelling the same output (t.x, u.x) extract under
457
+ their distinct declared names instead of collapsing onto one dict key
458
+ (cycle-9 review, F3). A star item makes the positions
459
+ unknowable offline; the caller falls back to renaming afterwards."""
460
+ from sqlglot import exp
461
+
462
+ renamed = inner.copy()
463
+ for idx, item in enumerate(renamed.selects):
464
+ if idx >= len(names):
465
+ break
466
+ if isinstance(item, exp.Alias):
467
+ item.set("alias", exp.to_identifier(names[idx]))
468
+ else:
469
+ item.replace(exp.alias_(item, names[idx]))
470
+ return renamed
471
+
472
+
473
+ def lateral_subquery_map(
474
+ lateral: "exp.Expression",
475
+ dialect: str,
476
+ correlated_aliases: dict[str, str] | None = None,
477
+ correlated_maps: dict[str, dict[str, list[dict]]] | None = None,
478
+ correlated_tables: list[str] | None = None,
479
+ ) -> dict[str, list[dict]] | None:
480
+ """Column map of a lateral derived table (CROSS/OUTER APPLY (SELECT ...)).
481
+
482
+ APPLY subqueries are correlated: their reads resolve against the outer
483
+ scope's aliases, tables and the maps of earlier applies, so all three
484
+ are passed through (DarkQueries' stacked CROSS APPLY chain, holdout
485
+ round 8). Returns None when the lateral wraps anything but a derived
486
+ table (Explode laterals are expansions, handled by the scope
487
+ machinery)."""
488
+ from sqlglot import exp
489
+
490
+ if not isinstance(lateral.this, (exp.Subquery, exp.Paren)):
491
+ return None
492
+ inner = unwrap_select(lateral.this)
493
+ alias_node = lateral.args.get("alias")
494
+ names = [
495
+ c.name for c in ((alias_node.columns if alias_node is not None else None) or []) if c.name
496
+ ]
497
+ if isinstance(inner, exp.Select):
498
+ bases = [item.this if isinstance(item, exp.Alias) else item for item in inner.selects]
499
+ renamed_on_ast = bool(names) and not any(
500
+ isinstance(b, exp.Star) or (isinstance(b, exp.Column) and isinstance(b.this, exp.Star))
501
+ for b in bases
502
+ )
503
+ if renamed_on_ast:
504
+ inner = alias_select_items(inner, names)
505
+ columns = extract_select_column_sources(
506
+ inner,
507
+ dialect,
508
+ correlated_aliases=correlated_aliases,
509
+ correlated_maps=correlated_maps,
510
+ correlated_tables=correlated_tables,
511
+ )
512
+ if renamed_on_ast:
513
+ return columns
514
+ elif isinstance(inner, (exp.Union, exp.Except, exp.Intersect)):
515
+ columns = extract_set_op_column_sources(
516
+ inner,
517
+ dialect,
518
+ correlated_aliases=correlated_aliases,
519
+ correlated_maps=correlated_maps,
520
+ correlated_tables=correlated_tables,
521
+ )
522
+ else:
523
+ return None
524
+ return rename_derived_columns(columns, names)
525
+
526
+
527
+ def extract_select_column_sources(
528
+ select_node: "exp.Select",
529
+ dialect: str,
530
+ correlated_aliases: dict[str, str] | None = None,
531
+ correlated_maps: dict[str, dict[str, list[dict]]] | None = None,
532
+ correlated_tables: list[str] | None = None,
533
+ ) -> dict[str, list[dict]]:
534
+ """Column name -> sources for a single SELECT, in select-list order.
535
+
536
+ Shared by CTE mapping extraction and set-operation branch tracing.
537
+ Qualified stars (rel.*) and bare stars land under the '*' key with one
538
+ entry per spanned relation. correlated_aliases, correlated_maps and
539
+ correlated_tables are the enclosing scope's names, passed only for
540
+ correlated lateral derived tables (APPLY); local definitions win on
541
+ collision.
542
+ """
543
+ from sqlglot import exp
544
+
545
+ alias_map: dict[str, str] = dict(correlated_aliases or {})
546
+ all_tables: list[str] = []
547
+
548
+ def _subquery_map(inner: "exp.Expression | None") -> dict[str, list[dict]] | None:
549
+ # a set-operation derived table is as absorbable as a plain SELECT;
550
+ # skipping it left the alias as a phantom upstream (review of
551
+ # PR #28)
552
+ if isinstance(inner, exp.Select):
553
+ return extract_select_column_sources(inner, dialect)
554
+ if isinstance(inner, (exp.Union, exp.Except, exp.Intersect)):
555
+ return extract_set_op_column_sources(inner, dialect)
556
+ return None
557
+
558
+ subquery_columns_by_alias: dict[str, dict[str, list[dict]]] = {}
559
+ subquery_columns_folded: dict[str, dict[str, list[dict]]] = {}
560
+ for name, sub_map in (correlated_maps or {}).items():
561
+ if name:
562
+ subquery_columns_by_alias[name] = sub_map
563
+ subquery_columns_folded.setdefault(name.lower(), sub_map)
564
+
565
+ def _register_subquery(subquery: "exp.Subquery") -> None:
566
+ sub_map = _subquery_map(unwrap_select(subquery.this))
567
+ if sub_map is None:
568
+ return
569
+ if not subquery.alias:
570
+ # an UNALIASED from-subquery: its projection IS this scope's
571
+ # source. Dropping it let the sole-source fallthrough chain to
572
+ # the external relation and invent columns on it
573
+ # (transfermarkt's datetime_str, holdout round 7)
574
+ subquery_columns_by_alias.setdefault("", sub_map)
575
+ return
576
+ subquery_columns_by_alias[subquery.alias] = sub_map
577
+ if alias_is_foldable(subquery):
578
+ subquery_columns_folded.setdefault(subquery.alias.lower(), sub_map)
579
+
580
+ def _sub_map_for(alias: str, quoted: bool) -> dict[str, list[dict]] | None:
581
+ # a quoted reference matches only exactly; an unquoted one folds
582
+ # case against foldable definitions (the cycle-6 review)
583
+ exact = subquery_columns_by_alias.get(alias)
584
+ if exact is not None or quoted:
585
+ return exact
586
+ return subquery_columns_folded.get(alias.lower())
587
+
588
+ from_clause = select_node.args.get("from_") or select_node.args.get("from")
589
+ local_real_table = False
590
+ joined_relation = False
591
+ anon_candidates = 0
592
+ if from_clause:
593
+ for subquery in from_clause.find_all(exp.Subquery):
594
+ # nested subqueries belong to their own select; registering
595
+ # them here leaked inner projections into the outer scope
596
+ # (the cycle-8 review)
597
+ if owning_select(subquery) is not select_node:
598
+ continue
599
+ if not subquery.alias:
600
+ anon_candidates += 1
601
+ _register_subquery(subquery)
602
+ for table in from_clause.find_all(exp.Table):
603
+ register_table(table, alias_map, all_tables)
604
+ if owning_select(table) is select_node and table_function_kind(table) != "srf":
605
+ local_real_table = True
606
+
607
+ for join in select_node.find_all(exp.Join):
608
+ # joins inside CTE bodies or nested subqueries belong to their own
609
+ # select; letting them in shadows this scope's aliases
610
+ if owning_select(join) is not select_node:
611
+ continue
612
+ join_table = join.this
613
+ if isinstance(join_table, exp.Subquery):
614
+ _register_subquery(join_table)
615
+ joined_relation = True
616
+ if isinstance(join_table, exp.Lateral) and join_table.alias:
617
+ lat_map = lateral_subquery_map(
618
+ join_table,
619
+ dialect,
620
+ correlated_aliases=alias_map,
621
+ correlated_maps=subquery_columns_by_alias,
622
+ correlated_tables=all_tables,
623
+ )
624
+ if lat_map is not None:
625
+ subquery_columns_by_alias[join_table.alias] = lat_map
626
+ if alias_is_foldable(join_table):
627
+ subquery_columns_folded[join_table.alias.lower()] = lat_map
628
+ joined_relation = True
629
+ if isinstance(join_table, exp.Table):
630
+ register_table(join_table, alias_map, all_tables)
631
+ if table_function_kind(join_table) != "srf":
632
+ local_real_table = True
633
+
634
+ # the anonymous map speaks for this scope only when the unaliased
635
+ # subquery is the SOLE relation: a second subquery or any other
636
+ # relation makes its outputs a coin flip (the cycle-8 review)
637
+ if "" in subquery_columns_by_alias and (
638
+ anon_candidates != 1 or local_real_table or joined_relation
639
+ ):
640
+ subquery_columns_by_alias.pop("", None)
641
+
642
+ # a correlated body with no FROM of its own resolves against the outer
643
+ # scope; without the outer relations an unqualified read had no
644
+ # ownership candidate at all (cycle-9 review, F8)
645
+ if not all_tables and correlated_tables:
646
+ all_tables.extend(dict.fromkeys(correlated_tables))
647
+
648
+ default_table = all_tables[0] if len(all_tables) == 1 else ""
649
+ join_anchor = "" if default_table else left_join_anchor(select_node)
650
+ register_subquery_from_aliases(list(select_node.selects), alias_map)
651
+ columns: dict[str, list[dict]] = {}
652
+
653
+ # UNNEST element aliases in this select's FROM scope, including the
654
+ # synthetic _0-style names sqlglot's qualify() invents. An element
655
+ # read resolves to the array root column, never to a phantom.
656
+ expansions = collect_unnest_expansions(select_node, alias_map, all_tables)
657
+ lateral_aliases: dict[str, list[dict]] = {}
658
+ allow_lateral = (dialect or "").lower() in LATERAL_ALIAS_DIALECTS
659
+
660
+ _known = (
661
+ {str(k).lower() for k in alias_map}
662
+ | {str(v).lower() for v in alias_map.values()}
663
+ | {str(k).lower() for k in expansions}
664
+ | {str(k).lower() for k in subquery_columns_by_alias}
665
+ )
666
+
667
+ for select_expr in select_node.selects:
668
+ is_computed = False
669
+
670
+ if isinstance(select_expr, exp.Alias):
671
+ col_name = select_expr.alias
672
+ source_expr = select_expr.this
673
+ is_computed = not isinstance(source_expr, exp.Column)
674
+ elif isinstance(select_expr, exp.Column) and isinstance(select_expr.this, exp.Star):
675
+ # qualified star: rel.* spans one relation. Over an inline
676
+ # subquery alias, absorb the subquery's own map instead of
677
+ # letting the alias surface as a phantom relation (pensjon's
678
+ # dedup wrapper, holdout round 5)
679
+ qualifier = select_expr.table or ""
680
+ sub_map = _sub_map_for(qualifier, qualifier_is_quoted(select_expr))
681
+ if sub_map is not None:
682
+ for name, srcs in sub_map.items():
683
+ if name == "*":
684
+ existing = columns.setdefault("*", [])
685
+ for src in srcs:
686
+ if all(s.get("table") != src.get("table") for s in existing):
687
+ existing.append(dict(src))
688
+ else:
689
+ columns.setdefault(name, [dict(s) for s in srcs])
690
+ continue
691
+ rel = alias_map.get(qualifier, qualifier)
692
+ if rel:
693
+ existing = columns.setdefault("*", [])
694
+ if all(s.get("table") != rel for s in existing):
695
+ existing.append({"table": rel, "column": "*", "confidence": 0.5})
696
+ continue
697
+ elif isinstance(select_expr, exp.Column):
698
+ col_name = select_expr.name
699
+ source_expr = select_expr
700
+ elif isinstance(select_expr, exp.Star):
701
+ if not local_real_table and subquery_columns_by_alias:
702
+ # bare star over a scope of only derived tables: the
703
+ # columns are the subqueries' own projections, all of them
704
+ for sub_map_ in subquery_columns_by_alias.values():
705
+ for name, srcs in sub_map_.items():
706
+ if str(name).startswith(META_PREFIX):
707
+ continue
708
+ if name == "*":
709
+ existing = columns.setdefault("*", [])
710
+ for src in srcs:
711
+ if all(s.get("table") != src.get("table") for s in existing):
712
+ existing.append(dict(src))
713
+ else:
714
+ columns.setdefault(name, [dict(s) for s in srcs])
715
+ continue
716
+ existing = columns.setdefault("*", [])
717
+ for t in all_tables:
718
+ if all(s.get("table") != t for s in existing):
719
+ existing.append({"table": t, "column": "*", "confidence": 0.5})
720
+ continue
721
+ else:
722
+ col_sql = safe_sql(select_expr, dialect)
723
+ col_name = col_sql if col_sql is not None and len(col_sql) < 50 else None
724
+ source_expr = select_expr
725
+ is_computed = True
726
+
727
+ if not col_name:
728
+ continue
729
+
730
+ sources: list[dict] = []
731
+ seen: set[tuple[str, str]] = set()
732
+
733
+ # qualify() rewrites a read of a table-valued alias (CROSS JOIN
734
+ # jsonb_array_elements_text(roles) AS role ... SELECT role) into a
735
+ # TableColumn node, which the Column sweep never sees (kingfisher,
736
+ # holdout round 7)
737
+ tc_cls = getattr(exp, "TableColumn", None)
738
+ if tc_cls is not None:
739
+ for tc in source_expr.find_all(tc_cls):
740
+ entry = expansions.get(tc.name) or expansions.get(tc.name.lower())
741
+ if not entry:
742
+ continue
743
+ reads = [(entry.get("source_table", ""), entry.get("source_column", ""))] + [
744
+ (x.get("source_table", ""), x.get("source_column", ""))
745
+ for x in entry.get("extra_columns", [])
746
+ ]
747
+ for key in reads:
748
+ if key not in seen and (key[0] or key[1]):
749
+ seen.add(key)
750
+ sources.append(
751
+ {
752
+ "table": key[0],
753
+ "column": key[1],
754
+ "confidence": 0.5,
755
+ "inferred": False,
756
+ "from_array_expansion": True,
757
+ }
758
+ )
759
+
760
+ for col in source_expr.find_all(exp.Column):
761
+ if under_subquery_where(col, source_expr):
762
+ continue
763
+ if in_window_ordering(col, source_expr):
764
+ continue
765
+ if in_selector_argument(col, source_expr):
766
+ continue
767
+ alias, column, _field_path, _certain = resolve_column_ref(col, _known)
768
+ if "{" in column or (not _certain and any("{" in p for p in column_parts(col))):
769
+ # a {placeholder} or jinja chain in column/qualifier position
770
+ # has no citable identity; anchoring it fabricated a struct
771
+ # root on the FROM table (cycle-12, F11/F19)
772
+ continue
773
+
774
+ expansion = None
775
+ if _certain and alias:
776
+ expansion = expansions.get(alias)
777
+ elif _certain and not alias:
778
+ expansion = expansions.get(column)
779
+ elif not _certain:
780
+ expansion = scoped_unnest_entry(
781
+ col, source_expr, alias_map, all_tables
782
+ ) or expansions.get("__anonymous_unnest__")
783
+
784
+ if expansion is not None:
785
+ table = expansion.get("source_table") or default_table
786
+ array_column = expansion["source_column"]
787
+ selective = expansion_field_reads(expansion, col, column, _field_path)
788
+ if selective is not None:
789
+ expanded = [
790
+ (e.get("source_table") or default_table, e.get("source_column", ""))
791
+ for e in selective
792
+ ]
793
+ else:
794
+ expanded = [(table, array_column)] + [
795
+ (e.get("source_table") or default_table, e.get("source_column", ""))
796
+ for e in expansion.get("extra_columns", [])
797
+ ]
798
+ for exp_table, exp_column in expanded:
799
+ key = (exp_table, exp_column)
800
+ if key not in seen and (exp_table or exp_column):
801
+ seen.add(key)
802
+ sources.append(
803
+ {
804
+ "table": exp_table,
805
+ "column": exp_column,
806
+ "confidence": 0.5,
807
+ "inferred": False,
808
+ "from_array_expansion": True,
809
+ }
810
+ )
811
+ continue
812
+
813
+ if _certain and not alias and allow_lateral and column.lower() in lateral_aliases:
814
+ # lateral column alias defined earlier in this list
815
+ for src in lateral_aliases[column.lower()]:
816
+ key = (src.get("table", ""), src.get("column", ""))
817
+ if key not in seen and (key[0] or key[1]):
818
+ seen.add(key)
819
+ sources.append({**src, "via_lateral_alias": True})
820
+ continue
821
+
822
+ if not _certain:
823
+ # struct-root read: anchor to the sole relation at reduced
824
+ # confidence rather than fabricating the leading part
825
+ table = default_table or join_anchor or ""
826
+ confidence = 0.5
827
+ elif alias and (sub_map := _sub_map_for(alias, qualifier_is_quoted(col))) is not None:
828
+ for src in subquery_column_entries(sub_map, column):
829
+ key = (src.get("table", ""), src.get("column", ""))
830
+ if key not in seen and (key[0] or key[1]):
831
+ seen.add(key)
832
+ sources.append(dict(src))
833
+ continue
834
+ elif alias:
835
+ table = alias_map.get(alias, alias)
836
+ confidence = 1.0
837
+ elif (anon := subquery_columns_by_alias.get("")) is not None and any(
838
+ k != "*" and k.lower() == column.lower() for k in anon
839
+ ):
840
+ # unqualified read of a column the unaliased from-subquery
841
+ # projects (transfermarkt's `where n = 1` wrapper)
842
+ for src in subquery_column_entries(anon, column):
843
+ key = (src.get("table", ""), src.get("column", ""))
844
+ if key not in seen and (key[0] or key[1]):
845
+ seen.add(key)
846
+ sources.append(dict(src))
847
+ continue
848
+ elif (
849
+ not local_real_table
850
+ and all("*" not in m for m in subquery_columns_by_alias.values())
851
+ and len(
852
+ owner_maps := [
853
+ m
854
+ for m in subquery_columns_by_alias.values()
855
+ if any(
856
+ k != "*"
857
+ and not str(k).startswith(META_PREFIX)
858
+ and str(k).lower() == column.lower()
859
+ for k in m
860
+ )
861
+ ]
862
+ )
863
+ == 1
864
+ ):
865
+ # this select reads only derived tables, and exactly one
866
+ # projects the column: anchoring to a table found INSIDE
867
+ # the subquery skips the derivation (kingfisher's role
868
+ # expansion read through id_role, holdout round 7)
869
+ for src in subquery_column_entries(owner_maps[0], column):
870
+ key = (src.get("table", ""), src.get("column", ""))
871
+ if key not in seen and (key[0] or key[1]):
872
+ seen.add(key)
873
+ sources.append(dict(src))
874
+ continue
875
+ elif (nested := nested_scope_sole_table(col, select_node)) is not None:
876
+ # the column lives in a subselect with its own FROM; the
877
+ # outer scope is the wrong place to anchor it (review
878
+ # of PR #28: correlated scalar subqueries)
879
+ table = nested
880
+ confidence = 0.8 if nested else 0.5
881
+ elif default_table:
882
+ table = default_table
883
+ confidence = 0.8
884
+ elif len(all_tables) > 1 and (
885
+ owner := system_catalog_owner(column, all_tables, dialect)
886
+ ):
887
+ # every relation in scope is a documented sys object and
888
+ # exactly one owns the column (InvestigateWaits, round 8);
889
+ # documented ownership outranks the join-anchor guess
890
+ # (cycle-9 review, F5)
891
+ table = owner
892
+ confidence = 0.8
893
+ elif join_anchor:
894
+ table = join_anchor
895
+ confidence = 0.6
896
+ else:
897
+ table = ""
898
+ confidence = 0.5
899
+
900
+ key = (table, column)
901
+ if key not in seen and (table or column):
902
+ seen.add(key)
903
+ sources.append(
904
+ {
905
+ "table": table,
906
+ "column": column,
907
+ "confidence": confidence,
908
+ "inferred": alias == "" and table != "",
909
+ }
910
+ )
911
+
912
+ if not sources and is_computed:
913
+ sources = [{"table": "", "column": col_name, "computed": True, "confidence": 0.3}]
914
+
915
+ if isinstance(select_expr, exp.Alias) and sources:
916
+ lateral_aliases.setdefault(col_name.lower(), sources)
917
+
918
+ columns[col_name] = sources
919
+
920
+ return columns
921
+
922
+
923
+ def extract_cte_column_mappings(
924
+ parsed: "exp.Expression",
925
+ dialect: str,
926
+ ) -> dict[str, dict[str, list[dict]]]:
927
+ """Extract column lineage from each CTE in the query.
928
+
929
+ Returns a mapping: CTE name -> {column_name -> [sources]}
930
+
931
+ This enables recursive tracing through CTEs.
932
+ """
933
+ from sqlglot import exp
934
+
935
+ cte_columns: dict[str, dict[str, list[dict]]] = {}
936
+
937
+ # nested WITHs flatten into one map, so a name defined twice (an inner
938
+ # CTE shadowing an outer one) resolves to neither; recursive CTEs read
939
+ # their own name legitimately and it must not pass for a real relation
940
+ # (the cycle-7 review)
941
+ counts: dict[str, int] = {}
942
+ recursive_names: set[str] = set()
943
+ for with_node in parsed.find_all(exp.With):
944
+ if with_node.args.get("recursive"):
945
+ for cte in with_node.expressions:
946
+ if cte.alias:
947
+ recursive_names.add(restore_jinja_dots(cte.alias.lower()))
948
+ for cte in parsed.find_all(exp.CTE):
949
+ if cte.alias:
950
+ key = restore_jinja_dots(cte.alias.lower())
951
+ counts[key] = counts.get(key, 0) + 1
952
+
953
+ per_name: dict[str, list[dict]] = {}
954
+ for cte in parsed.find_all(exp.CTE):
955
+ # keys restored so a templated CTE name agrees with the restored
956
+ # table reference that reads it (cycle-12, F10)
957
+ cte_name = restore_jinja_dots(cte.alias.lower()) if cte.alias else ""
958
+ if not cte_name:
959
+ continue
960
+
961
+ cte_select = unwrap_select(cte.this)
962
+ if isinstance(cte_select, exp.Select):
963
+ entry = extract_select_column_sources(cte_select, dialect)
964
+ elif isinstance(cte_select, (exp.Union, exp.Except, exp.Intersect)):
965
+ entry = extract_set_op_column_sources(cte_select, dialect)
966
+ else:
967
+ continue
968
+ # relations the CTE reads, for the sole-source fallthrough: a joined
969
+ # relation whose loop-generated projection vanished offline is
970
+ # invisible in the projected sources but not in the FROM scope
971
+ entry[META_CTE_RELATIONS] = sorted(
972
+ {
973
+ qualified_table_name(t)
974
+ for t in cte_select.find_all(exp.Table)
975
+ if qualified_table_name(t)
976
+ }
977
+ )
978
+ if cte_name in recursive_names:
979
+ entry[META_CTE_RECURSIVE] = True
980
+ per_name.setdefault(cte_name, []).append(entry)
981
+
982
+ for cte_name, entries in per_name.items():
983
+ keeper = entries[-1] # find_all is pre-order: last = innermost
984
+ if len(entries) > 1 and not all(_references_only(e, cte_name) for e in entries[:-1]):
985
+ # genuinely different definitions share the name; the flat map
986
+ # cannot know which one a reference means
987
+ keeper = dict(keeper)
988
+ keeper[META_CTE_SHADOWED] = True
989
+ cte_columns[cte_name] = keeper
990
+
991
+ return cte_columns
992
+
993
+
994
+ def _references_only(entry: dict, name: str) -> bool:
995
+ """True when a CTE definition's column sources reference nothing but
996
+ `name` itself: a pure self-wrapper. Macro-emitted models nest WITH X
997
+ inside WITH X (... SELECT * FROM X), where the innermost definition is
998
+ the meaning everywhere; only materially different definitions make the
999
+ name ambiguous."""
1000
+ return {
1001
+ (src.get("table") or "").lower()
1002
+ for key, sources in entry.items()
1003
+ if not key.startswith(META_PREFIX)
1004
+ for src in sources
1005
+ if src.get("table") and not src.get("computed")
1006
+ } <= {name}
1007
+
1008
+
1009
+ def _unknown_source(column: str) -> dict:
1010
+ return {"table": "", "column": column, "computed": True, "confidence": 0.3}
1011
+
1012
+
1013
+ def _cte_sole_relation(cte_cols: dict) -> str | None:
1014
+ """The single relation a CTE reads from, or None if it reads several.
1015
+
1016
+ Prefers the FROM-scope relations recorded at extraction: a joined
1017
+ relation whose loop-generated projection vanished offline is invisible
1018
+ in the projected sources but was still read, and guessing the other
1019
+ relation would be wrong-confident (the cycle-7 review). Falls back
1020
+ to the tables the surviving column sources name."""
1021
+ relations = cte_cols.get(META_CTE_RELATIONS)
1022
+ if relations is not None:
1023
+ return relations[0] if len(relations) == 1 else None
1024
+ inferred = {
1025
+ src["table"]
1026
+ for key, sources in cte_cols.items()
1027
+ if not key.startswith(META_PREFIX)
1028
+ for src in sources
1029
+ if src.get("table") and not src.get("computed")
1030
+ }
1031
+ return next(iter(inferred)) if len(inferred) == 1 else None
1032
+
1033
+
1034
+ def _dedupe(results: list[dict]) -> list[dict]:
1035
+ """Collapse duplicate (table, column) entries, keeping the most
1036
+ confident. Branching CTE DAGs (two CTEs per level, each reading both
1037
+ parents) otherwise multiply identical paths exponentially — 2^18
1038
+ entries at depth 18 (the cycle-7 review)."""
1039
+ best: dict[tuple, dict] = {}
1040
+ order: list[tuple] = []
1041
+ for entry in results:
1042
+ key = (
1043
+ (entry.get("table") or "").lower(),
1044
+ (entry.get("column") or "").lower(),
1045
+ bool(entry.get("computed")),
1046
+ )
1047
+ held = best.get(key)
1048
+ if held is None:
1049
+ best[key] = entry
1050
+ order.append(key)
1051
+ elif entry.get("confidence", 1.0) > held.get("confidence", 1.0):
1052
+ best[key] = entry
1053
+ return [best[k] for k in order]
1054
+
1055
+
1056
+ def _seal(
1057
+ entries: list[dict],
1058
+ cte_columns: dict[str, dict[str, list[dict]]],
1059
+ shadow: str = "",
1060
+ ) -> list[dict]:
1061
+ """A CTE is a statement-scoped name, never a relation; reporting one as a
1062
+ source is always wrong (holdout round 6 scored issue_multiselect_history
1063
+ and set_values as tables). Anything still CTE-named degrades to
1064
+ computed/unknown.
1065
+
1066
+ `shadow` is the CTE whose own body the entries came from: SQL forbids
1067
+ non-recursive self-reference, so that one spelling denotes the real
1068
+ relation the CTE shadows (the dbt import idiom, `with orders as
1069
+ (select * from orders)`) and must survive the seal."""
1070
+ return [
1071
+ e
1072
+ if (t := (e.get("table") or "").lower()) not in cte_columns or t == shadow
1073
+ else _unknown_source(e.get("column", ""))
1074
+ for e in entries
1075
+ ]
1076
+
1077
+
1078
+ def trace_through_ctes(
1079
+ column: str,
1080
+ table: str,
1081
+ cte_columns: dict[str, dict[str, list[dict]]],
1082
+ visited: set[str] | None = None,
1083
+ max_depth: int = 32,
1084
+ _budget: list[int] | None = None,
1085
+ ) -> list[dict]:
1086
+ """Recursively trace a column through CTEs to find actual sources.
1087
+
1088
+ Args:
1089
+ column: Column name to trace
1090
+ table: Table/CTE name the column comes from
1091
+ cte_columns: CTE name -> column mappings
1092
+ visited: Set of visited (table, column) pairs to prevent cycles
1093
+ max_depth: Maximum recursion depth (dbt_jira's pivot chains twelve
1094
+ CTEs, holdout round 6; the cap only guards pathological fanout)
1095
+
1096
+ Returns:
1097
+ List of source columns with full tracing through CTEs
1098
+ """
1099
+ if visited is None:
1100
+ visited = set()
1101
+ if _budget is None:
1102
+ # visited is copied per branch, so a branching DAG (two CTEs per
1103
+ # level, each reading both parents) re-explores 2^n paths inside
1104
+ # the depth cap. The shared budget bounds total work; exhaustion
1105
+ # degrades to unknown, never to a wrong answer (review of
1106
+ # cycle 7). Real chains are linear and use a few hundred calls.
1107
+ _budget = [50_000]
1108
+ _budget[0] -= 1
1109
+ if _budget[0] <= 0:
1110
+ return [_unknown_source(column)]
1111
+
1112
+ key = f"{table.lower()}.{column.lower()}"
1113
+ if key in visited or max_depth <= 0:
1114
+ return []
1115
+
1116
+ visited.add(key)
1117
+
1118
+ table_lower = table.lower()
1119
+ if table_lower not in cte_columns:
1120
+ # Not a CTE - this is an actual table reference
1121
+ return [{"table": table, "column": column, "confidence": 1.0}]
1122
+
1123
+ # It's a CTE - look up the column's sources
1124
+ cte_cols = cte_columns[table_lower]
1125
+
1126
+ if cte_cols.get(META_CTE_SHADOWED):
1127
+ # the name is defined more than once (nested WITH shadowing); the
1128
+ # flat map cannot know which definition a reference means
1129
+ return [_unknown_source(column)]
1130
+ self_is_real = not cte_cols.get(META_CTE_RECURSIVE)
1131
+
1132
+ if column == "*" and "*" in cte_cols:
1133
+ return _seal(cte_cols["*"], cte_columns, shadow=table_lower if self_is_real else "")
1134
+
1135
+ if column not in cte_cols and column.lower() not in {k.lower() for k in cte_cols}:
1136
+ # Column not found in CTE - might be SELECT * situation
1137
+ if "*" in cte_cols:
1138
+ star_sources = cte_cols["*"]
1139
+ results = []
1140
+ for src in star_sources:
1141
+ if (src.get("table") or "").lower() == table_lower:
1142
+ # self-named import CTE: the spelling is the real
1143
+ # relation it shadows, not a recursion target (a
1144
+ # RECURSIVE cte's self-read is the cte and adds nothing)
1145
+ if self_is_real:
1146
+ results.append(
1147
+ {
1148
+ "table": src["table"],
1149
+ "column": column,
1150
+ "confidence": src.get("confidence", 1.0),
1151
+ }
1152
+ )
1153
+ continue
1154
+ traced = trace_through_ctes(
1155
+ column,
1156
+ src["table"],
1157
+ cte_columns,
1158
+ visited.copy(),
1159
+ max_depth - 1,
1160
+ _budget,
1161
+ )
1162
+ results.extend(traced)
1163
+ return _dedupe(results) if results else [_unknown_source(column)]
1164
+ # Explicit projection without the column: offline rendering loses
1165
+ # loop-generated select items (dbt_jira's adapter.get_columns_in_
1166
+ # relation loops), so a sole-source CTE passes the read through at
1167
+ # guess confidence; several sources would be a coin flip, refuse.
1168
+ sole = _cte_sole_relation(cte_cols)
1169
+ if sole is not None and sole.lower() == table_lower:
1170
+ if self_is_real:
1171
+ return [{"table": sole, "column": column, "confidence": 0.4}]
1172
+ return [_unknown_source(column)]
1173
+ if sole is not None:
1174
+ traced = trace_through_ctes(
1175
+ column, sole, cte_columns, visited.copy(), max_depth - 1, _budget
1176
+ )
1177
+ if traced:
1178
+ return [
1179
+ {**t, "confidence": min(t.get("confidence", 1.0), 0.4)}
1180
+ for t in _seal(traced, cte_columns)
1181
+ ]
1182
+ return [_unknown_source(column)]
1183
+
1184
+ # Find the column (case-insensitive)
1185
+ col_key = column
1186
+ for k in cte_cols:
1187
+ if k.lower() == column.lower():
1188
+ col_key = k
1189
+ break
1190
+
1191
+ sources = cte_cols.get(col_key, [])
1192
+ if not sources:
1193
+ return [_unknown_source(column)]
1194
+
1195
+ # Recursively trace each source
1196
+ results = []
1197
+ for src in sources:
1198
+ if src.get("computed"):
1199
+ results.append(src)
1200
+ elif src.get("table"):
1201
+ if src["table"].lower() == table_lower:
1202
+ # self-named import CTE (with orders as (select * from
1203
+ # orders)): the inner spelling denotes the real relation
1204
+ # the CTE shadows, never a recursive lookup. A RECURSIVE
1205
+ # cte's self-read IS the cte and contributes nothing.
1206
+ if self_is_real:
1207
+ results.append(dict(src))
1208
+ continue
1209
+ traced = trace_through_ctes(
1210
+ src["column"],
1211
+ src["table"],
1212
+ cte_columns,
1213
+ visited.copy(),
1214
+ max_depth - 1,
1215
+ _budget,
1216
+ )
1217
+ if traced:
1218
+ # a hop through an uncertain mapping (array expansion,
1219
+ # struct guess) never comes out more confident than it went in
1220
+ src_confidence = src.get("confidence", 1.0)
1221
+ for t in traced:
1222
+ if t.get("confidence", 1.0) > src_confidence:
1223
+ t = {**t, "confidence": src_confidence}
1224
+ results.append(t)
1225
+ else:
1226
+ # budget or cycle exhaustion mid-chain: the raw src may still
1227
+ # be CTE-qualified and must not surface as a relation
1228
+ results.extend(_seal([src], cte_columns))
1229
+ else:
1230
+ results.append(src)
1231
+
1232
+ return _dedupe(results)
1233
+
1234
+
1235
+ def star_except_spellings(idents) -> frozenset[str]:
1236
+ """The spellings a star's EXCEPT/EXCLUDE list names.
1237
+
1238
+ Unquoted (or already-lowercase) identifiers fold and are stored
1239
+ lowercase. A quoted mixed-case identifier (Snowflake EXCLUDE
1240
+ ("MiXeD")) names exactly that column and keeps its case, so it can
1241
+ never collapse onto the unquoted column of the same letters
1242
+ (cycle-12, F20)."""
1243
+ out = set()
1244
+ for c in idents:
1245
+ name = getattr(c, "name", "") or ""
1246
+ if not name:
1247
+ continue
1248
+ ident = getattr(c, "this", None)
1249
+ quoted = bool(getattr(ident, "quoted", False) or getattr(c, "args", {}).get("quoted"))
1250
+ out.add(name if quoted and name != name.lower() else name.lower())
1251
+ return frozenset(out)
1252
+
1253
+
1254
+ def star_excepted(name: str, excepts) -> bool:
1255
+ """True when a star exclusion covers this column spelling: exact match
1256
+ for quoted mixed-case exclusions, case-folded for the rest."""
1257
+ return bool(name) and (name in excepts or name.lower() in excepts)
1258
+
1259
+
1260
+ def expand_star_columns(
1261
+ relations: list[str],
1262
+ cte_columns: dict[str, dict[str, list[dict]]],
1263
+ except_cols: set[str] | frozenset[str] = frozenset(),
1264
+ ) -> tuple[dict[str, list[dict]], list[dict]]:
1265
+ """Expand a star over known CTEs into their named output columns.
1266
+
1267
+ Walks star-passthrough chains (a CTE whose own select is `*` or `rel.*`
1268
+ over another relation) so named columns anywhere down the chain surface
1269
+ as outputs, each traced to its real sources. Relations the chain cannot
1270
+ resolve (real tables) come back as residual wildcard sources for
1271
+ schema-level expansion. except_cols (lowercase) are excluded at every
1272
+ level. Returns (named_columns, residual_star_sources).
1273
+ """
1274
+ named: dict[str, list[dict]] = {}
1275
+ residual: list[dict] = []
1276
+ seen_relations: set[str] = set()
1277
+
1278
+ def walk(rel: str) -> None:
1279
+ rel_lower = rel.lower()
1280
+ if rel_lower in seen_relations:
1281
+ return
1282
+ seen_relations.add(rel_lower)
1283
+ cols = cte_columns.get(rel_lower)
1284
+ if cols is None:
1285
+ entry: dict = {"table": rel, "column": "*", "confidence": 0.5}
1286
+ if except_cols:
1287
+ # the graph retains these so a named column can later
1288
+ # resolve through the star unless excepted (class D)
1289
+ entry["except_columns"] = sorted(except_cols)
1290
+ residual.append(entry)
1291
+ return
1292
+ for name in cols:
1293
+ if name == "*" or name.startswith(META_PREFIX):
1294
+ continue
1295
+ if star_excepted(name, except_cols) or name in named:
1296
+ continue
1297
+ traced = trace_through_ctes(name, rel, cte_columns)
1298
+ # rel is a CTE here (cols is not None); its name must never
1299
+ # stand in as a relation when tracing comes back empty
1300
+ named[name] = traced or [_unknown_source(name)]
1301
+ for src in cols.get("*", []):
1302
+ src_table = src.get("table") or ""
1303
+ if src_table:
1304
+ walk(src_table)
1305
+
1306
+ for rel in relations:
1307
+ if rel:
1308
+ walk(rel)
1309
+ return named, residual