You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(unparser): list every relation a join input reads when the input lists no columns of its own (refs spiceai/spiceai#14738) - #264
In the Join arm each input is walked into the shared SELECT and the items its walk set are popped and concatenated. A table scan with no projection sets no items, and an aliased one sets a bare *, so the list the join assembled no longer matched the plan's schema — a consumer reading the result by position got the other input's columns under this input's names, and DuckDB rejected the empty list outright. The issue's two shapes, before this change (spiceai-55 at cf084aa85; the unparser is identical at spiceai's pin 72cdbc610f), run in DuckDB 1.4.4:
plan
emitted SQL before
DuckDB before
plan's output
LeftSemi(t, u), bare scans
SELECT FROM t WHERE EXISTS (SELECT 1 FROM u WHERE (t.a = u.a))
Parser Error: SELECT clause without selection list
(t.a)
Inner(t, Projection[u.a, u.a * 2 AS d] over u), bare t
SELECT u.a, (u.a * 2) AS d FROM t INNER JOIN u ON (t.a = u.a)
2 columns: (1, 2) (2, 4) (3, 6)
3 columns: (1, 1, 2) (2, 2, 4) (3, 3, 6)
The same happened for a bare scan on either side of an inner, left, right or full join, under a filter, in a nested join, and for an aliased bare scan (SELECT *, u.a, … reads both relations through the *; two aliased scans gave SELECT *, *). Three neighbouring shapes of the same class were found while reviewing the fix and are fixed with it: an aggregate whose list no projection takes emitted its aggregates before its group keys, the reverse of the plan's schema (SELECT sum(t.b), t.a FROM t GROUP BY t.a for schema [t.a, sum(t.b)]); a join input projected to no columns carried its SELECT 1 placeholder into the shared list beside the other input's columns; and a window over a bare scan listed * and its window expression, the * reading the other input too.
What changes are included in this PR?
join_input_select_items replaces the two pop_projections() calls in the Join arm. An input whose walk set nothing, or only an unqualified * (a SelectItem::Wildcard, or the expression item an Expr::Wildcard listed by a projection renders as), contributes one q.* per relation its walk wrote into the FROM, meaning the relation builder and the joins it appended, under the names the FROM gives them. When it is the only relation the FROM holds (the probe side of an EXISTS-style join), it contributes a bare * instead, which is how a scan unparsed on its own renders. An unqualified * inside a longer list (a window input's) is narrowed in place to the same relations, and the explicit items around it are kept. The relations are read from the FROM and not from the plan's schema, so anything the walk derived or renamed is read under the name it has there. That covers a window's derived input (derived_window_input.*) and an alias over a join, which renames only the join's first relation (FROM t AS j INNER JOIN u reads as j.*, u.*). An input that already lists its own items is returned as it was set. One with no columns contributes nothing beside another relation (two such inputs keep the placeholder). One whose walk wrote no relation (a bare union) is refused, and so is one whose output still carries a mark join's mark, which * would drop (spiceai/spiceai#15220). The q.* form is kept even when every input reads whole relations, because a bare * would also read a column-less input's hidden columns and, under USING, would merge the key columns the plan keeps apart. The exception is two relations that answer to the same qualifier, such as main.l AS t beside other.t (both t when the dialect qualifies by table name alone). There t.*, t.* would read both relations twice. If the list is exactly one q.* per relation in FROM order and no join merges columns, the join emits the bare * the base did. Any other list with a colliding q.* is refused, and re-aliasing the relation instead is spiceai/spiceai#15238. The Aggregate arm lists group keys before aggregates when no projection takes the list, which is the order the plan's schema has.
Snapshots that encoded the old lists are re-pinned: seven sub-cases of test_join_with_table_scan_filters (four SELECT * over an aliased scan and a bare scan become "left".*, right_table.*; three listed only the left scan's two columns where the plan has four and gain right_table.*), and twelve projection-less aggregate snapshots whose only change is the group key moving in front of the aggregate.
Are these changes tested?
Twenty-three new tests in plan_to_sql.rs cover the issue's shapes and the shapes found while reviewing the fix. They include both scans bare, a scan under a filter, two aliased scans, a nested join of bare scans beside a projected input, a bare right input of a RIGHT JOIN, and a projection-less aggregate alone and as an aliased join input. They also cover a column-less join input alone and paired with another, a nested join holding a column-less input, two bare scans joined with USING, and windows over a bare scan, a projection, a limit, a filter, a sort, a DISTINCT and a semi or anti join beside a bare scan. The last five cover two relations answering to one qualifier (read through a bare *, or refused under USING, beside a column-less input and beside a listed column), a projection listing an unqualified Expr::Wildcard, an alias over a join as a join input, and a mark join read through *. With the tests committed and datafusion/sql/src reset to origin/spiceai-55, they and the re-pinned snapshots fail (802 passed; 46 failed = the suite's 22 pre-existing failures plus the first 24 test functions, before the window test was added). The later tests were red on the heads before their fixes: t.*, *, … on 17aba5043, t.*, u.*, … over a derived window input on 7e8cde350, and t.*, t.*, t.*, * and j.*, w.* on 0800a1231. On the head cd45aa3c7 (3112eb390 merged with spiceai-55 at ddd5c8ce6), the suite fails only the pre-existing 22: 851 passed; 22 failed, against 828 passed; 22 failed on ddd5c8ce6, with the same 22 test names. Before the merge, 3112eb390 gave 838 passed; 22 failed against 815 passed; 22 failed on e8d8abae9. On cd45aa3c7, cargo +1.97.0 test -p datafusion-sql --lib gives 103 passed, and cargo +1.97.0 clippy -p datafusion-sql --all-targets -- -D warnings and cargo fmt --all -- --check are clean.
Differential check against DuckDB 1.4.4 (Python duckdb): a probe unparses 22 join shapes with plan_to_sql, and an oracle script runs the emitted SQL and a hand-written statement for the same plan over tables matching the plan's schema, comparing column count and rows. Before, on cf084aa85 (16 shapes; J17–J22 were added through the reviews):
FAIL J1: Parser Error: SELECT clause without selection list SELECT FROM t WHERE EXISTS (…)
FAIL J2: columns=2 rows=[(1, 2), (2, 4), (3, 6)] expected columns=3 rows=[(1, 1, 2), (2, 2, 4), (3, 3, 6)]
FAIL J3: Parser Error … SELECT FROM t INNER JOIN u ON (t.a = u.a)
FAIL J4: columns=2 expected columns=3 SELECT t.a, (t.b + 1) AS c FROM t INNER JOIN u …
FAIL J5: columns=1 expected columns=3 SELECT u.a FROM t LEFT OUTER JOIN u …
FAIL J6: Parser Error … SELECT FROM t WHERE NOT EXISTS (…)
FAIL J10: columns=5 expected columns=4 SELECT *, u.a, (u.a * 2) AS d FROM t AS x INNER JOIN u …
FAIL J11: columns=2 expected columns=4 … FROM t INNER JOIN u ON (t.a = u.a) WHERE (t.b > 1)
FAIL J12: columns=2 expected columns=4 SELECT w.a, (w.a * 2) AS d FROM t INNER JOIN u … INNER JOIN w …
FAIL J13: Parser Error … SELECT FROM t INNER JOIN u … WHERE (t.b > 1)
FAIL J14: columns=6 expected columns=3 SELECT *, * FROM t AS x INNER JOIN u AS y …
FAIL J15: columns=2 expected columns=3 SELECT t.a, (t.b + 1) AS c FROM t RIGHT OUTER JOIN u …
FAIL J16: Parser Error … SELECT FROM t FULL JOIN u ON (t.a = u.a)
RESULT: FAIL (3/16 shapes agree with DuckDB 1.4.4)
After, on this branch at 34c21dda7:
J1 SELECT * FROM t WHERE EXISTS (SELECT 1 FROM u WHERE (t.a = u.a))
J2 SELECT t.*, u.a, (u.a * 2) AS d FROM t INNER JOIN u ON (t.a = u.a)
J3 SELECT t.*, u.* FROM t INNER JOIN u ON (t.a = u.a)
J10 SELECT x.*, u.a, (u.a * 2) AS d FROM t AS x INNER JOIN u ON (x.a = u.a)
J12 SELECT t.*, u.*, w.a, (w.a * 2) AS d FROM t INNER JOIN u ON (t.a = u.a) INNER JOIN w ON (t.a = w.a)
J15 SELECT t.a, (t.b + 1) AS c, u.* FROM t RIGHT OUTER JOIN u ON (t.a = u.a)
J17 SELECT u.*, w.* FROM u INNER JOIN t ON true INNER JOIN w ON (u.a = w.a)
J18 SELECT t.*, u.* FROM t INNER JOIN u USING(a)
J19 SELECT t.*, u.*, row_number() OVER (ORDER BY u.a ASC NULLS FIRST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rn FROM t INNER JOIN u ON true
J20 SELECT j.*, w.* FROM (SELECT t.a, u.a AS "a:1", u.b FROM t INNER JOIN u ON true) AS j INNER JOIN w ON true
J21 SELECT t.*, derived_window_input.*, row_number() OVER (…) AS rn FROM t INNER JOIN (SELECT u.a FROM u) AS derived_window_input ON true
RESULT: PASS (22/22 shapes agree with DuckDB 1.4.4)
The review threads on 0800a1231 found three regressions against the base. A third probe (17 shapes, default and SqliteDialect) runs each statement in SQLite 3.50.4 (schemas as attached databases) and DuckDB 1.4.4 against a hand-written statement for the same plan. Disagreeing engine runs: base e8d8abae9 24, 0800a1231 36 (on its first 15 shapes), 3112eb390 6. The 6 left on this branch are the two ON t.a = other.t.a shapes. Both engines reject those as ambiguous on the base too, with the identical statement (spiceai/spiceai#15238):
shape
base e8d8abae9
0800a1231
3112eb390
main.l AS t ⋈ other.t (also with u between, or nested on the right)
SELECT * ✓
t.*, t.*: SQLite 4 cols for 2, DuckDB ambiguous
SELECT * ✓
s1.t ⋈ s2.t
SELECT FROM: parse error
4 cols / ambiguous
SELECT * ✓
colliding, with USING(k) / a column-less input / a listed column
3 cols for 4 / 3 for 1 / 4 for 2
8 / 2 / 4 cols, DuckDB ambiguous
refused
t ⋈ projection of Expr::Wildcard over u (either side, or with an extra item)
SELECT * ✓
t.*, *: 3 cols for 2
t.*, u.* ✓
SubqueryAlias(j, t ⋈ u) ⋈ w
SELECT * ✓
j.*, w.*: 2 cols for 3
j.*, u.*, w.* ✓
mark join ⋈ w
SELECT FROM: parse error
t.*, u.*, w.*: u not found
refused
aliased union ⋈ t
SELECT FROM: parse error
t.*, x.* ✓
t.*, x.* ✓
A second, independently written probe of 68 join shapes (nested and interleaved inputs, three-part table names on the default and PostgreSQL dialects, RIGHT/semi/anti/mark joins, limits and filters over bare inputs, derived-table and union inputs, duplicate column names, self-joins) agreed with DuckDB on 56 shapes on 17aba5043, against 7 on an older base. It has not been re-run on this head. The 12 that did not match were not changed by the diff at that point beyond the empty list becoming a wildcard. 3 shapes are an Aggregate directly as a join input, which folds into the join's own SELECT and is now a binder error where the base returned wrong rows. 5 are a mark join with no projection above, which drops its mark column; as a join input that is now refused. 3 are unions and VALUES as bare inputs, which are refused where the base dropped the join or failed internally. The rest are three-part names under CustomDialectBuilder::with_full_qualified_col(true), which render cat.sch.t.*; DuckDB's parser rejects that, and no Spice dialect enables the option. The first two are filed as spiceai/spiceai#15220.
Are there any user-facing changes?
A federated join whose input is a scan with no projection now returns every column of that input, in the plan's order, from the remote engine; before, the remote either rejected the statement or returned the other input's columns in its place.
Review gate
Adversarial review: skipped — on cd45aa3c7, codex exited 1: "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." (receipt head=cd45aa3c7…, attempt=codex, engine=none). cd45aa3c7 only merges spiceai-55 into 3112eb390, and codex did review 3112eb390: job review-mvbmuoyc-w628yq, verdict needs-attention, 1 high finding. The finding is that a window over a join input is folded into the join's SELECT, so row_number() counts the joined rows, and the PR's window snapshots pin that placement. I reproduced it in SQLite and DuckDB: rn 1, 2 where the plan gives 1, 1. The base emits the same placement with the same values, and this PR changes only the column list, so it is out of scope here and filed as unparser: a window over a join input is evaluated over the joined rows (row_number() counts the join's output) spiceai#15239. Earlier rounds: review-mvak9j0j-i6js2l on 17aba5043 approved with no findings. review-mvak1zn3-w9ay8z on 6397f2b41 returned needs-attention with 1 high finding, fixed in 17aba5043. On 7e8cde350 and 34c21dda7 the review was skipped because codex was out of credits.
/security-review: run by a read-only sub-agent over this round's diff, no security defects. Every q.* qualifier is a clone of the identifier already written in the FROM, with its quote style, and there is no new indexing or unwrap. It raised one low note, unverified, code inspection only: the collision check rescanned every relation for each item, which is cubic over a left-deep chain of bare joins. The check now counts each qualifier suffix once in a map. Earlier rounds: every emitted name goes through new_ident_quoted_if_needs. The two pre-existing shapes the 68-shape probe confirmed are filed as unparser: an Aggregate as a direct join input folds into the join's SELECT, and a mark join with no projection above drops its mark column spiceai#15220.
/simplify: run by a read-only sub-agent; this round made 3 proposals. Two are applied: each item is now keyed once, and the comment on what an unset relation builder means is corrected. One is declined: moving the full_qualified_col rule next to emitted_qualifier would touch expr.rs for a single call site. The light checks were re-run green. Earlier rounds: 4 proposals, all applied in a9387e6e9.
Overlap: #261 and #247 edit other arms of plan.rs (union inputs; sort keys over volatile outputs), and #263 the Sort/Limit/Projection arms; this change touches the Join arm's item assembly and the Aggregate arm's list order.
…lists no columns of its own (refs spiceai/spiceai#14738)
A join input that is a table scan with no projection, reached directly or
through a filter, sets no SELECT items, and an aliased one claims every
column with a bare `*`. Beside the other input's items the shared list no
longer matches the plan's schema: `SELECT FROM t WHERE EXISTS (...)`,
which DuckDB rejects, and `SELECT u.a, (u.a * 2) AS d FROM t INNER JOIN u`,
which returns two columns where the plan has three. Such an input now
contributes `t.*` for each relation it reads, in the plan's order, or a
bare `*` when it is the only relation of the FROM; two inputs that both
read whole relations read as one `*`.
…and keep a column-less join input out of the shared list (refs spiceai/spiceai#14738)
An aggregate whose list no projection takes emitted its aggregates before
its group keys, the reverse of the plan's schema; a consumer reading by
position, and a `g.*` over that SELECT as a derived table beside another
join input, got the columns swapped. The list now follows the schema.
A join input projected to no columns carries the `SELECT 1` placeholder a
dialect that rejects an empty list needs; beside another relation that
placeholder was a column the plan does not have. Such an input now
contributes nothing, and a join of two such inputs keeps the placeholder.
…collapsing them to `*` (refs spiceai/spiceai#14738)
Two inputs that both read whole relations were collapsed to a bare `*`.
That reads every relation of the FROM, which is more than the inputs
read when the FROM also holds an input projected to no columns, and
under `USING` it merges the key columns the plan keeps apart: for a
nested join of `u` and a column-less `t` beside `w` the SQL returned four
columns where the plan has two, and `t JOIN u USING (a)` returned two
where the plan has three. Each input's `t.*` items are now kept as they
are.
Follow-up for the two pre-existing shapes the review exposed, which this PR does not change beyond the empty list becoming a wildcard: spiceai/spiceai#15220 (an Aggregate directly as a join input folds into the join's own SELECT; a mark join with no projection above drops its mark column).
The 68-shape probe behind the "second probe" paragraph of the description, on 17aba5043, agrees with DuckDB 1.4.4 on 56 shapes. The 12 that do not, each with the base's behaviour for comparison:
shapes
this branch
base (SELECT FROM …, or the other side's items only)
Aggregate directly as a join input (3)
SELECT sum(t.b), t.a, u.* … GROUP BY t.a → binder error
accepted; 2 columns for a 3-column plan
mark join, bare or projected probe (5)
SELECT * FROM t → mark missing
parse error / mark missing
union or VALUES as a bare input (3)
refused (…every output of the input is qualified by a relation, but 'a' is not; Unsupported operator: Values)
the join silently dropped, or an internal error
three-part names under with_full_qualified_col(true) (3)
cat.sch.t.*, which DuckDB's parser rejects (no Spice dialect sets that option)
parse error / wrong columns
Everything else the probe covers — interleaved nested inputs, RIGHT/semi/anti joins, limits and filters over bare inputs, derived-table inputs, duplicate column names, self-joins, three-part names on the default and PostgreSQL dialects — matches the plan's rows and column count.
…o that input's relations (refs spiceai/spiceai#14738)
A window over a bare scan lists `*` and then its window expressions.
Beside another relation that `*` read the other input's columns as well,
so `Inner(t, Window[row_number()](u))` returned five columns for a
four-column plan. The `*` is now narrowed to the input's own relations in
place, and the explicit items around it are kept.
…indow arm derives it under (refs spiceai/spiceai#14738)
A window over a projection, limit, sort, distinct or union is unparsed
with its input as `(…) AS derived_window_input`, while the plan's schema
still names the relations inside it. Narrowing the input's `*` by those
names emitted `u.*` beside a FROM that only holds the derived table, and
the remote could not bind `u`. The `*` now narrows to the derived alias.
A filter above the window bypasses the derived-alias detection. For Inner(t, Filter(true, Window[rn](Projection[u.a](u))), ON true), the walk emits (SELECT u.a FROM u) AS derived_window_input, but this loop never enters because the input is a Filter. The schema fallback then replaces * with u.*, referencing a hidden table and causing a binding error. Traverse filters when locating the window's derived input, and add a filtered variant of the derived-window regression test.
Avoid wildcard expansion from qualified window output aliases
datafusion/sql/src/unparser/plan.rs:1382
This reads the window's output schema, including qualified outputs already represented by explicit SELECT items. A window expression using .alias_qualified(Some("x"), "rn") over bare u produces schema [u.a, x.rn]. Joined to bare t, the wildcard expands to u.*, x.* while retaining row_number() ... AS rn. Since x is not a FROM relation, the SQL fails to bind. Read the schema of the underlying input reached by the traversal instead, and add a regression test with a qualified window alias.
…s windows and filters (refs spiceai/spiceai#14738)
The `*` a window arm lists stands for the columns of what lies below the
window, so the relations it is narrowed to are now read there:
- a filter above the window (folded in as WHERE/QUALIFY) no longer hides a
derived window input, which emitted `u.*` against a hidden `u`;
- a window output qualified by its alias (`x.rn`) is no longer read as a
relation, which emitted `x.*` beside the explicit `rn` item.
Adds a regression test for each shape.
Both "Previously missed" findings from the review on 34c21dda7 reproduced and are fixed in 3509804 (whole_relation_items now walks through filters as well as windows, and reads the relations from the node it reaches rather than from the input's own schema).
1. Traverse filters to detect derived window inputs: new test test_a_filtered_window_over_a_derived_input_as_a_join_input_reads_the_derived_alias (Inner(t, Filter(true, Window[rn](Projection[u.a](u))), ON true)).
Red on 34c21dda7:
+SELECT t.*, u.*, row_number() OVER (ORDER BY derived_window_input.a ASC NULLS FIRST RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rn FROM t INNER JOIN (SELECT u.a FROM u) AS derived_window_input ON true QUALIFY true
SELECT t.*, derived_window_input.*, row_number() OVER (ORDER BY derived_window_input.a ASC NULLS FIRST RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rn FROM t INNER JOIN (SELECT u.a FROM u) AS derived_window_input ON true QUALIFY true
test cases::plan_to_sql::test_a_filtered_window_over_a_derived_input_as_a_join_input_reads_the_derived_alias ... ok
DuckDB binds it and returns 3 columns (a, a, rn), matching the plan's [t.a, u.a, rn].
2. Avoid wildcard expansion from qualified window output aliases: new test test_a_window_with_a_qualified_alias_as_a_join_input_reads_only_its_input_relations (row_number().alias_qualified(Some("x"), "rn") over bare u, joined to bare t).
Red on 34c21dda7:
+SELECT t.*, u.*, x.*, row_number() OVER (ORDER BY u.a ASC NULLS FIRST RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rn FROM t INNER JOIN u ON true
DuckDB 1.5.5: Binder Error: Referenced table "x" not found! Candidate tables: "t"
Green on 350980468:
SELECT t.*, u.*, row_number() OVER (ORDER BY u.a ASC NULLS FIRST RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rn FROM t INNER JOIN u ON true
test cases::plan_to_sql::test_a_window_with_a_qualified_alias_as_a_join_input_reads_only_its_input_relations ... ok
DuckDB binds it and returns 4 columns (a, a, b, rn), matching the plan's [t.a, u.a, u.b, x.rn].
Checks on 350980468
cargo test -p datafusion-sql --test sql_integration -- plan_to_sql: 424 passed; 22 failed. The 22 failing names are identical (diff empty) to the base spiceai-55 at cf084aa85, run in a separate worktree: 409 passed; 22 failed.
Seen while verifying, already on the base and not changed here. When a window over a join input is folded into the join's SELECT, row_number() is computed over the joined rows instead of over the window's input. With t = 2 rows and u = 3 rows, both emitted statements above return rn 1..6 in DuckDB. A hand-written statement for the plan (SELECT t.a, w.a, w.rn FROM t, (SELECT a, row_number() OVER (ORDER BY a) rn FROM u) w) returns 1..3, each value twice. The existing test_a_window_over_a_bare_scan_as_a_join_input_narrows_its_wildcard snapshot has the same placement, and so does the base's SELECT *, row_number() … FROM t INNER JOIN u ON true. This PR only fixes the column list, so I'm reporting it here and not changing it.
…put (refs spiceai/spiceai#14738)
A sort or a `DISTINCT` above the window folds into the same SELECT as the
window, so the relations the window's `*` is narrowed to are read from
below them too. Before, a derived window input under either emitted `u.*`
against a hidden `u`, and a qualified window output (`x.rn`) emitted `x.*`.
…put (refs spiceai/spiceai#14738)
A semi or anti join forwards its probe's `*, window_expr` list, so the
relations that `*` is narrowed to are read from below the probe's window
(the right input of RIGHT SEMI / RIGHT ANTI). Before, a derived probe
window emitted `u.*` against a hidden `u`, and a qualified window output
(`x.rn`) emitted `x.*`.
…t or an empty window (refs spiceai/spiceai#14738)
The Limit arm forwards a limit with neither skip nor fetch, and a window
with no expressions lists a lone `*` over its derived input; both now read
the `derived_window_input` alias instead of `u.*` against a hidden `u`.
The plan walk is factored into `wildcard_source`, shared by both paths.
Escalated: how to handle two FROM relations that emit the same t.* qualifier (thread)
This reproduces on 0800a1231. It is a regression against the base, and fixing it means picking a behaviour, so I've left it for a maintainer to decide rather than choosing one myself.
Plan: Inner(SubqueryAlias(t, main.l[a]), other.t[b], ON true), both inputs unprojected. emitted_qualifier drops the schema unless full_qualified_col is set, so both relations come out as t:
emitted SQL
DuckDB 1.5.5
SQLite 3 (other attached)
base spiceai-55 (cf084aa85)
SELECT * FROM "main".l AS t INNER JOIN "other".t ON true
(11, 22), 2 columns ✓
11,22 ✓
this PR (0800a1231), default dialect
SELECT t.*, t.* FROM "main".l AS t INNER JOIN "other".t ON true
Binder Error: Ambiguous reference to table "t" (use: .t or other.t)
—
this PR, SqliteDialect
SELECT `t`.*, `t`.* FROM `main`.`l` AS `t` INNER JOIN `other`.`t` ON true
—
11,22,11,22, 4 columns for a 2-column plan
Spelling out the full name doesn't help either. SELECT t.*, other.t.* … is a parser error in both DuckDB (syntax error at or near "*") and SQLite (near "*": syntax error).
Options I can see:
Refuse (not_impl_err) when two relations in the join's FROM emit the same qualifier. This fits the PR's "refuse rather than guess" stance, but it gives up a pushdown that the base unparsed correctly.
Fall back to a bare * when the qualifiers collide and the two reasons the PR keeps t.* don't apply (no USING and no column-less input). This brings back the base output for this shape, but adds another case to the item assembly.
Re-alias the colliding relation in the FROM (e.g. "other".t AS t_1) and qualify its wildcard by the new alias. This is the most complete fix, but it reaches into relation building and every reference to that input (ON, filters, the list).
A note for whoever picks this up: the last four review rounds each found another operator that folds into the window's SELECT. This run added Filter, Sort, Distinct::All, semi/anti probe, no-op Limit and empty-window handling to wildcard_source. Deriving the narrowed relations from what the input's walk actually put into the FROM, instead of from the plan's shape, would close that class of finding and would also see collisions like this one. That's a larger change than this run should make on its own, so it's included here as part of the decision.
Commits from this run: 350980468 (filter / qualified window alias), 8869f03d7 (sort / DISTINCT), 6c483123b (semi/anti probe), 0800a1231 (no-op limit / empty window). Each has its red→green test output in its thread or in the comment above. On every head the plan_to_sql suite fails only the base's 22 tests.
…ote into the FROM, and read colliding qualifiers through a bare `*` (refs spiceai/spiceai#14738)
The relations a join input's unqualified `*` was narrowed to were read from
the plan's shape: the schema's qualifiers, looking through the operators that
fold into a window's SELECT. Three shapes showed that reading disagreeing with
the FROM the walk emits (SQLite 3.50.4, DuckDB 1.4.4):
- Two relations answering to one qualifier: `main.l AS t` joined to the
unaliased `other.t` emitted `SELECT t.*, t.* ...`. SQLite returns 4
columns for the 2-column plan and DuckDB rejects it as ambiguous; the base
emitted `SELECT *`, which is correct.
- An alias over a join renames only the join's first relation in the FROM
(`FROM t AS j INNER JOIN u`), so `j.*` read `t` alone: 2 columns for a
3-column plan, where the base's `SELECT *` returned 3.
- A projection that lists an unqualified `Expr::Wildcard` renders it as an
expression item, which the wildcard checks missed: `SELECT t.*, * ...`,
3 columns for a 2-column plan.
Each input's `*` is now narrowed to the relations its walk wrote into the
FROM, which are the relation builder and the joins it appended, under the
names the FROM gives them. Both forms of an unqualified `*` are recognised.
When a `q.*` in a join's list matches more than one relation of its FROM,
the list becomes a bare `*` if it is exactly one `q.*` per relation in FROM
order and no join merges columns with USING or NATURAL. Otherwise the join is
refused. A join input whose output still carries a mark join's mark is
refused instead of being read through `*`, which would drop the mark
(spiceai/spiceai#15220).
Wildcard-qualifier hold released: both open threads are fixed in 3112eb390 (head cd45aa3c7)
The user asked for both open review threads to be fixed and resolved, which releases the hold from the escalation.
Approach: structural. Each join input's * is now narrowed to the relations its walk actually wrote into the FROM. Those are the RelationBuilder plus the joins it appended to the shared FROM since a snapshot taken before the left input's walk, read under the names the FROM gives them. The plan's schema qualifiers are no longer used. I chose this over a fallback bolted onto the plan-shape reading for three reasons:
It replaces wildcard_source/whole_relation_items and the operator list they walked (Window, Filter, Sort, Distinct::All, no-op Limit, semi/anti probe). That closes the class the last four rounds kept finding: each round turned up one more operator that folds into the window's SELECT.
The collision check needs the FROM's relation names anyway.
The plan-shape reading had one more silent regression that only the FROM shows. SubqueryAlias(j, t ⋈ u) ⋈ w emits FROM t AS j INNER JOIN u ... (the alias renames only t). 0800a1231 listed j.*, w.*, 2 columns for a 3-column plan in SQLite and DuckDB. The base's SELECT * was correct, and this branch lists j.*, u.*, w.*.
Size: plan.rs + ast.rs are +343/−156 lines (net +187; +153 net code lines, the rest doc comments). All 18 earlier PR tests pass unchanged, with no snapshot re-pinned.
Collision decision as implemented.join_select_list checks each q.* in a join's list against the relations of its FROM:
Bare * fallback. When a q.* matches more than one relation, the list is exactly one q.* per relation in FROM order, and no join merges columns (USING/NATURAL), the join emits a bare *. That restores the base's output.
Refusal. Any other list with a colliding q.* gets not_impl_err!. That covers USING, a column-less input, and an input that lists its own columns; the base returned wrong column counts for all three.
Thread 2.SelectItem::UnnamedExpr(Expr::Wildcard) reproduced: SELECT t.*, * ... returned 3 columns for a 2-column plan. It is now treated as an unqualified * in both singleton and mixed lists and narrowed to the projection's FROM relations.
Also refused, not to regress: a join input whose output still carries a mark join's mark. Read through * it would lose the mark silently (spiceai/spiceai#15220). 0800a1231 emitted a dangling u.*, which both engines reject.
Evidence (red on 0800a1231, green on 3112eb390, details in each thread):
New tests: test_join_inputs_answering_to_one_qualifier_read_through_a_bare_wildcard, ..._are_refused_where_a_bare_wildcard_misreads, test_an_unqualified_wildcard_expr_in_a_join_input_reads_only_that_input, test_an_alias_over_a_join_as_a_join_input_reads_every_relation_it_holds, test_a_mark_join_read_through_a_wildcard_as_a_join_input_is_refused. All 5 failed on 0800a1231 and pass here.
cargo test -p datafusion-sql --test sql_integration: 851 passed; 22 failed on the head cd45aa3c7 (3112eb390 merged with spiceai-55, which had gone CONFLICTING after fix(unparser): keep a union input's own ORDER BY and LIMIT inside that input #261 landed; the only conflict was the two sides' tests appended at the end of plan_to_sql.rs, and both sets are kept). Base ddd5c8ce6 gives 828 passed; 22 failed. The 22 failing names are identical; I compared them as sets, and none is introduced here. Before the merge, 3112eb390 gave 838 / 22 against 815 / 22 on e8d8abae9.
On cd45aa3c7: --lib gives 103 passed, and cargo fmt --all -- --check and cargo clippy -p datafusion-sql --all-targets -- -D warnings are clean.
Review gate:codex reviewed 3112eb390 (job review-mvbmuoyc-w628yq) and returned needs-attention with 1 high finding. The finding: a window over a join input is folded into the join's SELECT, so row_number() counts the joined rows. I reproduced it on the base with identical values, so it predates this PR, and filed it as spiceai/spiceai#15239. On cd45aa3c7, codex exited 1 (out of credits), and the PR body's ## Review gate block declares that as a skip. A read-only security pass found no defects, and its one low note (a cubic rescan in the collision check) is addressed. Two of three simplify proposals are applied.
Process note: the shared build directory let a peer worktree's test binary run under this worktree's name. Every count above comes from a binary that I confirmed was built from this worktree's sources.
plan_to_sql.rs, 7 hunks. All append/append, where each side added whole tests at the same spot. I kept both sides.
Adjustments the merge needed beyond the textual conflicts.
Four fully qualified paths became unused_qualifications errors, because the base now imports HashMap, Projection and get_field: std::collections::HashMap ×2 and datafusion_expr::logical_plan::Projection::try_new_with_schema ×2. I shortened them.
Not changed here.spiceai-55 already emits … AS derived_aggregate_1 INNER JOIN t.s ON t.a = t.s.a for that test's plan, reading t.a through a derived table that hides t. That is unverified, code inspection only: I didn't run the SQL against an engine. This merge leaves it as it is.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Refs spiceai/spiceai#14738.
Rationale for this change
In the
Joinarm each input is walked into the sharedSELECTand the items its walk set are popped and concatenated. A table scan with no projection sets no items, and an aliased one sets a bare*, so the list the join assembled no longer matched the plan's schema — a consumer reading the result by position got the other input's columns under this input's names, and DuckDB rejected the empty list outright. The issue's two shapes, before this change (spiceai-55atcf084aa85; the unparser is identical at spiceai's pin72cdbc610f), run in DuckDB 1.4.4:LeftSemi(t, u), bare scansSELECT FROM t WHERE EXISTS (SELECT 1 FROM u WHERE (t.a = u.a))Parser Error: SELECT clause without selection list(t.a)Inner(t, Projection[u.a, u.a * 2 AS d] over u), baretSELECT u.a, (u.a * 2) AS d FROM t INNER JOIN u ON (t.a = u.a)The same happened for a bare scan on either side of an inner, left, right or full join, under a filter, in a nested join, and for an aliased bare scan (
SELECT *, u.a, …reads both relations through the*; two aliased scans gaveSELECT *, *). Three neighbouring shapes of the same class were found while reviewing the fix and are fixed with it: an aggregate whose list no projection takes emitted its aggregates before its group keys, the reverse of the plan's schema (SELECT sum(t.b), t.a FROM t GROUP BY t.afor schema[t.a, sum(t.b)]); a join input projected to no columns carried itsSELECT 1placeholder into the shared list beside the other input's columns; and a window over a bare scan listed*and its window expression, the*reading the other input too.What changes are included in this PR?
join_input_select_itemsreplaces the twopop_projections()calls in theJoinarm. An input whose walk set nothing, or only an unqualified*(aSelectItem::Wildcard, or the expression item anExpr::Wildcardlisted by a projection renders as), contributes oneq.*per relation its walk wrote into theFROM, meaning the relation builder and the joins it appended, under the names theFROMgives them. When it is the only relation theFROMholds (the probe side of an EXISTS-style join), it contributes a bare*instead, which is how a scan unparsed on its own renders. An unqualified*inside a longer list (a window input's) is narrowed in place to the same relations, and the explicit items around it are kept. The relations are read from theFROMand not from the plan's schema, so anything the walk derived or renamed is read under the name it has there. That covers a window's derived input (derived_window_input.*) and an alias over a join, which renames only the join's first relation (FROM t AS j INNER JOIN ureads asj.*, u.*). An input that already lists its own items is returned as it was set. One with no columns contributes nothing beside another relation (two such inputs keep the placeholder). One whose walk wrote no relation (a bare union) is refused, and so is one whose output still carries a mark join'smark, which*would drop (spiceai/spiceai#15220). Theq.*form is kept even when every input reads whole relations, because a bare*would also read a column-less input's hidden columns and, underUSING, would merge the key columns the plan keeps apart. The exception is two relations that answer to the same qualifier, such asmain.l AS tbesideother.t(bothtwhen the dialect qualifies by table name alone). Theret.*, t.*would read both relations twice. If the list is exactly oneq.*per relation inFROMorder and no join merges columns, the join emits the bare*the base did. Any other list with a collidingq.*is refused, and re-aliasing the relation instead is spiceai/spiceai#15238. TheAggregatearm lists group keys before aggregates when no projection takes the list, which is the order the plan's schema has.Snapshots that encoded the old lists are re-pinned: seven sub-cases of
test_join_with_table_scan_filters(fourSELECT *over an aliased scan and a bare scan become"left".*, right_table.*; three listed only the left scan's two columns where the plan has four and gainright_table.*), and twelve projection-less aggregate snapshots whose only change is the group key moving in front of the aggregate.Are these changes tested?
Twenty-three new tests in
plan_to_sql.rscover the issue's shapes and the shapes found while reviewing the fix. They include both scans bare, a scan under a filter, two aliased scans, a nested join of bare scans beside a projected input, a bare right input of a RIGHT JOIN, and a projection-less aggregate alone and as an aliased join input. They also cover a column-less join input alone and paired with another, a nested join holding a column-less input, two bare scans joined withUSING, and windows over a bare scan, a projection, a limit, a filter, a sort, aDISTINCTand a semi or anti join beside a bare scan. The last five cover two relations answering to one qualifier (read through a bare*, or refused underUSING, beside a column-less input and beside a listed column), a projection listing an unqualifiedExpr::Wildcard, an alias over a join as a join input, and a mark join read through*. With the tests committed anddatafusion/sql/srcreset toorigin/spiceai-55, they and the re-pinned snapshots fail (802 passed; 46 failed= the suite's 22 pre-existing failures plus the first 24 test functions, before the window test was added). The later tests were red on the heads before their fixes:t.*, *, …on17aba5043,t.*, u.*, …over a derived window input on7e8cde350, andt.*, t.*,t.*, *andj.*, w.*on0800a1231. On the headcd45aa3c7(3112eb390merged withspiceai-55atddd5c8ce6), the suite fails only the pre-existing 22:851 passed; 22 failed, against828 passed; 22 failedonddd5c8ce6, with the same 22 test names. Before the merge,3112eb390gave838 passed; 22 failedagainst815 passed; 22 failedone8d8abae9. Oncd45aa3c7,cargo +1.97.0 test -p datafusion-sql --libgives103 passed, andcargo +1.97.0 clippy -p datafusion-sql --all-targets -- -D warningsandcargo fmt --all -- --checkare clean.Differential check against DuckDB 1.4.4 (Python
duckdb): a probe unparses 22 join shapes withplan_to_sql, and an oracle script runs the emitted SQL and a hand-written statement for the same plan over tables matching the plan's schema, comparing column count and rows. Before, oncf084aa85(16 shapes; J17–J22 were added through the reviews):After, on this branch at
34c21dda7:The review threads on
0800a1231found three regressions against the base. A third probe (17 shapes, default andSqliteDialect) runs each statement in SQLite 3.50.4 (schemas as attached databases) and DuckDB 1.4.4 against a hand-written statement for the same plan. Disagreeing engine runs: basee8d8abae924,0800a123136 (on its first 15 shapes),3112eb3906. The 6 left on this branch are the twoON t.a = other.t.ashapes. Both engines reject those as ambiguous on the base too, with the identical statement (spiceai/spiceai#15238):e8d8abae90800a12313112eb390main.l AS t⋈other.t(also withubetween, or nested on the right)SELECT *✓t.*, t.*: SQLite 4 cols for 2, DuckDB ambiguousSELECT *✓s1.t⋈s2.tSELECT FROM: parse errorSELECT *✓USING(k)/ a column-less input / a listed columnt⋈ projection ofExpr::Wildcardoveru(either side, or with an extra item)SELECT *✓t.*, *: 3 cols for 2t.*, u.*✓SubqueryAlias(j, t ⋈ u)⋈wSELECT *✓j.*, w.*: 2 cols for 3j.*, u.*, w.*✓wSELECT FROM: parse errort.*, u.*, w.*:unot foundtSELECT FROM: parse errort.*, x.*✓t.*, x.*✓A second, independently written probe of 68 join shapes (nested and interleaved inputs, three-part table names on the default and PostgreSQL dialects, RIGHT/semi/anti/mark joins, limits and filters over bare inputs, derived-table and union inputs, duplicate column names, self-joins) agreed with DuckDB on 56 shapes on
17aba5043, against 7 on an older base. It has not been re-run on this head. The 12 that did not match were not changed by the diff at that point beyond the empty list becoming a wildcard. 3 shapes are anAggregatedirectly as a join input, which folds into the join's ownSELECTand is now a binder error where the base returned wrong rows. 5 are a mark join with no projection above, which drops itsmarkcolumn; as a join input that is now refused. 3 are unions andVALUESas bare inputs, which are refused where the base dropped the join or failed internally. The rest are three-part names underCustomDialectBuilder::with_full_qualified_col(true), which rendercat.sch.t.*; DuckDB's parser rejects that, and no Spice dialect enables the option. The first two are filed as spiceai/spiceai#15220.Are there any user-facing changes?
A federated join whose input is a scan with no projection now returns every column of that input, in the plan's order, from the remote engine; before, the remote either rejected the statement or returned the other input's columns in its place.
Review gate
cd45aa3c7,codexexited 1: "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." (receipthead=cd45aa3c7…,attempt=codex,engine=none).cd45aa3c7only mergesspiceai-55into3112eb390, andcodexdid review3112eb390: jobreview-mvbmuoyc-w628yq, verdict needs-attention, 1 high finding. The finding is that a window over a join input is folded into the join'sSELECT, sorow_number()counts the joined rows, and the PR's window snapshots pin that placement. I reproduced it in SQLite and DuckDB: rn1, 2where the plan gives1, 1. The base emits the same placement with the same values, and this PR changes only the column list, so it is out of scope here and filed as unparser: a window over a join input is evaluated over the joined rows (row_number() counts the join's output) spiceai#15239. Earlier rounds:review-mvak9j0j-i6js2lon17aba5043approved with no findings.review-mvak1zn3-w9ay8zon6397f2b41returned needs-attention with 1 high finding, fixed in17aba5043. On7e8cde350and34c21dda7the review was skipped becausecodexwas out of credits./security-review: run by a read-only sub-agent over this round's diff, no security defects. Everyq.*qualifier is a clone of the identifier already written in the FROM, with its quote style, and there is no new indexing orunwrap. It raised one low note, unverified, code inspection only: the collision check rescanned every relation for each item, which is cubic over a left-deep chain of bare joins. The check now counts each qualifier suffix once in a map. Earlier rounds: every emitted name goes throughnew_ident_quoted_if_needs. The two pre-existing shapes the 68-shape probe confirmed are filed as unparser: an Aggregate as a direct join input folds into the join's SELECT, and a mark join with no projection above drops its mark column spiceai#15220./simplify: run by a read-only sub-agent; this round made 3 proposals. Two are applied: each item is now keyed once, and the comment on what an unset relation builder means is corrected. One is declined: moving thefull_qualified_colrule next toemitted_qualifierwould touchexpr.rsfor a single call site. The light checks were re-run green. Earlier rounds: 4 proposals, all applied ina9387e6e9.Overlap: #261 and #247 edit other arms of
plan.rs(union inputs; sort keys over volatile outputs), and #263 the Sort/Limit/Projection arms; this change touches theJoinarm's item assembly and theAggregatearm's list order.