Skip to content

fix(unparser): keep a qualifier that an UNNEST or FLATTEN alias, a schema-qualified table or a struct field path binds (refs spiceai/spiceai#13442) - #246

Merged
lukekim merged 14 commits into
spiceai-55from
fix/13442-dangling-qualifier-visibility
Oct 17, 2026
Merged

lukekim merged 14 commits into
spiceai-55from
fix/13442-dangling-qualifier-visibility

Conversation

@grokspice

@grokspice grokspice commented Oct 2, 2026 •

Copy link
Copy Markdown

Which issue does this PR close?

Rationale for this change

spiceai-55 still fails the same 22 datafusion-sql tests that spiceai/spiceai#13442 lists for spiceai-54 (measured at ddd5c8ce6, below). Nineteen are the fork's dangling-qualifier sweep stripping a qualifier that does bind. The other three pin upstream output that the fork changed on purpose.

The sweep strips a column qualifier that no FROM relation answers to. spiceai-55 decides which names are visible with collect_relation_idents. This PR keeps that model and only widens what it collects. Earlier revisions of this branch replaced the sweep with a VisibleScope object; that redesign is dropped (see "History" below).

What the base gets wrong. Each item shows the SQL the base emits and what a real engine does with it:

  • An UNNEST(…) AS u or LATERAL FLATTEN(…) AS u alias is not collected. u.c1 loses its qualifier. Two of them sharing a column name give SELECT c1, c1 FROM UNNEST([1, 2]) AS a (c1) CROSS JOIN UNNEST([3]) AS b (c1), and DuckDB answers Ambiguous reference to column name "c1".
  • A bare multi-part table is collected only by its full name. A column over it is qualified by the last part ("table".id over "schema"."table"), so a join of two such tables becomes SELECT v, v FROM s.a INNER JOIN s.b ON a.id = b.id. DuckDB and SQLite both reject that as ambiguous.
  • A struct field path is one flat CompoundIdentifier, judged by everything but its last part. test."metadata".product is cut to product (DuckDB: Referenced column "product" not found). SELECT s.a FROM (SELECT {a:1, b:2} AS s) becomes SELECT a FROM ….

What changes are included in this PR?

  • plan.rs: collect_relation_idents also collects the alias of an UNNEST and of a table function (LATERAL FLATTEN). A bare table contributes its full name and, for a multi-part name, its last part (collect_table_name_idents). This applies to the main relation and to joined ones. The projection, query ORDER BY and select-level sort sweeps call remove_dangling_qualifier.
  • rewrite.rs: remove_dangling_qualifier applies spiceai-55's remove_dangling_identifiers to a bare column reference, as before. It also applies it to the column a path of .field steps is rooted at, keeping the path. That shape only comes from get_field, which refuses a correlated column. A subscript (o.a[1]) is left alone, because its root can be a correlated reference.
  • ast.rs: RelationBuilder::get_alias reports the alias of an Unnest or Flatten relation (it returned None). A crate-private table_name() exposes a bare table's ObjectName.
  • expr.rs: get_field_to_sql emits a field path on a column as CompoundFieldAccess { root: <column reference>, access_chain }. That is the shape it already used for a scalar-function root, and the text is unchanged. The sweep can now tell the column's qualifier from the fields after it, instead of judging test."metadata" as if it were a qualifier.
  • expr/mod.rs (planner): extract_root_and_access_chain folds a CompoundIdentifier root the same way as an Identifier root. Without that, the emitted AST for u.struct_col.field1 fails when handed to the planner directly (the roundtrip_statement path) with NotImplemented("Dot access not supported for non-string expr: Identifier(… "field1" …)").
  • Tests:
    • Five plan-level tests: a column named like its table, an aliased projection item, a struct field path over an un-aliased derived table, a correlated reference under an alias, and a local and a correlated reference sharing a qualifier spelling.
    • test_correlated_array_subscript_keeps_its_qualifier, covering the projection and the ORDER BY.
    • test_field_path_over_a_limit_input_is_judged_by_its_column. It covers a field path over a LIMIT input that no rescoping reaches, and one under a visible alias over a LIMIT; spiceai-55 cut the latter to field1.
    • test_remove_dangling_qualifier (unit).
    • A qualified struct field path in roundtrip_statement.
    • Three upstream expectations updated to the fork's deliberate output, each with a comment: roundtrip_statement_with_dialect_5, test_table_scan_pushdown, and test_sort_with_scalar_fn_and_push_down_fetch, whose sort fork PR fix(unparser): keep ORDER BY out of a derived table when the sort key is computed #191 hoists.
    • Six unparse_left_semi_join_keeps_* snapshots now keep "t"."c" over FROM "public"."t" instead of the bare "c".
    • The field-path test expects the alias spiceai-55 adds to keep the plan's output name: SELECT "metadata".product AS "t.metadata[product]" FROM (SELECT t."metadata" FROM t). In DuckDB the alias is what makes the result column t.metadata[product], the plan's own name; without it the column is named product.

Are these changes tested?

The same command was run on the base, on this branch's tests over the base's unparser, and on this branch. Each run compiled all 15 workspace crates from its own tree:

$ cargo test -p datafusion-sql --lib --test sql_integration --no-fail-fast
# spiceai-55 ddd5c8ce6
lib:             test result: ok. 103 passed; 0 failed
sql_integration: test result: FAILED. 828 passed; 22 failed
# merge commit 71d39305c: this branch's tests, spiceai-55's unparser
sql_integration: test result: FAILED. 830 passed; 25 failed
# this branch 55a705a06
lib:             test result: ok. 104 passed; 0 failed
sql_integration: test result: ok. 857 passed; 0 failed

The 22 failing on the base, by name, are all in cases::plan_to_sql:

  • roundtrip_statement, roundtrip_statement_with_dialect_{5,34,36,37,42}
  • snowflake_flatten_{cross_join_unnest_table_column,from_unnest_with_table_alias,implicit_from,multiple_unnest_cross_join,select_unnest_plus_literal,sort_between_projection_and_unnest,string_array,unnest_from_subselect}
  • snowflake_unnest_to_lateral_flatten_{cross_join_inline,simple,with_cross_join}
  • test_sort_with_scalar_fn_and_push_down_fetch, test_struct_expr, test_table_references_in_plan_to_sql_{1,2}, test_table_scan_pushdown

The branch fails none of them, and no test that passes on the base fails here. The extra 3 red at 71d39305c (25 rather than 22) are the 6 EXISTS snapshots, minus the 3 expectation updates.

The emitted SQL was executed in DuckDB 1.4.4 and SQLite 3.50.4. Base outputs are from e8d8abae9, the base tip when they were captured:

Shape spiceai-55 emits → engine this branch emits → engine
struct field path (test_struct_expr) SELECT product FROM (…) AS test WHERE … → DuckDB: Referenced column "product" not found SELECT test."metadata".product FROM (…) AS test WHERE … → 1 row
two UNNESTs, same column name SELECT c1, c1 FROM UNNEST([1, 2]) AS a (c1) CROSS JOIN UNNEST([3]) AS b (c1) → DuckDB: ambiguous c1 SELECT a.c1, b.c1 … → (1, 3), (2, 3)
join of s.a and s.b SELECT v, v FROM s.a INNER JOIN s.b ON a.id = b.id → DuckDB and SQLite: ambiguous v SELECT a.v, b.v … → ('from_a', 'from_b') in both

A correlated array subscript was found by the adversarial review of an intermediate revision (06692b5f3), which swept the root of every field access, subscripts included. The regression test fails on that revision and passes here, same command:

$ cargo test -p datafusion-sql --test sql_integration -- test_correlated_array_subscript_keeps_its_qualifier
# 06692b5f3
-SELECT (SELECT o.array_col[1] FROM unnest_table AS i LIMIT 1) FROM unnest_table AS o
+SELECT (SELECT array_col[1] FROM unnest_table AS i LIMIT 1) FROM unnest_table AS o
test result: FAILED. 0 passed; 1 failed
# 55a705a06: test result: ok (part of the 857 above)

Copilot's review of 65f7f8e95 found a field path over a LIMIT input left with a hidden qualifier. It is reproduced, fixed in 55a705a06, and the thread is answered. Same command:

$ cargo test -p datafusion-sql --test sql_integration -- test_field_path_over_a_limit_input_is_judged_by_its_column
# 65f7f8e95
-SELECT "metadata".product FROM (SELECT * FROM t LIMIT 1)
+SELECT t."metadata".product FROM (SELECT * FROM t LIMIT 1)
test result: FAILED. 0 passed; 1 failed
# 55a705a06: test result: ok (part of the 857 above)

spiceai-55 emits SELECT product FROM (SELECT * FROM t LIMIT 1) for that plan, a column that does not exist.

Over rows [10] and [20], DuckDB returns 10, 20 for the kept form and 10, 10 for the stripped one. spiceai-55 keeps the qualifier, so the final diff does not change this shape.

cargo fmt --all -- --check exits 0. cargo clippy -p datafusion-sql --all-targets -- -D warnings exits 0.

Not in this PR (pre-existing on spiceai-55, unchanged here)

Each of these emits the same text on the base and on this branch:

  • A plain correlated reference in a subquery's projection loses its qualifier. SELECT (SELECT o.j1_id FROM j1 AS i LIMIT 1) FROM j1 AS o is emitted as SELECT (SELECT j1_id FROM j1 AS i LIMIT 1) FROM j1 AS o, which DuckDB answers 1, 1 instead of 1, 2. Fixing it needs the sweep to know where a reference came from, which the base's model does not carry.
  • An aliased projection item is not swept. test_unparse_extension_to_sql still pins SELECT j1.j1_id AS user_id FROM (SELECT j1.j1_id, j1.j1_string FROM j1), which SQLite (no such column: j1.j1_id) and DuckDB both reject. Sweeping aliased items was tried. It strips the correlated j1. that test_correlated_reference_keeps_its_qualifier_under_an_alias pins (SELECT (SELECT j1_id AS x FROM j2 LIMIT 1) FROM j1), so it needs the same provenance work.
  • A reference the sweep strips to one part stays a one-element CompoundIdentifier. The printed SQL is unaffected. A planner handed the AST directly refuses it: select j1_id from (select j1_id from j1 limit 10) gives Not a compound identifier. Converting it to an Identifier would also make a stripped correlated reference plannable where the base fails loudly, so it is left as it is.
  • A nested field path in WHERE does not plan from the AST. test."metadata".product."name" fails with Dot access not supported for non-string expr.

Are there any user-facing changes?

Federated SQL keeps the qualifier in the shapes above where the base removed one that binds. The emitted text of every other statement is unchanged. The one public API change: RelationBuilder::get_alias now returns the alias of an UNNEST or FLATTEN relation instead of None.

History

Earlier revisions of this branch were cut from spiceai-54. They replaced the sweep with a VisibleScope / strip_dangling_qualifier design and also swept aliased items, carrying a correlated-reference marker for that. After spiceai-55 landed collect_relation_idents, that design was dropped in favour of the base's. The branch's spiceai-54 listing and Date32 commits resolve to spiceai-55's own ports (#240, #242), and git diff origin/spiceai-55 -- datafusion/catalog-listing datafusion/core is empty.

Review gate

  • Adversarial review: skipped — on the final head 55a705a06, codex exited 1 twice. The first run "did not return valid structured JSON"; the second reported "Your workspace is out of credits". grok is not a permitted fallback for a Grok-authored diff. The engine did review the earlier revisions:
    • review-mvbm7mhz-032sb8 on 65f7f8e95 approved, with no material findings. The only change since is the .field-path sweep in 55a705a06, which is covered by the red/green test above but was not reviewed by an engine.
    • review-mvbl88qb-3h59c5 on 06692b5f3 returned one high finding: a correlated array subscript lost its qualifier. Reproduced, fixed in d7e39c62d, with a regression test.
    • review-mvblpfno-2pbbcx on d7e39c62d returned one high finding: a plain correlated reference loses its qualifier. Reproduced as pre-existing on spiceai-55 with identical output, and listed above as not in this PR. The one-part Identifier conversion that would have made it plannable from the AST was removed in 65f7f8e95.
  • /security-review: skipped — the command reviews the session's own checkout, which is a different clone of this repository. Instead, the diff was read for security issues in this session. It adds no input handling: identifiers keep the unparser's quoting, and the planner change routes a compound root through the existing sql_compound_identifier_to_expr. No findings.
  • /simplify: not run. The diff was cut down by hand to the collection, emission and .field-path sweep changes.

grokspice and others added 6 commits September 29, 2026 18:42
…type (refs spiceai/spiceai#14491)

A Date32 literal is rendered as CAST('YYYY-MM-DD' AS DATE) whatever the dialect, while an Expr::Cast to Date32 takes Dialect::date32_cast_dtype. SQLite gives the type name DATE numeric affinity, so the literal form is the integer 1994 and a YYYY-MM-DD text column compares greater than it, which turns a date range filter pushed into a SQLite scan into one that matches no row. The literal now takes the same target type the cast does.
ListingTable materializes metadata columns (_last_modified, _size, _location)
from each object's ObjectMeta, but pruning only used partition columns. A
predicate such as `_last_modified > <watermark>` fell through to a row-level
FilterExec, so every file was opened and read before its rows were dropped.
For a compressed row format (e.g. jsonl.gz) this decompressed the whole object
on every scan.

Prune the listing by these predicates before any file is opened:

- `pruned_partition_list_with_metadata` filters the listed ObjectMeta set by
  evaluating the metadata-only predicate against each object. The existing
  `pruned_partition_list` delegates to it with empty metadata arguments, so its
  signature and behavior are unchanged.
- `filter_by_metadata` evaluates the predicate via create_physical_expr against
  a one-row batch built from MetadataColumn::to_scalar_value, giving correct
  >, >=, <, <=, =, BETWEEN, IN, LIKE, cast and NULL semantics. A NULL (unknown)
  result prunes the file, matching SQL WHERE semantics.
- `supports_filters_pushdown` reports a metadata-only conjunct as Exact, so the
  redundant FilterExec is dropped. A mixed metadata+data predicate, or one under
  OR/NOT, stays Inexact and keeps a residual filter (correct, not optimal).

Metadata pruning is orthogonal to partition pruning and applies to both
partitioned and unpartitioned tables, and to all file formats.

Reproduces spiceai/spiceai#14264.
A caller that obtains an ObjectMeta from a HEAD on a known key (rather than a
listing) can apply the identical metadata-column prune before opening the file,
and report the same predicates as Exact.
Compile the metadata predicate once per listing instead of per file
(MetadataPredicate), threading the caller's session ExecutionProps
through so filters using session variables still evaluate correctly.
Deduplicate metadata-column-name extraction between scan_with_args and
supports_filters_pushdown, and add test coverage: unit-level predicate
tests, a listing-helper integration test, and a ListingTable plan-level
test asserting the Exact pushdown marking and scan wiring actually
prune file groups and residual filters as expected. Also documents the
Exact-pushdown contract so a TableProvider wrapping ListingTable with
its own scan path doesn't inherit it without enforcing it.
fix(unparser): spell a Date32 literal's cast with the dialect's date type (refs spiceai/spiceai#14491)
…FROM makes visible (refs spiceai/spiceai#13442)

The sweep that strips a column qualifier no FROM relation answers to decided
"no relation" from too little: it collected only Table and Derived factors, so
an UNNEST or LATERAL FLATTEN alias was invisible and `u.c1` lost its
qualifier (ambiguous over two FLATTENs); it recorded a bare multi-part table
as its full spelling while a column is emitted with the table part alone, so
every column over a schema-qualified table was stripped; it compared every
part but the last, so a struct field path `test."metadata".product` was judged
by `test."metadata"` and cut to `product`; and a reference left with one part
stayed a one-element CompoundIdentifier, which a planner handed the AST
refuses.

VisibleScope now records every alias-bearing TableFactor, a bare table by its
last name part (and its full name for a dialect that writes every part),
compares identifier values, and reads an un-aliased derived table's output
names so a field path rooted at one is not mistaken for a dangling qualifier.
Stripping removes only the qualifier and produces an Identifier when one part
remains. The select-level sort sweep is written back; it previously mutated a
clone.

Twenty of the branch's 22 failing datafusion-sql unparser tests pinned the
output this damaged. The remaining expectation pins the derived-table sort
that fork PR #191 deliberately hoists; its snapshot is updated. Three upstream
expectations are updated to the fork's deliberate output, each with a comment.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved qualifier disambiguation can emit invalid SQL or change field references, requiring human review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Updates DataFusion’s SQL unparser to judge dangling qualifiers against relations visible in the emitted FROM.

Changes:

  • Adds visibility tracking for tables, derived tables, UNNEST, and FLATTEN.
  • Preserves field paths, normalizes stripped identifiers, and writes back select-level sorts.
  • Updates unit tests and SQL expectations.
File Description
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_reference_an_outer_body_answers_to.snap Retains visible table qualifiers.
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_reference_an_inner_body_answers_to.snap Retains visible table qualifiers.
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_nested_reference_the_enclosing_body_answers_to.snap Updates nested EXISTS output.
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_nested_reference_a_sibling_branch_names.snap Updates sibling-branch qualifiers.
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_nested_reference_a_joined_body_names.snap Preserves joined-table qualifiers.
datafusion/​sql/​tests/​cases/​snapshots/​sql_integration__cases__plan_to_sql__unparse_left_semi_join_keeps_nested_outer_reference_neither_side_answers_to.snap Retains outer table qualifiers.
datafusion/​sql/​tests/​cases/​plan_to_sql.rs Updates three SQL expectations and explanatory comments.
datafusion/​sql/​src/​unparser/​rewrite.rs Adds visible-scope tracking, qualifier cleanup, and tests.
datafusion/​sql/​src/​unparser/​plan.rs Applies scope-aware cleanup and saves rewritten sorts.
datafusion/​sql/​src/​unparser/​ast.rs Supplies relation visibility from builders.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread datafusion/sql/src/unparser/rewrite.rs Outdated
Comment thread datafusion/sql/src/unparser/rewrite.rs Outdated
Comment thread datafusion/sql/src/unparser/plan.rs
…so the qualifier sweep never guesses (refs spiceai/spiceai#13442)

Copilot's review found two shapes where the sweep still misjudged a
qualifier, both because get_field_to_sql flattened a column's qualifier,
name and field path into one CompoundIdentifier: a derived table exposing a
column named like the relation it encloses (t(t, id)) kept a dangling
`t.id`, and under a dialect that writes every qualifier part a dangling
`catalog.schema.table.payload.item` lost `payload` with the qualifier.

A field path is now emitted as CompoundFieldAccess with the column reference
as its root, the shape the scalar-function root already used; the text is
unchanged. A CompoundIdentifier is therefore a qualifier plus one column
name, the sweep judges only that, and the derived-output-name heuristic is
gone. The sweep also covers an aliased projection item, which it skipped.
One expectation pinned the dangling aliased form and is updated.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 13:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread datafusion/sql/src/unparser/plan.rs Outdated
Comment thread datafusion/sql/src/unparser/rewrite.rs Outdated
Comment thread datafusion/sql/src/unparser/rewrite.rs Outdated
Comment thread datafusion/sql/src/unparser/expr.rs
…fier exactly, and replan a compound field root (refs spiceai/spiceai#13442)

Four shapes from the second review round:

- A correlated reference binds in the enclosing query, so the sweep now
  collects the qualifiers of the SELECT's own OuterReferenceColumns and
  treats them as visible. Aliased items had just joined the sweep, and an
  outer reference under an alias would have lost its qualifier; unnamed ones
  had been losing it before this branch.
- A CompoundIdentifier is a qualifier plus one column, so a visible name must
  match the whole qualifier, not a prefix: an alias `d` does not make `d.t.id`
  bind when `d.t` is the enclosed table.
- An unquoted `@root.name` is a scalar variable, not a qualified column, by
  the planner's own rule, and is left alone.
- The planner's access-chain extraction accepted only a plain identifier
  root before folding leading Dot identifiers into one qualified reference;
  a CompoundIdentifier root fell through and its trailing Dot was rejected
  when the emitted AST was planned directly. It folds a compound root too.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 13:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Correlation handling can preserve hidden local qualifiers and silently read values from the wrong query scope.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (4)

Comment thread datafusion/sql/src/unparser/plan.rs Outdated
…llow-listing its qualifier (refs spiceai/spiceai#13442)

Adding an outer reference's qualifier to the SELECT-wide visible scope was
a judgement by spelling: a local `t.id` read through an un-aliased derived
table kept its qualifier whenever the enclosing query also aliased `t`, and
then bound to the enclosing row. The plan walk that gathered those
qualifiers also reached subquery inputs and lateral children.

An OuterReferenceColumn is now emitted through mark_outer_reference, which
gives each of its identifiers a sentinel Span: the one Ident field that
neither prints nor takes part in equality or hashing, so the statement and
every AST comparison are unchanged. The sweep leaves a marked reference
alone and judges an unmarked one against the FROM as before; the plan walk
is gone.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 13:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Cross-dialect name resolution and correlated-reference provenance warrant final human validation.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@grokspice

Copy link
Copy Markdown
Author

Nothing technical is left — this needs one human approval

Read back just now on head ffc603fe521c3786b99e6821d42a8392fba3645a:

gh pr view 246 --repo spiceai/datafusion --json mergeable,mergeStateStatus,reviewDecision,statusCheckRollup
  state=MERGEABLE/BLOCKED  reviewDecision=REVIEW_REQUIRED
  checks: License compliance check=SUCCESS

reviewThreads(first:100)  total=8  unresolved=0
  • No conflict. MERGEABLE, base spiceai-54.
  • Every check green. License compliance check is the only check this repo runs on a PR; there is no build or test gate here, so a green board is the whole board.
  • Every review thread resolved — 8 of 8, each answered in place.
  • Copilot's last review is on this exact head, pullrequestreview-5392741914 at 2026-10-02T14:04:45Z: "Needs a closer look", Findings: None. Its one earlier finding this round (correlation handling preserving a hidden local qualifier) is answered and shows as resolved in that same overview.

BLOCKED is therefore REVIEW_REQUIRED and nothing else. Copilot has deferred rather than approved — its stated reason is that cross-dialect name resolution and correlated-reference provenance want final human validation, which is a judgement about the class of change, not an open hole in it. Another Copilot round on an unchanged tree would return the same deferral, so I am not requesting one.

What a reviewer is being asked to confirm: that judging a dangling qualifier against every relation the FROM clause makes visible — rather than against the single nearest scan — is the right resolution rule, and that it does not let an outer-scope name bind to an inner relation that happens to share its qualifier.

@krinart is already the requested reviewer. Holding here; a later fix-failing-prs run will leave this PR alone until a person acts on it.

@lukekim
lukekim changed the base branch from spiceai-54 to spiceai-55 October 5, 2026 20:53
@grokspice

Copy link
Copy Markdown
Author

The blocker has changed: this is no longer "needs one approval"

My earlier note on this PR said "No conflict. MERGEABLE, base spiceai-54." That is now stale. The base was retargeted to spiceai-55, spiceai-55 has moved, and the PR is CONFLICTING/DIRTY. More importantly, most of what this PR does has since landed on the base by a different design, so resolving the conflict hunk-by-hunk would be the wrong move.

Current state, read back just now

gh pr view 246 --json state,mergeStateStatus,baseRefName,headRefOid
  state=OPEN  DIRTY  base=spiceai-55  head=ffc603fe521c3786b99e6821d42a8392fba3645a

git merge-tree --write-tree --name-only ffc603fe52 origin/spiceai-55   # base tip 27351ab0e
  CONFLICT (content): datafusion/catalog-listing/src/table.rs
  CONFLICT (content): datafusion/sql/src/unparser/plan.rs
  CONFLICT (content): datafusion/sql/tests/cases/plan_to_sql.rs

The plan.rs conflict is a design opposition, not a mechanical one

Both sides rewrote the same loop with different data structures:

// this PR (ffc603fe52) — a scope object
for join in twj.get_joins() {
    scope.add_relation(&join.relation);
}

// spiceai-55 (27351ab0e) — a flat ident list
twj.get_joins()
    .iter()
    .for_each(|join| collect_relation_idents(&join.relation, &mut all_idents));

spiceai-55 now carries collect_relation_idents (datafusion/sql/src/unparser/plan.rs:293), which recurses through NestedJoin and aliased Derived relations — i.e. it already answers the question this PR set out to answer. Picking a side here means choosing which visibility model the unparser keeps, which is an author/maintainer call.

Evidence: 4 of this PR's 5 tests already pass on the base without its production code

Rather than argue it from reading, I ran this PR's own tests against spiceai-55 with none of its production changes applied:

git worktree add --detach /tmp/probe origin/spiceai-55        # 27351ab0e
cd /tmp/probe
# take ONLY the test file and its snapshots from this PR — no src/ changes
git checkout origin/fix/13442-dangling-qualifier-visibility -- \
  datafusion/sql/tests/cases/plan_to_sql.rs \
  datafusion/sql/tests/cases/snapshots/
git status --porcelain   # confirms: tests + snapshots only, no src/

cargo test -p datafusion-sql --test sql_integration -- \
  test_dangling_qualifier test_correlated_reference_keeps_its_qualifier \
  test_local_and_correlated_references
running 5 tests
test test_dangling_qualifier_is_stripped_when_a_column_shares_the_table_name ... ok
test test_local_and_correlated_references_sharing_a_qualifier_are_told_apart ... ok
test test_correlated_reference_keeps_its_qualifier_under_an_alias ... ok
test test_dangling_qualifier_is_stripped_under_a_column_alias ... ok
test test_dangling_qualifier_leaves_a_field_path_in_place ... FAILED

test result: FAILED. 4 passed; 1 failed; 0 ignored; 810 filtered out; finished in 1.05s

So four of the five behaviours this PR adds are already correct on spiceai-55. Exactly one gap remains:

-SELECT "metadata".product FROM (SELECT t."metadata" FROM t)                                  # this PR expects
+SELECT "metadata".product AS "t.metadata[product]" FROM (SELECT t."metadata" FROM t)         # spiceai-55 emits

On a struct field path, spiceai-55 leaks the internal display name t.metadata[product] out as a column alias. That changes the emitted column's name, so the generated SQL does not round-trip.

What I think should happen — but this is the author's call, so I have not touched the branch

Resolving the three conflicts as-is would re-litigate a design that the base has already settled. The cheaper and lower-risk path looks like:

  1. Keep spiceai-55's collect_relation_idents model.
  2. Rebuild this PR as a minimal fix for the one remaining case — suppressing the spurious alias on a struct field path — on top of the current base.
  3. Keep test_dangling_qualifier_leaves_a_field_path_in_place; drop the other four tests as redundant, or keep them as regression cover for behaviour the base now owns.

That is a scope change and a design decision, so I am leaving the branch untouched and the conflict unresolved.

Two things I did not establish, and am labelling rather than asserting:

  • Whether the t.metadata[product] alias is a regression introduced on spiceai-55 or a long-standing gap this PR happened to catch. I did not run the failing case at the merge base — unverified.
  • Why datafusion/catalog-listing/src/table.rs is in this PR's file list at all. It is unrelated to the unparser change in the title and looks like base-merge contamination worth a look while rebuilding — unverified, file-list inspection only.

…g the base's visibility model

The branch was cut from spiceai-54 and carried that branch's metadata-column
listing pruning and Date32 literal commits; spiceai-55 has both as its own
ports (#240, #242), so datafusion/catalog-listing and datafusion/core resolve
to spiceai-55's version.

spiceai-55 also answers "which names does this FROM make visible" with
collect_relation_idents. This branch's VisibleScope / strip_dangling_qualifier
redesign of the same sweep is dropped, together with the rest of its
production changes, so the unparser here is exactly spiceai-55's. The
branch's tests are kept on top of the base's: the five plan-level tests, the
qualified struct field path in roundtrip_statement, the three upstream
expectations the fork deliberately changed (roundtrip_statement_with_dialect_5,
test_table_scan_pushdown, the hoisted sort), and the six EXISTS snapshots.
test_unparse_extension_to_sql keeps the base's expectation. The field-path
test now expects the alias spiceai-55 adds to keep the plan's output name.

At this commit those tests fail on the base's unparser; the next commit is
the fix.
…LATTEN, a schema-qualified table or a struct field path (refs spiceai/spiceai#13442)

The dangling-qualifier sweep strips a column qualifier no FROM relation
answers to. Four gaps in what it treated as visible made it strip qualifiers
that do bind, and they account for the 19 datafusion-sql tests that still
fail on spiceai-55 for spiceai/spiceai#13442:

- An `UNNEST(...) AS u` or `LATERAL FLATTEN(...) AS u` relation was not
  collected, so `u.c1` lost its qualifier (two of them sharing a column name
  gave `SELECT c1, c1`, which DuckDB rejects as ambiguous).
  collect_relation_idents and RelationBuilder::get_alias now report both.
- A bare multi-part table was collected only by its full name, while a column
  over it is qualified by the last part (`"table".id` over
  `"schema"."table"`), so `SELECT a.v, b.v FROM s.a JOIN s.b` became the
  ambiguous `SELECT v, v`. The last part is collected too.
- A struct field path was one flat CompoundIdentifier, judged by everything
  but its last part: `test."metadata".product` was cut to `product`, a column
  that does not exist. get_field_to_sql now emits CompoundFieldAccess with the
  column reference as its root (the text is unchanged), the sweep judges that
  root, and the planner folds a compound-identifier root of an access chain
  the way it already folded a plain one, so the emitted AST still plans.
- A reference stripped to one part was left a one-element CompoundIdentifier,
  which the planner refuses ("Not a compound identifier") when handed the
  AST. It becomes a plain Identifier.

The visibility model is spiceai-55's collect_relation_idents unchanged in
shape; this only widens what it collects.
…correlated subscript keeps its qualifier (refs spiceai/spiceai#13442)

The previous commit judged the root of every CompoundFieldAccess against the
FROM. That also reached an array subscript whose root is a correlated
reference: `SELECT (SELECT o.array_col[1] FROM unnest_table AS i LIMIT 1)
FROM unnest_table AS o` was emitted with `array_col[1]`, which reads the
subquery's own row (DuckDB returns 10, 10 instead of 10, 20 over rows [10]
and [20]). spiceai-55 never swept a field access, so this was a regression
of that commit.

A field access is left alone again; a struct field path no longer needs the
sweep to be read correctly, because its column's qualifier now sits in the
access's root rather than at the front of a flat identifier.
…anged (refs spiceai/spiceai#13442)

With field accesses out of the sweep again, the only behaviour left in the
remove_dangling_qualifier helper was turning a reference stripped to one
part into a plain Identifier. No test this PR targets needs it: the one
roundtrip_statement entry it was added for (`SELECT s.a FROM (SELECT {a:1,
b:2} AS s)`) was a struct field path, now emitted as a CompoundFieldAccess
the sweep does not touch. It also would have made a stripped correlated
reference plannable from the AST, where the base fails loudly. The helper
is removed and the sweep sites are spiceai-55's again, so the change to the
sweep is only what it treats as visible.
Copilot AI balanced review requested due to automatic review settings October 16, 2026 23:48
@grokspice grokspice changed the title fix(unparser): judge a dangling qualifier against every relation the FROM makes visible (refs spiceai/spiceai#13442) fix(unparser): keep a qualifier that an UNNEST or FLATTEN alias, a schema-qualified table or a struct field path binds (refs spiceai/spiceai#13442) Oct 16, 2026
@grokspice

Copy link
Copy Markdown
Author

Rebuilt on spiceai-55's visibility model

The hold from the earlier escalation is released: the conflict is resolved and the branch is rebuilt as the smallest fix on top of spiceai-55. Head is 65f7f8e95. gh pr view now reads MERGEABLE / BLOCKED, waiting on review.

What changed, and why

  • Base merge (71d39305c). spiceai-55 at ddd5c8ce6 is merged in; nothing was rebased or force-pushed.
  • The earlier note's premise was incomplete. It ran five of this PR's tests and found one failing. The base still fails the issue's 22 tests, the same names as on spiceai-54. That is the work left for this PR, and the minimal fix (06692b5f3, d7e39c62d, 65f7f8e95) only widens what collect_relation_idents collects:
    • an UNNEST or FLATTEN alias;
    • a bare table's last name part;
    • a struct field path emitted as CompoundFieldAccess instead of one flat identifier, plus the planner fold that keeps that AST plannable.
  • The field-path alias is the base's own naming. The earlier note called AS "t.metadata[product]" a leak. It comes from spiceai-55's rescoping step ("keep each output under the name the enclosing scope refers to it by"). In DuckDB it is what names the result column t.metadata[product], the plan's own output name; without it the column is product. The test now expects the alias.

Red → green, same command

Each run compiled all 15 workspace crates from its own tree:

$ cargo test -p datafusion-sql --lib --test sql_integration --no-fail-fast
spiceai-55 ddd5c8ce6          lib 103 ok | sql_integration 828 passed, 22 failed
merge 71d39305c (tests only)               sql_integration 830 passed, 25 failed
this branch 65f7f8e95         lib 103 ok | sql_integration 856 passed, 0 failed

The 22 base failures are the issue's list (names in the PR body). None of them fail on the branch, and nothing that passes on the base fails here. Executed in DuckDB and SQLite, the base's output for three of these shapes is rejected (Referenced column "product" not found, ambiguous c1, ambiguous v), and the branch's output returns rows. The table is in the PR body.

The adversarial review (codex) caught one regression in an intermediate revision. 06692b5f3 swept field-access roots, so o.array_col[1] in a correlated subquery became array_col[1]; DuckDB returned 10, 10 instead of 10, 20. It is reverted in d7e39c62d, and test_correlated_array_subscript_keeps_its_qualifier fails on 06692b5f3 and passes on 65f7f8e95. The final review, review-mvbm7mhz-032sb8 on 65f7f8e95, approved.

cargo fmt --all -- --check and cargo clippy -p datafusion-sql --all-targets -- -D warnings both exit 0.

Diff vs spiceai-55

$ git diff origin/spiceai-55..HEAD --stat
 datafusion/sql/src/expr/mod.rs                     |  16 ++-
 datafusion/sql/src/unparser/ast.rs                 |  13 ++
 datafusion/sql/src/unparser/expr.rs                |  46 +++----
 datafusion/sql/src/unparser/plan.rs                |  27 +++-
 datafusion/sql/tests/cases/plan_to_sql.rs          | 138 ++++++++++++++++++++-
 ...ed_outer_reference_neither_side_answers_to.snap |   2 +-
 ...keeps_nested_reference_a_joined_body_names.snap |   2 +-
 ...ps_nested_reference_a_sibling_branch_names.snap |   2 +-
 ...ed_reference_the_enclosing_body_answers_to.snap |   2 +-
 ...n_keeps_reference_an_inner_body_answers_to.snap |   2 +-
 ...n_keeps_reference_an_outer_body_answers_to.snap |   2 +-
 11 files changed, 204 insertions(+), 48 deletions(-)

Left out deliberately (pre-existing on spiceai-55, same output before and after)

Each of these is reproduced and described in the PR body:

  • A plain correlated reference in a subquery's projection still loses its qualifier.
  • An aliased projection item with a dangling qualifier is not swept.
  • A one-part reference left by the sweep cannot be planned straight from the AST.

The first two need the sweep to know where a reference came from, which the base's model does not carry.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Struct-field roots bypass qualifier cleanup across limit-derived tables, leaving invalid SQL references.

1 open issue

Review effort: Balanced

Comment thread datafusion/sql/src/unparser/expr.rs
…eep, but never a subscript's (refs spiceai/spiceai#13442)

With a field path emitted as CompoundFieldAccess, the sweep (which matched
only a bare CompoundIdentifier) no longer looked at it at all. Where no
rescoping reaches the derived table, the qualifier then dangled:
`Projection(get_field(t.metadata, 'product')) -> Limit(1) -> TableScan(t)`
emitted `SELECT t."metadata".product FROM (SELECT * FROM t LIMIT 1)`.
spiceai-55 emitted `SELECT product FROM ...` there, a column that does not
exist, so neither form ran.

The sweep now also judges the column a path of `.field` steps is rooted at,
and keeps the path: `SELECT "metadata".product FROM (SELECT * FROM t LIMIT
1)`. That shape comes only from get_field, whose root is always a column of
the query's own relations (a correlated column is refused there), so it
cannot strip a correlated reference. A subscript stays untouched: its root
can be correlated, as test_correlated_array_subscript_keeps_its_qualifier
pins.
Copilot AI balanced review requested due to automatic review settings October 17, 2026 00:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No unresolved blocking issues remain, and focused regressions cover qualifier preservation and direct AST replanning.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants