Skip to content

Commit 0d8ed28

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): binder tail — lateral aliases, NATURAL JOIN, u(x,y), NULL-op typing, mixed IN literals — corpus 477 -> 484 (TASK-52 stage 6)
Per pins-wave5/binder-tail.json: lateral aliases with real-column-wins shadowing in SELECT and WHERE (WHERE now binds after the projection so DuckDB's alias-in-WHERE extension resolves; forward references get the pinned cannot-be-referenced-before-defined error); NULL <op> NULL types by operator (+ - * % BIGINT, / DOUBLE, comparisons BOOLEAN, ^@/|| VARCHAR); main.tbl resolves to the bare dynamic table while other schema qualifiers stay unsupported (DuckDB itself errors, never a bare-name fallback); NATURAL JOIN desugars to USING over common columns case-insensitively with the pinned hard error when none exist; t AS u(x, y) renames via a Cow column view (partial list = prefix rename, old names and the original table name fully shadowed, positions unchanged so marshalling still uses the original field names); BETWEEN/IN numeric-with-string/bool LITERALS cast to the numeric side (half-away rounding) — non-numeric literals stay clean-unsupported because DuckDB converts at EXECUTION time (an empty input succeeds; caught by corpus replay, not guessed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0cb4bd9 commit 0d8ed28

5 files changed

Lines changed: 363 additions & 65 deletions

backlog/tasks/task-52 - Specializer-wave-5-—-structural-dialect-sweep-slices-subscripts-operators-star-forms-dup-name-contract-binder-tail.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ title: >-
66
status: In Progress
77
assignee: []
88
created_date: '2026-07-26 21:46'
9-
updated_date: '2026-07-26 22:02'
9+
updated_date: '2026-07-26 22:52'
1010
labels: []
1111
milestone: m-7
1212
dependencies:
@@ -33,10 +33,10 @@ Out of wave: regexp family (wave B), lists/structs (wave C), stage-B multiplicit
3333
## Acceptance Criteria
3434
<!-- AC:BEGIN -->
3535
- [x] #1 Pins-first: wave-5 pins spec (md + JSON) committed before implementation, incl. a sqlparser-capability spike deciding parse strategy per dialect form
36-
- [ ] #2 VARCHAR slices s[a:b] serve with DuckDB semantics (bounds, negatives, NULL/dynamic bounds, out-of-range, non-ASCII)
37-
- [ ] #3 Extended subscripts serve: dynamic, negative, out-of-range indices per pins
38-
- [ ] #4 Operator/star dialect forms serve or reclassify cleanly: ^@, GLOB, << >>, * LIKE/ILIKE/SIMILAR, * REPLACE, * RENAME, qualified EXCLUDE, alias-prefix colon
39-
- [ ] #5 Duplicate output column names: contract pinned to DuckDB Python-client behavior, implemented across output modes
36+
- [x] #2 VARCHAR slices s[a:b] serve with DuckDB semantics (bounds, negatives, NULL/dynamic bounds, out-of-range, non-ASCII)
37+
- [x] #3 Extended subscripts serve: dynamic, negative, out-of-range indices per pins
38+
- [x] #4 Operator/star dialect forms serve or reclassify cleanly: ^@, GLOB, << >>, * LIKE/ILIKE/SIMILAR, * REPLACE, * RENAME, qualified EXCLUDE, alias-prefix colon
39+
- [x] #5 Duplicate output column names: contract pinned to DuckDB Python-client behavior, implemented across output modes
4040
- [ ] #6 Binder tail per pins: NULL <op> NULL typing, lateral aliases, t(a,b), NATURAL JOIN, schema-qualified driving relation, BETWEEN/IN mixed-type semantics
4141
- [ ] #7 Corpus replay: three-outcome contract holds, zero FAILs, match count reported (ceiling ~130 over 395)
4242
- [ ] #8 Gate green both backends, clippy clean, serving-bench parity gate passes

src/specializer/frontend.rs

Lines changed: 187 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,6 @@ pub fn frontend(
128128

129129
let (binder, joins, leftover_where) = bind_from(select, this_name, in_cols, statics)?;
130130

131-
let mut rel = Rel::Scan;
132-
if let Some(pred) = &leftover_where {
133-
let pred = fold(bool_context(binder.expr(pred)?, "WHERE predicate")?);
134-
rel = Rel::Filter {
135-
input: Box::new(rel),
136-
pred,
137-
};
138-
}
139-
140131
let mut out_cols = Vec::new();
141132
let mut exprs = Vec::new();
142133
let push_item = |out_cols: &mut Vec<Col>,
@@ -162,12 +153,16 @@ pub fn frontend(
162153
default_name(e),
163154
fold(binder.expr(e)?),
164155
)?,
165-
SelectItem::ExprWithAlias { expr, alias } => push_item(
166-
&mut out_cols,
167-
&mut exprs,
168-
alias.value.clone(),
169-
fold(binder.expr(expr)?),
170-
)?,
156+
SelectItem::ExprWithAlias { expr, alias } => {
157+
let e = fold(binder.expr(expr)?);
158+
// Lateral aliases (wave-5 pins): later items and WHERE may
159+
// reference this alias; the real column still wins.
160+
binder
161+
.bound_aliases
162+
.borrow_mut()
163+
.push((alias.value.clone(), e.clone()));
164+
push_item(&mut out_cols, &mut exprs, alias.value.clone(), e)?
165+
}
171166
SelectItem::Wildcard(opts) => {
172167
for (name, e) in binder.expand_star(None, opts)? {
173168
push_item(&mut out_cols, &mut exprs, name, e)?;
@@ -192,6 +187,19 @@ pub fn frontend(
192187
}
193188
dedup_output_names(&mut out_cols);
194189

190+
// WHERE binds AFTER the projection so DuckDB's lateral-alias extension
191+
// (an alias visible inside WHERE when no real column shares the name)
192+
// resolves; the plan shape is unchanged — Filter still sits under
193+
// Project on the scan.
194+
let mut rel = Rel::Scan;
195+
if let Some(pred) = &leftover_where {
196+
let pred = fold(bool_context(binder.expr(pred)?, "WHERE predicate")?);
197+
rel = Rel::Filter {
198+
input: Box::new(rel),
199+
pred,
200+
};
201+
}
202+
195203
let named = out_cols
196204
.iter()
197205
.map(|c| c.name.clone())
@@ -222,27 +230,57 @@ fn bind_from<'a>(
222230
let dyn_name = match &table.relation {
223231
TableFactor::Table { name, alias, .. } => {
224232
let n = name.to_string();
225-
if !n.eq_ignore_ascii_case(this_name) {
233+
// `main.tbl` resolves to bare `tbl` (DuckDB's default schema);
234+
// every OTHER qualifier fails in DuckDB itself with a
235+
// schema-does-not-exist Catalog Error, so it stays unsupported
236+
// here — never a silent bare-name fallback (wave-5 pins).
237+
let bare = n
238+
.strip_prefix("main.")
239+
.or_else(|| n.strip_prefix("MAIN."))
240+
.unwrap_or(&n);
241+
if !bare.eq_ignore_ascii_case(this_name) {
226242
return Err(unsup(format!(
227243
"table '{n}' as the driving relation (must be the dynamic table '{this_name}')"
228244
)));
229245
}
246+
let n = bare.to_string();
230247
match alias {
231248
// Measured: an alias REPLACES the original name entirely
232249
// (qualified refs through the original are binder errors in
233250
// DuckDB) — making the alias the binder's this_name gives
234251
// exactly that scoping.
235-
Some(a) if a.columns.is_empty() => a.name.value.clone(),
236-
Some(_) => return Err(unsup("column-renaming table alias t(a, b, ...)")),
237-
None => n,
252+
Some(a) if a.columns.is_empty() => (a.name.value.clone(), None),
253+
// `t AS u(x, y)`: a PARTIAL list is legal (prefix rename,
254+
// remaining columns keep their names); too many names is
255+
// the pinned bind error; old names are fully shadowed
256+
// (wave-5 pins).
257+
Some(a) => {
258+
if a.columns.len() > in_cols.len() {
259+
return Err(PrepareError::Bind(format!(
260+
"table \"{n}\" has {} columns available but {} columns specified",
261+
in_cols.len(),
262+
a.columns.len()
263+
)));
264+
}
265+
let mut renamed = in_cols.to_vec();
266+
for (c, def) in renamed.iter_mut().zip(&a.columns) {
267+
c.name = def.name.value.clone();
268+
}
269+
(a.name.value.clone(), Some(renamed))
270+
}
271+
None => (n, None),
238272
}
239273
}
240274
other => return Err(unsup(format!("FROM {other}"))),
241275
};
276+
let (dyn_name, renamed_cols) = dyn_name;
242277

243278
let mut binder = Binder {
244279
this_name: dyn_name,
245-
in_cols,
280+
in_cols: match renamed_cols {
281+
Some(v) => std::borrow::Cow::Owned(v),
282+
None => std::borrow::Cow::Borrowed(in_cols),
283+
},
246284
joins: Vec::new(),
247285
select_aliases: select
248286
.projection
@@ -252,6 +290,7 @@ fn bind_from<'a>(
252290
_ => None,
253291
})
254292
.collect(),
293+
bound_aliases: std::cell::RefCell::new(Vec::new()),
255294
};
256295
let mut specs: Vec<JoinSpec> = Vec::new();
257296

@@ -328,7 +367,28 @@ fn bind_from<'a>(
328367
}
329368
(keys, key_cols, Vec::new(), true)
330369
}
331-
JoinConstraint::Natural => return Err(unsup("NATURAL JOIN")),
370+
// NATURAL = USING(all common column names), case-insensitive,
371+
// merged output like USING with the LEFT spelling; NO common
372+
// columns is a hard error, never a cross product (wave-5 pins).
373+
JoinConstraint::Natural => {
374+
let mut keys = Vec::new();
375+
let mut key_cols = Vec::new();
376+
for (i, c) in st.cols.iter().enumerate() {
377+
let Ok(key) = binder.column(&c.name) else {
378+
continue;
379+
};
380+
keys.push(promote_key(fold(key), st, i as u32)?);
381+
key_cols.push(i as u32);
382+
}
383+
if key_cols.is_empty() {
384+
return Err(PrepareError::Bind(
385+
"No columns found to join on in NATURAL JOIN.\n\
386+
Use CROSS JOIN if you intended for this to be a cross-product."
387+
.into(),
388+
));
389+
}
390+
(keys, key_cols, Vec::new(), true)
391+
}
332392
JoinConstraint::None => return Err(unsup("JOIN without ON (cross join)")),
333393
};
334394
let val_cols: Vec<u32> = (0..st.cols.len() as u32)
@@ -768,11 +828,19 @@ struct ScopeJoin<'a> {
768828
struct Binder<'a> {
769829
/// The dynamic table's name as spelled in FROM.
770830
this_name: String,
771-
in_cols: &'a [Col],
831+
/// The dynamic table's columns AS THE BINDER SEES THEM: borrowed
832+
/// normally; an owned renamed copy under `t AS u(x, y)` (wave-5 pins —
833+
/// prefix rename, old names fully shadowed). Positions never change,
834+
/// so the lowered program still marshals by the ORIGINAL field names.
835+
in_cols: std::borrow::Cow<'a, [Col]>,
772836
joins: Vec<ScopeJoin<'a>>,
773-
/// SELECT-list aliases, for cleanly rejecting DuckDB's lateral-alias
774-
/// extension (an alias referenced inside WHERE) as unsupported.
837+
/// All SELECT-list aliases (wave-5 pins: DuckDB's lateral aliases — a
838+
/// later item or WHERE may reference an earlier alias; the REAL column
839+
/// wins on a name clash; a forward reference is the pinned bind error).
775840
select_aliases: Vec<String>,
841+
/// Aliases already bound this pass, in SELECT order (frontend() fills
842+
/// this as it walks the projection; RefCell keeps `expr(&self)` intact).
843+
bound_aliases: std::cell::RefCell<Vec<(String, SExpr)>>,
776844
}
777845

778846
fn math1_node(op: NumOp1, inner: SExpr) -> SExpr {
@@ -1014,15 +1082,64 @@ impl Binder<'_> {
10141082
}
10151083
}
10161084
}
1017-
if any_num && any_other {
1018-
return Err(unsup(
1019-
"BETWEEN/IN mixing strings or booleans with numbers (exec-time cast semantics)",
1020-
));
1085+
// Wave-5 pins: mixing casts the string/bool side to the NUMERIC
1086+
// side (strings numerically with half-away-from-zero rounding to
1087+
// ints; bool -> 0/1; non-numeric strings are DuckDB Conversion
1088+
// Errors). Only literals convert at bind — a string/bool COLUMN
1089+
// would need runtime cast traps and stays unsupported.
1090+
let mut owned: Vec<SqlExpr> = Vec::with_capacity(exprs.len());
1091+
for e in exprs {
1092+
let b = self.expr_or_null(e)?;
1093+
let needs_cast =
1094+
any_num && b.as_ref().is_some_and(|b| matches!(b.ty, Ty::Str | Ty::I1));
1095+
if !needs_cast {
1096+
owned.push((*e).clone());
1097+
continue;
1098+
}
1099+
let lit = match b.map(|b| b.kind) {
1100+
Some(SKind::Lit(Lit::Str(s))) => {
1101+
// Non-numeric strings convert (and fail) at EXECUTION
1102+
// time in DuckDB — an empty input succeeds — so a
1103+
// bind-time error would be over-eager; stay clean.
1104+
let Ok(x) = s.trim().parse::<f64>() else {
1105+
return Err(unsup(
1106+
"BETWEEN/IN mixing non-numeric string literals with numbers \
1107+
(exec-time conversion)",
1108+
));
1109+
};
1110+
if any_f64 {
1111+
s.trim().to_string()
1112+
} else {
1113+
let r = if x >= 0.0 {
1114+
(x + 0.5).floor()
1115+
} else {
1116+
(x - 0.5).ceil()
1117+
};
1118+
if r < i64::MIN as f64 || r > i64::MAX as f64 {
1119+
return Err(unsup(
1120+
"BETWEEN/IN mixing out-of-range string literals with numbers \
1121+
(exec-time conversion)",
1122+
));
1123+
}
1124+
format!("{}", r as i64)
1125+
}
1126+
}
1127+
Some(SKind::Lit(Lit::I1(v))) => format!("{}", v as i64),
1128+
_ => {
1129+
return Err(unsup(
1130+
"BETWEEN/IN mixing strings or booleans with numbers \
1131+
(non-literal side needs exec-time cast semantics)",
1132+
))
1133+
}
1134+
};
1135+
owned.push(SqlExpr::Value(sqlparser::ast::ValueWithSpan {
1136+
value: SqlValue::Number(lit, false),
1137+
span: sqlparser::tokenizer::Span::empty(),
1138+
}));
10211139
}
1022-
Ok(exprs
1023-
.iter()
1140+
Ok(owned
1141+
.into_iter()
10241142
.map(|e| {
1025-
let e = (*e).clone();
10261143
if any_f64 {
10271144
// CAST is a no-op on already-f64 sides and types NULL
10281145
// literals from context; exactly DuckDB's unification.
@@ -1796,23 +1913,37 @@ impl Binder<'_> {
17961913
}
17971914
}
17981915
match hits.len() {
1916+
// The REAL column wins over a same-named select alias (measured
1917+
// in both SELECT and WHERE — wave-5 pins).
17991918
1 => Ok(hits.pop().expect("len checked")),
1800-
// Real DuckDB features we don't model reject cleanly, not as
1801-
// bind errors: the rowid pseudo-column, and DuckDB's lateral
1802-
// alias extension (a SELECT alias visible inside WHERE).
18031919
0 if name.eq_ignore_ascii_case("rowid") => Err(unsup("rowid pseudo-column")),
1804-
0 if self
1805-
.select_aliases
1806-
.iter()
1807-
.any(|a| a.eq_ignore_ascii_case(name)) =>
1808-
{
1809-
Err(unsup(format!(
1810-
"SELECT alias '{name}' referenced outside the SELECT list (lateral alias)"
1920+
0 => {
1921+
// Lateral aliases: an already-bound alias resolves to its
1922+
// expression; a known-but-later alias is the pinned
1923+
// forward-reference error.
1924+
if let Some((_, e)) = self
1925+
.bound_aliases
1926+
.borrow()
1927+
.iter()
1928+
.rev()
1929+
.find(|(a, _)| a.eq_ignore_ascii_case(name))
1930+
{
1931+
return Ok(e.clone());
1932+
}
1933+
if self
1934+
.select_aliases
1935+
.iter()
1936+
.any(|a| a.eq_ignore_ascii_case(name))
1937+
{
1938+
return Err(PrepareError::Bind(format!(
1939+
"column \"{name}\" referenced that exists in the SELECT clause - \
1940+
but this column cannot be referenced before it is defined"
1941+
)));
1942+
}
1943+
Err(PrepareError::Bind(format!(
1944+
"column '{name}' does not exist in scope"
18111945
)))
18121946
}
1813-
0 => Err(PrepareError::Bind(format!(
1814-
"column '{name}' does not exist in scope"
1815-
))),
18161947
_ => Err(PrepareError::Bind(format!("ambiguous column '{name}'"))),
18171948
}
18181949
}
@@ -1893,7 +2024,18 @@ impl Binder<'_> {
18932024
let n = null_of(null_context_ty(op, b.ty));
18942025
(n, b)
18952026
}
1896-
(None, None) => return Err(unsup("NULL <op> NULL without a typing context")),
2027+
(None, None) => {
2028+
// Wave-5 pins: NULL <op> NULL types by the operator —
2029+
// + - * % -> BIGINT, / -> DOUBLE, comparisons -> BOOLEAN
2030+
// (via I64 operands), AND/OR -> BOOLEAN.
2031+
let ty = match op {
2032+
BinaryOperator::Divide => Ty::F64,
2033+
BinaryOperator::And | BinaryOperator::Or => Ty::I1,
2034+
BinaryOperator::PGStartsWith | BinaryOperator::StringConcat => Ty::Str,
2035+
_ => Ty::I64,
2036+
};
2037+
(null_of(ty), null_of(ty))
2038+
}
18972039
};
18982040
match op {
18992041
BinaryOperator::Plus => self.arith(ArithOp::Add, a, b),

0 commit comments

Comments
 (0)