Related PR
#23327
Problem
The SQL unparser now has several aggregate-aware rendering paths that manually compose the same small set of operations:
- unproject logical column references through an
Aggregate with unproject_agg_exprs,
- detect whether the aggregate input is rendered through a derived-projection boundary with
contains_projection_before_relation,
- normalize aggregate input column qualifiers with
normalize_agg_input_columns,
- then render the expression into the appropriate SQL clause.
This composition is repeated in datafusion/sql/src/unparser/plan.rs for projection-over-aggregate rendering, direct LogicalPlan::Aggregate rendering, and Filter rendering for HAVING / QUALIFY. Nearby aggregate-aware paths, such as window projection and ORDER BY unprojection, have to remember which parts of the same rule apply.
The current code is mostly correct for the recently fixed SELECT / GROUP BY derived-projection shape, but the invariant is still distributed across clause-specific call sites. That makes future fixes easy to apply partially.
Why it matters
This is primarily a maintainability refactor with correctness-risk reduction.
Aggregate unparsing has a scope boundary that is easy to miss: if an aggregate input is emitted as a derived relation, outer aggregate clauses must reference the derived relation output columns, not aliases from inside that relation. When each clause manually wires unprojection and normalization, reviewers must re-check the same invariant for every new path.
A small helper would make the intended scope rule obvious and reduce drift between SELECT, GROUP BY, HAVING, QUALIFY, and aggregate-aware ORDER BY handling.
Invariant / desired behavior
For every aggregate expression rendered in one SQL SELECT block, column references should be normalized against the same visible SQL scope.
If an Aggregate reads from a child plan that the unparser renders through a derived-projection boundary, aggregate-related expressions emitted above that boundary should not retain out-of-scope base-table qualifiers for columns that belong to the aggregate input schema.
This invariant should be applied consistently where the unparser already performs aggregate unprojection or aggregate input normalization.
Proposed direction
Introduce one narrow internal helper in datafusion/sql/src/unparser/plan.rs or a nearby unparser utility module.
The helper should centralize the common aggregate expression preparation step, for example:
- optionally unproject an expression through an
Aggregate when the caller needs unprojection;
- apply aggregate-input qualifier normalization when the aggregate input crosses a derived-projection boundary;
- return an
Expr ready for expr_to_sql or select_item_to_sql.
The helper should stay small and semantic. It should not become a general SQL rendering abstraction or rewrite unrelated plan-node behavior.
Call sites should become clear about intent, for example:
- projection-over-aggregate: prepare projected expressions and group expressions through the helper;
- direct aggregate rendering: prepare aggregate and group expressions through the helper;
HAVING / QUALIFY: prepare unprojected filter predicates through the helper;
- aggregate-aware
ORDER BY / window-projection paths: either use the helper where the same scope rule applies, or document why those paths are already scoped differently.
Scope
In
- Add a focused internal helper for aggregate expression unprojection plus derived-input qualifier normalization.
- Migrate existing aggregate-aware call sites in
datafusion/sql/src/unparser/plan.rs where the helper directly matches current behavior.
- Audit nearby paths that unproject aggregate expressions, especially
project_window_output and unproject_sort_expr callers, and either route them through the helper or leave a short comment explaining why normalization is not applicable there.
- Preserve current SQL output for cases that do not cross a derived-projection boundary.
- Add focused regression coverage for the derived-projection aggregate-input shape across at least the clauses that are migrated.
Out
- Do not redesign the SQL unparser.
- Do not change optimizer behavior.
- Do not change SQL formatting, alias generation, or dialect behavior except where required to preserve aggregate scope.
- Do not claim new support for unrelated nested-query, window, or sort shapes.
- Do not add public APIs unless a very small internal helper needs to move modules.
Acceptance criteria
Tests / verification
- Add or update tests in
datafusion/core/tests/sql/unparser.rs using an issue_23317-style schema / plan shape.
- Prefer tests that parse or execute the unparsed SQL where practical, not only string-fragment checks.
- Suggested focused command:
cargo test -p datafusion --test core_integration unparser -- --nocapture
Use narrower test filters during development as needed.
Notes / open questions
- Existing helpers such as
contains_projection_before_relation, contains_aggregate_before_relation, and normalize_agg_input_columns are likely sufficient building blocks. The refactor should mainly make their combined use harder to forget.
- Confirm whether
ORDER BY can still hit the same derived-projection aggregate-input shape after current optimizer rewrites. If not, document that boundary instead of adding speculative logic.
- Confirm whether the
QUALIFY branch has a stable test shape in the currently supported dialect setup. If not, still route existing code through the shared helper when behavior is unchanged.
Related PR
#23327
Problem
The SQL unparser now has several aggregate-aware rendering paths that manually compose the same small set of operations:
Aggregatewithunproject_agg_exprs,contains_projection_before_relation,normalize_agg_input_columns,This composition is repeated in
datafusion/sql/src/unparser/plan.rsfor projection-over-aggregate rendering, directLogicalPlan::Aggregaterendering, andFilterrendering forHAVING/QUALIFY. Nearby aggregate-aware paths, such as window projection andORDER BYunprojection, have to remember which parts of the same rule apply.The current code is mostly correct for the recently fixed
SELECT/GROUP BYderived-projection shape, but the invariant is still distributed across clause-specific call sites. That makes future fixes easy to apply partially.Why it matters
This is primarily a maintainability refactor with correctness-risk reduction.
Aggregate unparsing has a scope boundary that is easy to miss: if an aggregate input is emitted as a derived relation, outer aggregate clauses must reference the derived relation output columns, not aliases from inside that relation. When each clause manually wires unprojection and normalization, reviewers must re-check the same invariant for every new path.
A small helper would make the intended scope rule obvious and reduce drift between
SELECT,GROUP BY,HAVING,QUALIFY, and aggregate-awareORDER BYhandling.Invariant / desired behavior
For every aggregate expression rendered in one SQL
SELECTblock, column references should be normalized against the same visible SQL scope.If an
Aggregatereads from a child plan that the unparser renders through a derived-projection boundary, aggregate-related expressions emitted above that boundary should not retain out-of-scope base-table qualifiers for columns that belong to the aggregate input schema.This invariant should be applied consistently where the unparser already performs aggregate unprojection or aggregate input normalization.
Proposed direction
Introduce one narrow internal helper in
datafusion/sql/src/unparser/plan.rsor a nearby unparser utility module.The helper should centralize the common aggregate expression preparation step, for example:
Aggregatewhen the caller needs unprojection;Exprready forexpr_to_sqlorselect_item_to_sql.The helper should stay small and semantic. It should not become a general SQL rendering abstraction or rewrite unrelated plan-node behavior.
Call sites should become clear about intent, for example:
HAVING/QUALIFY: prepare unprojected filter predicates through the helper;ORDER BY/ window-projection paths: either use the helper where the same scope rule applies, or document why those paths are already scoped differently.Scope
In
datafusion/sql/src/unparser/plan.rswhere the helper directly matches current behavior.project_window_outputandunproject_sort_exprcallers, and either route them through the helper or leave a short comment explaining why normalization is not applicable there.Out
Acceptance criteria
SELECTandGROUP BYbehavior for derived-projection aggregate inputs is preserved.HAVING/QUALIFYpaths use the same helper where they unproject aggregate expressions.ORDER BYand window-projection paths are audited; they either use the helper or document why the helper does not apply.SELECT/GROUP BYpath, where practical.Tests / verification
datafusion/core/tests/sql/unparser.rsusing an issue_23317-style schema / plan shape.cargo test -p datafusion --test core_integration unparser -- --nocaptureUse narrower test filters during development as needed.
Notes / open questions
contains_projection_before_relation,contains_aggregate_before_relation, andnormalize_agg_input_columnsare likely sufficient building blocks. The refactor should mainly make their combined use harder to forget.ORDER BYcan still hit the same derived-projection aggregate-input shape after current optimizer rewrites. If not, document that boundary instead of adding speculative logic.QUALIFYbranch has a stable test shape in the currently supported dialect setup. If not, still route existing code through the shared helper when behavior is unchanged.