ripple-sql 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|