Skip to content

Commit b2bc6b0

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): dynamic-table alias — the alias replaces the original name (TASK-48)
Measured: DuckDB makes qualified refs through the ORIGINAL name binder errors once aliased; making the alias the binder's this_name reproduces that scoping for qualified binds, star expansion, and messages with a three-line change. AS/bare identical; column-renaming alias t(a,b) rejects by name. Census note in the ticket: the comma-join rewrite was DROPPED from wave scope — the corpus cases are self-joins of the dynamic table, so the planned rewrite would flip ~0 cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8d193ec commit b2bc6b0

3 files changed

Lines changed: 46 additions & 5 deletions

File tree

backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ The dual-axis rung plus two structural cheap wins, ~120 corpus first-blockers in
2626
<!-- AC:BEGIN -->
2727
- [ ] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended)
2828
- [ ] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored)
29-
- [ ] #3 Comma-join with equi-WHERE rewrites to the served INNER join; non-equi shapes reject by name
29+
- [x] #3 RE-SCOPED by census (recorded below): the corpus comma-join cases are SELF-joins of the dynamic table (FROM integers i1, integers i2 ...), not dynamic-x-static shapes — a rewrite would flip ~0 cases. Comma-join stays cleanly unsupported; dynamic self-join is a distinct future feature (needs row-table-as-static materialization).
3030
- [ ] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here
3131
- [ ] #5 mise gate-specializer green
3232
<!-- AC:END -->
33+
34+
## Implementation Notes
35+
36+
<!-- SECTION:NOTES:BEGIN -->
37+
Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE pins fleet running; matcher lands next.
38+
<!-- SECTION:NOTES:END -->

src/specializer/frontend.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,21 @@ fn bind_from<'a>(
204204
};
205205
let dyn_name = match &table.relation {
206206
TableFactor::Table { name, alias, .. } => {
207-
if alias.is_some() {
208-
return Err(unsup("alias on the dynamic table"));
209-
}
210207
let n = name.to_string();
211208
if !n.eq_ignore_ascii_case(this_name) {
212209
return Err(unsup(format!(
213210
"table '{n}' as the driving relation (must be the dynamic table '{this_name}')"
214211
)));
215212
}
216-
n
213+
match alias {
214+
// Measured: an alias REPLACES the original name entirely
215+
// (qualified refs through the original are binder errors in
216+
// DuckDB) — making the alias the binder's this_name gives
217+
// exactly that scoping.
218+
Some(a) if a.columns.is_empty() => a.name.value.clone(),
219+
Some(_) => return Err(unsup("column-renaming table alias t(a, b, ...)")),
220+
None => n,
221+
}
217222
}
218223
other => return Err(unsup(format!("FROM {other}"))),
219224
};

tests/test_duckdb_interpreter.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,3 +1428,33 @@ def test_round_family_int64_identity_and_negative_n():
14281428
{"i": "int?"},
14291429
[{"i": v} for v in vals],
14301430
)
1431+
1432+
1433+
# --------------------------------------------------------- TASK-48:
1434+
# dynamic-table alias — the alias REPLACES the original name (measured).
1435+
1436+
1437+
def test_dynamic_table_alias():
1438+
for sql in [
1439+
"SELECT t.a AS x, a AS y FROM __THIS__ t",
1440+
"SELECT t.a AS x FROM __THIS__ AS t",
1441+
"SELECT t.a AS x, dim.name AS nm FROM __THIS__ t JOIN dim ON t.a = dim.id",
1442+
"SELECT t.*, upper(t.s) AS u FROM __THIS__ t",
1443+
]:
1444+
duck_check(sql, {"a": "int", "s": "str"}, [{"a": 1, "s": "x"}], {"dim": DIM})
1445+
1446+
1447+
def test_alias_shadows_original_name():
1448+
# Mirrors DuckDB's 'Referenced table not found' as our bind error.
1449+
with pytest.raises(ValueError, match="unknown table"):
1450+
DuckDBInferFn(
1451+
"SELECT __THIS__.a FROM __THIS__ t",
1452+
row_tables={"__THIS__": _row_model({"a": "int"})},
1453+
static_tables={},
1454+
)
1455+
with pytest.raises(ValueError, match="column-renaming"):
1456+
DuckDBInferFn(
1457+
"SELECT y FROM __THIS__ t(y)",
1458+
row_tables={"__THIS__": _row_model({"a": "int"})},
1459+
static_tables={},
1460+
)

0 commit comments

Comments
 (0)