Skip to content

Commit 0237567

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): SELECT * star expansion — corpus 53 -> 172 match (TASK-46)
Binder::expand_star at the single projection site: plain * and tbl.* in measured DuckDB order (FROM order, declared column order), EXCLUDE in bare/paren forms case-insensitively with DuckDB-mirrored binder errors, mixed *,expr items via item-position expansion. Star over a joined static table rejects cleanly naming the join key (DuckDB includes the key and emits duplicate output names there — both unrepresentable in the typed output model); __THIS__.* under a join works. ILIKE/EXCEPT/REPLACE/RENAME/COLUMNS() reject by name. Corpus replay: 53 -> 172 match of 678 (+119), zero FAILs. Gate green (cargo 129, pytest 627). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cbf5c5f commit 0237567

4 files changed

Lines changed: 272 additions & 17 deletions

File tree

backlog/tasks/task-46 - Specializer-SQL-support-SELECT-star-expansion.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
---
22
id: TASK-46
33
title: 'Specializer SQL support: SELECT * star expansion'
4-
status: To Do
4+
status: In Progress
55
assignee: []
66
created_date: '2026-07-26 11:42'
7+
updated_date: '2026-07-26 11:55'
78
labels: []
89
milestone: m-7
910
dependencies:
@@ -22,8 +23,26 @@ The single biggest corpus rung: 128 of 625 clean-unsupported corpus cases have `
2223

2324
## Acceptance Criteria
2425
<!-- AC:BEGIN -->
25-
- [ ] #1 Star semantics pinned by measurement against DuckDB 1.5.5 (column order, duplicate-name policy, qualified tbl.*, modifier handling) and recorded as duck_check tests
26-
- [ ] #2 Corpus replay: star-first-blocker cases flip to match or to a NAMED deeper blocker; zero FAILs; match count and the new first-blocker tally recorded here
27-
- [ ] #3 Unsupported star forms (if any remain) reject with a clean "unsupported: ..." naming the form
28-
- [ ] #4 mise gate-specializer green
26+
- [x] #1 Star semantics pinned by measurement against DuckDB 1.5.5 (column order, duplicate-name policy, qualified tbl.*, modifier handling) and recorded as duck_check tests
27+
- [x] #2 Corpus replay: star-first-blocker cases flip to match or to a NAMED deeper blocker; zero FAILs; match count and the new first-blocker tally recorded here
28+
- [x] #3 Unsupported star forms (if any remain) reject with a clean "unsupported: ..." naming the form
29+
- [x] #4 mise gate-specializer green
2930
<!-- AC:END -->
31+
32+
## Implementation Plan
33+
34+
<!-- SECTION:PLAN:BEGIN -->
35+
Stretch plan (recorded 2026-07-26 after the measurement spike; all facts below are measured on DuckDB 1.5.5).
36+
Corpus star census: 156 plain `SELECT *`, 26 `tbl.*`, 16 `* EXCLUDE`, 13 `COLUMNS(...)`, 3 `* LIKE`, 2 `* REPLACE`, 2 mixed. Wave scope: plain `*`, `tbl.*`, `EXCLUDE` (~198 mentions); COLUMNS()/REPLACE/LIKE/RENAME/EXCEPT/ILIKE reject by name. All forms already parse under sqlparser 0.62 and funnel to the single rejection at frontend.rs:125 — one expansion site.
37+
Measured pins: expansion order is FROM order then declared column order per table; `SELECT * FROM a JOIN b ON a.k=b.k` emits DUPLICATE column names ('k','x','k','y') — unrepresentable in a pydantic output model, and any star item covering a joined static table necessarily includes its join-key column, whose projection our engine already cleanly rejects; therefore star items that cover a static table reject cleanly naming the key column (covers the duplicate-name shape too). `__THIS__.*` under a join is fine (row columns only). EXCLUDE: paren and bare forms; case-insensitive; unknown name = binder error mirroring DuckDB ("Column \"nope\" in EXCLUDE list not found"); duplicate list entry = error ("Duplicate entry"); unqualified EXCLUDE removes every same-named column.
38+
1. Binder::expand_star(qualifier, options) -> Vec<(name, SExpr)> using the existing SKind::Col / static_lane constructors; wire into the projection loop (mixed `*, expr` items fall out of item-position expansion); existing duplicate-output-name check stays as the final guard.
39+
2. duck_check pins for every measured behavior above incl. the EXCLUDE edges (exclude-all, EXCLUDE K case-folding) and clean-unsupported messages for the rejected forms.
40+
3. Corpus replay re-tally into this ticket (AC #2): flips + newly-surfaced second blockers.
41+
4. Gate green.
42+
<!-- SECTION:PLAN:END -->
43+
44+
## Implementation Notes
45+
46+
<!-- SECTION:NOTES:BEGIN -->
47+
Landed in one pass: Binder::expand_star (frontend.rs) expands `*` and `tbl.*` at the single projection site using the existing SKind::Col / static_lane constructors — no IR/backend/boundary changes, exactly as scoped. EXCLUDE handles bare + paren forms case-insensitively (sqlparser 0.62 carries entries as ObjectName; single-part idents only, qualified entries reject by name); unknown-column and duplicate-entry EXCLUDE mirror DuckDB's binder errors; exclude-all falls through to the existing empty-SELECT bind error (same shape as DuckDB's "SELECT list is empty after resolving * expressions"). Star items covering a joined static table reject cleanly naming the join-key column (DuckDB includes the key AND emits duplicate output names there — both unrepresentable; `__THIS__.*` under a join works). ILIKE/EXCEPT/REPLACE/RENAME/COLUMNS() reject by name. CORPUS RESULT (AC #2): 53 -> 172 match of 678 (+119, a 3.2x coverage jump), 0 FAILs, 506 clean-unsupported. New first-blocker head: BETWEEN 31, comma join 30, dynamic-table alias 30, table-function FROM 24, then the builtin catalogue (array_slice 23, contains 18, damerau_levenshtein 17, ...) — the TASK-47 target list confirmed. Gate green: cargo 129 + pytest 627 (13 xfail incl. the pre-existing substr const-fold pin from master).
48+
<!-- SECTION:NOTES:END -->

src/specializer/frontend.rs

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,11 @@ pub fn frontend(
115115

116116
let mut out_cols = Vec::new();
117117
let mut exprs = Vec::new();
118-
for item in &select.projection {
119-
let (name, e) = match item {
120-
SelectItem::UnnamedExpr(e) => (default_name(e), fold(binder.expr(e)?)),
121-
SelectItem::ExprWithAlias { expr, alias } => {
122-
(alias.value.clone(), fold(binder.expr(expr)?))
123-
}
124-
SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(..) => {
125-
return Err(unsup("SELECT * (star expansion)"))
126-
}
127-
SelectItem::ExprWithAliases { .. } => return Err(unsup("multi-alias SELECT item")),
128-
};
118+
let push_item = |out_cols: &mut Vec<Col>,
119+
exprs: &mut Vec<SExpr>,
120+
name: String,
121+
e: SExpr|
122+
-> Result<(), PrepareError> {
129123
if out_cols.iter().any(|c: &Col| c.name == name) {
130124
// DuckDB allows duplicate output names; our IR requires unique
131125
// columns. Rare in real queries — punt cleanly for now.
@@ -139,6 +133,40 @@ pub fn frontend(
139133
},
140134
});
141135
exprs.push(e);
136+
Ok(())
137+
};
138+
for item in &select.projection {
139+
match item {
140+
SelectItem::UnnamedExpr(e) => push_item(
141+
&mut out_cols,
142+
&mut exprs,
143+
default_name(e),
144+
fold(binder.expr(e)?),
145+
)?,
146+
SelectItem::ExprWithAlias { expr, alias } => push_item(
147+
&mut out_cols,
148+
&mut exprs,
149+
alias.value.clone(),
150+
fold(binder.expr(expr)?),
151+
)?,
152+
SelectItem::Wildcard(opts) => {
153+
for (name, e) in binder.expand_star(None, opts)? {
154+
push_item(&mut out_cols, &mut exprs, name, e)?;
155+
}
156+
}
157+
SelectItem::QualifiedWildcard(kind, opts) => {
158+
let table = match kind {
159+
sqlparser::ast::SelectItemQualifiedWildcardKind::ObjectName(n) => n.to_string(),
160+
sqlparser::ast::SelectItemQualifiedWildcardKind::Expr(_) => {
161+
return Err(unsup("expression.* wildcard"))
162+
}
163+
};
164+
for (name, e) in binder.expand_star(Some(&table), opts)? {
165+
push_item(&mut out_cols, &mut exprs, name, e)?;
166+
}
167+
}
168+
SelectItem::ExprWithAliases { .. } => return Err(unsup("multi-alias SELECT item")),
169+
};
142170
}
143171
if exprs.is_empty() {
144172
return Err(PrepareError::Bind("SELECT list is empty".to_string()));
@@ -450,6 +478,98 @@ struct Binder<'a> {
450478
}
451479

452480
impl Binder<'_> {
481+
/// Expand `*` / `tbl.*` per DuckDB's measured semantics (1.5.5): FROM
482+
/// order, declared column order within a table, EXCLUDE filtered
483+
/// case-insensitively. A star item covering a joined static table is
484+
/// unsupported: DuckDB's expansion includes the join-key column there
485+
/// (and emits duplicate output names for the shared key), neither of
486+
/// which the engine models — rejected by name, not silently narrowed.
487+
fn expand_star(
488+
&self,
489+
qualifier: Option<&str>,
490+
opts: &sqlparser::ast::WildcardAdditionalOptions,
491+
) -> Result<Vec<(String, SExpr)>, PrepareError> {
492+
use sqlparser::ast::ExcludeSelectItem;
493+
if opts.opt_ilike.is_some() {
494+
return Err(unsup("SELECT * ILIKE (COLUMNS filter)"));
495+
}
496+
if opts.opt_except.is_some() {
497+
return Err(unsup("SELECT * EXCEPT"));
498+
}
499+
if opts.opt_replace.is_some() {
500+
return Err(unsup("SELECT * REPLACE"));
501+
}
502+
if opts.opt_rename.is_some() {
503+
return Err(unsup("SELECT * RENAME"));
504+
}
505+
fn exclude_name(n: &sqlparser::ast::ObjectName) -> Result<&str, PrepareError> {
506+
match n.0.as_slice() {
507+
[part] => part
508+
.as_ident()
509+
.map(|i| i.value.as_str())
510+
.ok_or_else(|| unsup("EXCLUDE list entry form")),
511+
_ => Err(unsup("qualified name in EXCLUDE list")),
512+
}
513+
}
514+
let exclude: Vec<&str> = match &opts.opt_exclude {
515+
None => Vec::new(),
516+
Some(ExcludeSelectItem::Single(id)) => vec![exclude_name(id)?],
517+
Some(ExcludeSelectItem::Multiple(ids)) => ids
518+
.iter()
519+
.map(exclude_name)
520+
.collect::<Result<Vec<_>, _>>()?,
521+
};
522+
for (i, a) in exclude.iter().enumerate() {
523+
if exclude[..i].iter().any(|b| b.eq_ignore_ascii_case(a)) {
524+
// DuckDB rejects this at parse; ours surfaces at bind.
525+
return Err(PrepareError::Bind(format!(
526+
"duplicate entry \"{a}\" in EXCLUDE list"
527+
)));
528+
}
529+
}
530+
531+
let mut cols: Vec<(String, SExpr)> = Vec::new();
532+
let mut matched = false;
533+
if qualifier.is_none_or(|q| q.eq_ignore_ascii_case(&self.this_name)) {
534+
matched = true;
535+
for (i, c) in self.in_cols.iter().enumerate() {
536+
cols.push((
537+
c.name.clone(),
538+
SExpr {
539+
kind: SKind::Col(i as u32),
540+
ty: c.ty.ty,
541+
nullable: c.ty.nullable,
542+
},
543+
));
544+
}
545+
}
546+
for sj in &self.joins {
547+
if qualifier.is_none_or(|q| q.eq_ignore_ascii_case(&sj.name)) {
548+
let key = &sj.table.cols[sj.key_cols[0] as usize].name;
549+
return Err(unsup(format!(
550+
"star expansion over joined table '{}' (includes join key column '{key}')",
551+
sj.name
552+
)));
553+
}
554+
}
555+
if !matched {
556+
return Err(PrepareError::Bind(format!(
557+
"table '{}' in wildcard does not exist in FROM",
558+
qualifier.unwrap_or("?")
559+
)));
560+
}
561+
562+
for ex in &exclude {
563+
if !cols.iter().any(|(n, _)| n.eq_ignore_ascii_case(ex)) {
564+
return Err(PrepareError::Bind(format!(
565+
"column \"{ex}\" in EXCLUDE list not found in FROM clause"
566+
)));
567+
}
568+
}
569+
cols.retain(|(n, _)| !exclude.iter().any(|ex| ex.eq_ignore_ascii_case(n)));
570+
Ok(cols)
571+
}
572+
453573
/// Bind an expression that must have a definite type on its own — a bare
454574
/// NULL literal here is unsupported (no context to type it).
455575
fn expr(&self, e: &SqlExpr) -> Result<SExpr, PrepareError> {

src/specializer/tests.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,81 @@ fn bool_column_directly_in_where() {
397397
assert_eq!(got, rows(&[&["1"]]));
398398
}
399399

400+
#[test]
401+
fn star_expands_in_declared_order_with_exclude() {
402+
let schema = cols(&[
403+
("a", Ty::I64, false),
404+
("b", Ty::F64, true),
405+
("s", Ty::Str, false),
406+
]);
407+
let names = |p: &super::ir::Program| {
408+
p.out_cols
409+
.iter()
410+
.map(|c| c.name.clone())
411+
.collect::<Vec<_>>()
412+
};
413+
414+
let p = prep("SELECT * FROM __THIS__", &schema).unwrap();
415+
assert_eq!(names(&p), ["a", "b", "s"]);
416+
// Nullability and types ride along from the input schema.
417+
assert!(p.out_cols[1].ty.nullable && p.out_cols[1].ty.ty == Ty::F64);
418+
419+
// EXCLUDE is case-insensitive (measured: DuckDB drops k for EXCLUDE (K)),
420+
// both paren and bare forms; qualified star + mixed items compose.
421+
let p = prep("SELECT * EXCLUDE (B) FROM __THIS__", &schema).unwrap();
422+
assert_eq!(names(&p), ["a", "s"]);
423+
let p = prep("SELECT * EXCLUDE b FROM __THIS__", &schema).unwrap();
424+
assert_eq!(names(&p), ["a", "s"]);
425+
let p = prep("SELECT __THIS__.*, a + 1 AS a2 FROM __THIS__", &schema).unwrap();
426+
assert_eq!(names(&p), ["a", "b", "s", "a2"]);
427+
428+
// Star + explicit same column: DuckDB-legal duplicate names stay our
429+
// clean unsupported (the output model needs unique fields).
430+
match prep("SELECT *, a FROM __THIS__", &schema) {
431+
Err(PrepareError::Unsupported(m)) => assert!(m.contains("duplicate output column")),
432+
other => panic!("wanted duplicate-name unsupported, got {other:?}"),
433+
}
434+
// EXCLUDE of an unknown column is a bind error, mirroring DuckDB's
435+
// binder message.
436+
match prep("SELECT * EXCLUDE (nope) FROM __THIS__", &schema) {
437+
Err(PrepareError::Bind(m)) => assert!(m.contains("EXCLUDE list not found")),
438+
other => panic!("wanted EXCLUDE bind error, got {other:?}"),
439+
}
440+
// Excluding everything leaves an empty SELECT — bind error like DuckDB.
441+
match prep("SELECT * EXCLUDE (a, b, s) FROM __THIS__", &schema) {
442+
Err(PrepareError::Bind(m)) => assert!(m.contains("empty")),
443+
other => panic!("wanted empty-list bind error, got {other:?}"),
444+
}
445+
}
446+
447+
#[test]
448+
fn star_over_joined_table_rejects_by_name() {
449+
let schema = cols(&[("a", Ty::I64, false)]);
450+
let st = stat("dim", &[("id", Ty::I64, false), ("v", Ty::F64, false)]);
451+
for sql in [
452+
"SELECT * FROM __THIS__ JOIN dim ON a = dim.id",
453+
"SELECT dim.* FROM __THIS__ JOIN dim ON a = dim.id",
454+
] {
455+
match prepare(sql, "__THIS__", &schema, std::slice::from_ref(&st)) {
456+
Err(PrepareError::Unsupported(m)) => assert!(
457+
m.contains("star expansion over joined table") && m.contains("id"),
458+
"'{sql}': got '{m}'"
459+
),
460+
Err(other) => panic!("'{sql}': wrong error kind: {other}"),
461+
Ok(_) => panic!("'{sql}': unexpectedly prepared"),
462+
}
463+
}
464+
// Qualified star over the ROW table under a join is fine.
465+
let p = prepare(
466+
"SELECT __THIS__.*, dim.v AS v FROM __THIS__ JOIN dim ON a = dim.id",
467+
"__THIS__",
468+
&schema,
469+
std::slice::from_ref(&st),
470+
)
471+
.unwrap();
472+
assert_eq!(p.program.out_cols.len(), 2);
473+
}
474+
400475
#[test]
401476
fn unsupported_constructs_are_named_cleanly() {
402477
let schema = cols(&[("a", Ty::I64, false)]);
@@ -407,7 +482,9 @@ fn unsupported_constructs_are_named_cleanly() {
407482
("SELECT sum(a) FROM __THIS__", "function sum"),
408483
("SELECT a FROM __THIS__ GROUP BY a", "aggregation"),
409484
("SELECT length('x') FROM __THIS__", "function"),
410-
("SELECT * FROM __THIS__", "star expansion"),
485+
// Star now expands; the still-unsupported star forms reject by name.
486+
("SELECT * REPLACE (a + 1 AS a) FROM __THIS__", "REPLACE"),
487+
("SELECT COLUMNS('a') FROM __THIS__", "function COLUMNS"),
411488
("SELECT a FROM __THIS__ ORDER BY a", "ORDER BY"),
412489
("SELECT a, a FROM __THIS__", "duplicate output column"),
413490
("SELECT NULL FROM __THIS__", "NULL literal"),

tests/test_duckdb_interpreter.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,42 @@ def k(self):
543543
(m,) = fn.infer_rows([Row()])
544544
assert m.d == 14
545545
assert inner == [10] # the nested call completed via the generic path
546+
547+
548+
# --------------------------------------------------------- TASK-46:
549+
# SELECT * star expansion — measured DuckDB 1.5.5 pins (order, EXCLUDE
550+
# case-folding, mixed items) verified end-to-end against the oracle.
551+
552+
553+
def test_star_expansion_matches_duckdb():
554+
rows = [
555+
{"a": 1, "b": 2.5, "s": "x"},
556+
{"a": 2, "b": None, "s": "y"},
557+
]
558+
for sql in [
559+
"SELECT * FROM __THIS__",
560+
"SELECT __THIS__.* FROM __THIS__",
561+
"SELECT * EXCLUDE (b) FROM __THIS__",
562+
"SELECT * EXCLUDE B FROM __THIS__", # case-insensitive, bare form
563+
"SELECT *, a * 2 AS a2 FROM __THIS__",
564+
"SELECT * EXCLUDE (s), upper(s) AS u FROM __THIS__",
565+
]:
566+
duck_check(sql, {"a": "int", "b": "float?", "s": "str"}, rows)
567+
568+
569+
def test_star_qualified_over_row_table_under_join():
570+
duck_check(
571+
"SELECT __THIS__.*, dim.name AS nm FROM __THIS__ JOIN dim ON k = dim.id",
572+
{"k": "int"},
573+
[{"k": 1}, {"k": 2}, {"k": 99}],
574+
statics={"dim": DIM},
575+
)
576+
577+
578+
def test_star_over_joined_table_rejects_by_name():
579+
with pytest.raises(ValueError, match="star expansion over joined table"):
580+
DuckDBInferFn(
581+
"SELECT * FROM __THIS__ JOIN dim ON k = dim.id",
582+
row_tables={"__THIS__": _row_model({"k": "int"})},
583+
static_tables={"dim": DIM},
584+
)

0 commit comments

Comments
 (0)