Skip to content
This repository was archived by the owner on May 7, 2026. It is now read-only.

Commit ed03388

Browse files
authored
feat(wren): preserve SELECT * in CTE rewriter (#1536)
1 parent 1442288 commit ed03388

2 files changed

Lines changed: 110 additions & 6 deletions

File tree

wren/src/wren/mdl/cte_rewriter.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,13 @@ def rewrite(self, sql: str) -> str:
119119

120120
def _collect_model_columns(
121121
self, ast: exp.Expression, user_cte_names: set[str]
122-
) -> dict[str, list[str]]:
122+
) -> dict[str, list[str] | None]:
123123
"""Return ``{model_name: [col1, col2, ...]}`` for all referenced models.
124124
125+
A value of ``None`` means the model was referenced via ``SELECT *``
126+
and should be passed as-is to ``transform_sql`` so that wren-core
127+
can apply column-level access control (CLAC).
128+
125129
Uses sqlglot's ``qualify_columns`` to fully resolve all column
126130
references (including ``SELECT *`` expansion and
127131
correlated subquery outer references), then walks the qualified AST
@@ -131,6 +135,12 @@ def _collect_model_columns(
131135
copy = ast.copy()
132136
copy = qualify_tables(copy, dialect=self.dialect)
133137
copy = normalize_identifiers(copy, dialect=self.dialect)
138+
139+
# Detect models referenced via SELECT * BEFORE qualify_columns
140+
# expands the star. These will use SELECT * in transform_sql so
141+
# that wren-core controls column visibility (CLAC).
142+
star_models = self._detect_star_models(copy, user_cte_names)
143+
134144
qualified = qualify_columns(
135145
copy,
136146
schema=self.schema,
@@ -161,17 +171,22 @@ def _collect_model_columns(
161171
if model_name:
162172
used[model_name][col.name] = None
163173

164-
return {m: list(cols) for m, cols in used.items()}
174+
return {m: None if m in star_models else list(cols) for m, cols in used.items()}
165175

166176
# ------------------------------------------------------------------
167177
# CTE generation
168178
# ------------------------------------------------------------------
169179

170-
def _build_model_ctes(self, used_columns: dict[str, list[str]]) -> list[exp.CTE]:
180+
def _build_model_ctes(
181+
self, used_columns: dict[str, list[str] | None]
182+
) -> list[exp.CTE]:
171183
"""Generate one CTE per model via wren-core transform_sql."""
172184
ctes: list[exp.CTE] = []
173185
for model_name, columns in used_columns.items():
174-
if columns:
186+
if columns is None:
187+
# SELECT * — let wren-core handle column visibility (CLAC)
188+
col_list = "*"
189+
elif columns:
175190
orig = self._col_orig_name.get(model_name, {})
176191
resolved = [orig.get(c, c) for c in columns]
177192
col_list = ", ".join(f'"{model_name}"."{c}"' for c in resolved)
@@ -218,6 +233,43 @@ def _inject_ctes(self, ast: exp.Expression, model_ctes: list[exp.CTE]) -> None:
218233
# Helpers
219234
# ------------------------------------------------------------------
220235

236+
def _detect_star_models(
237+
self, ast: exp.Expression, user_cte_names: set[str]
238+
) -> set[str]:
239+
"""Detect models selected via ``*`` before column qualification.
240+
241+
A bare ``SELECT *`` marks all models; ``SELECT t.*`` marks only
242+
the referenced model.
243+
"""
244+
star_models: set[str] = set()
245+
select = ast.find(exp.Select)
246+
if not select:
247+
return star_models
248+
249+
# Build alias → model mapping from tables in FROM/JOIN
250+
alias_to_model: dict[str, str] = {}
251+
for table in ast.find_all(exp.Table):
252+
name = table.name
253+
if name not in self.model_dict or name in user_cte_names:
254+
continue
255+
alias = table.alias or name
256+
alias_to_model[alias] = name
257+
alias_to_model[name] = name
258+
259+
for sel_expr in select.expressions:
260+
if isinstance(sel_expr, exp.Star):
261+
# Bare * → all models
262+
star_models.update(alias_to_model.values())
263+
elif isinstance(sel_expr, exp.Column) and isinstance(
264+
sel_expr.this, exp.Star
265+
):
266+
# table.* → specific model
267+
table_ref = sel_expr.table
268+
if table_ref and table_ref in alias_to_model:
269+
star_models.add(alias_to_model[table_ref])
270+
271+
return star_models
272+
221273
@staticmethod
222274
def _collect_user_cte_names(ast: exp.Expression) -> set[str]:
223275
"""Collect all CTE names defined in the user's SQL (all scopes)."""

wren/tests/unit/test_cte_rewriter.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ def _count_ctes(sql: str, dialect: str = "duckdb") -> int:
121121
return len(with_clause.expressions)
122122

123123

124+
def _cte_body_sql(sql: str, cte_name: str, dialect: str = "duckdb") -> str | None:
125+
"""Return the SQL body of a named CTE, or None if not found."""
126+
ast = sqlglot.parse_one(sql, dialect=dialect)
127+
with_clause = ast.args.get("with_")
128+
if not with_clause:
129+
return None
130+
for cte in with_clause.expressions:
131+
alias = cte.args.get("alias")
132+
if alias and alias.this.name == cte_name:
133+
return cte.this.sql(dialect=dialect)
134+
return None
135+
136+
124137
# ---------------------------------------------------------------------------
125138
# Tests: basic model resolution
126139
# ---------------------------------------------------------------------------
@@ -134,10 +147,20 @@ def test_single_model_generates_cte(self):
134147
assert isinstance(result, str)
135148
assert len(result) > 0
136149

137-
def test_star_expansion(self):
150+
def test_star_not_expanded(self):
151+
"""SELECT * should pass star to wren-core so all columns are included."""
138152
rw = _make_rewriter(_SINGLE_MODEL_MANIFEST)
139153
result = rw.rewrite('SELECT * FROM "orders"')
140154
assert _has_cte(result, "orders")
155+
cte_body = _cte_body_sql(result, "orders")
156+
assert cte_body is not None
157+
# wren-core expands SELECT * into all model columns — verify every
158+
# non-hidden, non-relationship column is present in the CTE body.
159+
body_lower = cte_body.lower()
160+
for col_name in ("o_orderkey", "o_custkey", "o_orderstatus", "order_cust_key"):
161+
assert col_name.lower() in body_lower, (
162+
f"Expected column {col_name!r} in CTE body: {cte_body}"
163+
)
141164

142165
def test_alias_resolution(self):
143166
rw = _make_rewriter(_SINGLE_MODEL_MANIFEST)
@@ -163,10 +186,39 @@ def test_join_generates_two_ctes(self):
163186
assert _has_cte(result, "customer")
164187
assert _count_ctes(result) >= 2
165188

166-
def test_qualified_star(self):
189+
def test_qualified_star_not_expanded(self):
190+
"""SELECT table.* should pass star to wren-core so all columns appear."""
167191
rw = _make_rewriter(_MULTI_MODEL_MANIFEST)
168192
result = rw.rewrite('SELECT "orders".* FROM "orders"')
169193
assert _has_cte(result, "orders")
194+
cte_body = _cte_body_sql(result, "orders")
195+
assert cte_body is not None
196+
body_lower = cte_body.lower()
197+
for col_name in ("o_orderkey", "o_custkey", "o_orderstatus", "order_cust_key"):
198+
assert col_name.lower() in body_lower, (
199+
f"Expected column {col_name!r} in CTE body: {cte_body}"
200+
)
201+
202+
def test_mixed_star_and_explicit_columns(self):
203+
"""orders.* should include all columns; customer CTE only referenced ones."""
204+
rw = _make_rewriter(_MULTI_MODEL_MANIFEST)
205+
result = rw.rewrite(
206+
'SELECT "orders".*, c.c_name '
207+
'FROM "orders" JOIN "customer" c ON "orders".o_custkey = c.c_custkey'
208+
)
209+
assert _has_cte(result, "orders")
210+
assert _has_cte(result, "customer")
211+
# orders used star → all columns present
212+
orders_body = _cte_body_sql(result, "orders")
213+
assert orders_body is not None
214+
orders_lower = orders_body.lower()
215+
for col_name in ("o_orderkey", "o_custkey", "o_orderstatus", "order_cust_key"):
216+
assert col_name.lower() in orders_lower
217+
# customer used explicit column → only c_name (not necessarily c_custkey
218+
# in the select list, though it may appear in the subquery structure)
219+
customer_body = _cte_body_sql(result, "customer")
220+
assert customer_body is not None
221+
assert "c_name" in customer_body.lower()
170222

171223

172224
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)