Skip to content

Support evaluation of IFNULL/COALESCE scalar wrappers (with literal or nested-aggregation fallbacks) over merged aggregation results#38990

Open
somiljain2006 wants to merge 22 commits into
apache:masterfrom
somiljain2006:support-ifnull-expression-evaluation
Open

Support evaluation of IFNULL/COALESCE scalar wrappers (with literal or nested-aggregation fallbacks) over merged aggregation results#38990
somiljain2006 wants to merge 22 commits into
apache:masterfrom
somiljain2006:support-ifnull-expression-evaluation

Conversation

@somiljain2006

@somiljain2006 somiljain2006 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #37644

Support evaluating IFNULL/COALESCE expression projections after aggregation merge

Add support for evaluating expression projections that wrap aggregation functions, such as IFNULL(SUM(...), 0) and COALESCE(...), after aggregation results are merged. Track derived aggregations for supported expression projections in ProjectionEngine, evaluate them during both memory and stream group-by merging, and limit expression extraction to supported functions to avoid unsupported in-memory SQL evaluation. Also add regression tests covering projection extraction, expression evaluation, and end-to-end group-by merge behavior.


Before committing this PR, I'm sure that I have checked the following options:

  • My code follows the code of conduct of this project.
  • I have self-reviewed the commit code.
  • I have (or in the comment I request) added corresponding labels for the pull request.
  • I have passed Maven check locally: ./mvnw clean install -B -T1C -Dmaven.javadoc.skip -Dmaven.jacoco.skip -e.
  • I have made corresponding changes to the documentation.
  • I have added corresponding unit tests for my changes.
  • I have updated the Release Notes of the current development version. For more details, see Update Release Note

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Can you review this pr?

@yx9o yx9o left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: The PR direction is aligned with the linked issue, but the new expression-derived aggregation path breaks expression-wrapped AVG(...). The binder creates COUNT/SUM derived projections for AVG, and the merge layer requires those derived columns, but the sharding projection rewrite only appends the expression-derived AVG(...) column itself. As a result, queries such as IFNULL(AVG(price), 0) or COALESCE(AVG(price), 0) can fail before merge/index setup or cannot compute the correct cross-shard average.

Issues

P1: Expression-derived AVG does not rewrite its required COUNT/SUM derived projections

Problem: ProjectionEngine now extracts aggregations from IFNULL / COALESCE expressions and creates expression-derived aggregation projections. For nested AVG, it correctly calls appendAverageDerivedProjection(...), which attaches the required COUNT(...) and SUM(...) derived projections to the expression-derived AVG (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java:143-148, :230-240).

However, ShardingProjectionsTokenGenerator only appends each expression-derived aggregation itself to the actual SQL (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingProjectionsTokenGenerator.java:103-105). It does not also append that aggregation's getDerivedAggregationProjections() children. This differs from the existing top-level aggregation path, which explicitly appends derived aggregation projections (ShardingProjectionsTokenGenerator.java:92-95).

The downstream contract requires those child columns to exist. SelectStatementBaseContext#setIndexForAggregationProjection iterates projectionsContext.getAggregationProjections(), sets the expression-derived aggregation index, and then requires every child derived aggregation alias to be present in the result-set label map (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/statement/type/dml/SelectStatementBaseContext.java:293-302). The merge layer then reads those child values for any aggregation whose derived projections are non-empty (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByMemoryMergedResult.java:93-102, features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByStreamMergedResult.java:111-121). AverageAggregationUnit also expects exactly count and sum values (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/aggregation/AverageAggregationUnit.java:37-49).

Impact: A sharding query like SELECT IFNULL(AVG(price), 0) FROM t_order or SELECT COALESCE(AVG(price), 0) FROM t_order GROUP BY user_id needs the actual routed SQL to return COUNT(price) and SUM(price) for correct cross-shard AVG merging. This PR only adds the expression-derived AVG(price) column, so the result-set labels do not contain AVG_DERIVED_COUNT_* / AVG_DERIVED_SUM_*. That can fail with Can't find index during setIndexes(...); if bypassed, it still cannot compute the correct merged AVG from shard-level partial averages.

Required Change: Please make the expression-derived aggregation rewrite/index path preserve the normal derived aggregation semantics recursively. For expression-derived AVG / AVG DISTINCT, the routed SQL must include the generated COUNT and SUM derived projections, those projections must receive result-set indexes, and the merge/evaluator path should use the final merged AVG value. Please also add a regression test for expression-wrapped AVG, for example IFNULL(AVG(...), 0) and/or COALESCE(AVG(...), 0), covering the rewritten projection text and the group-by merge path.

Review Details

Review Focus: Code Correctness Review. CI not reviewed by request.

Reviewed Scope: PR #38990 latest head 9de5f81a92a0fda3aac0bd7e019ebdd927631e0f, base master at 7a03f99acdc277dd809665699e296072e5e9176d. GitHub /pulls/38990/files returned 14 files, matching the public diff file list. Reviewed the binder projection changes, sharding projection rewrite, group-by memory/stream merge changes, evaluator, release note, and related tests.

Not Reviewed Scope: GitHub Actions/check-runs and full Maven verification were not reviewed because this was a code-correctness review and a static code-path blocker was confirmed.

Verification: Used public GitHub REST metadata/files and public PR diff/raw source for latest-head review. No local Maven command was run; the blocker is proven from the projection creation -> SQL rewrite -> index assignment -> merge aggregation path.

Release Note / User Docs: Release note entry is present for the new IFNULL/COALESCE aggregation-expression support. No additional user docs were required for this blocker beyond fixing the behavior and adding regression coverage.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@yx9o Thanks for the detailed review. I've addressed the issues you pointed out.

The rewrite logic now preserves the derived aggregation semantics for expression-derived aggregations by appending the derived projections of expression-wrapped AVG/AVG DISTINCT (i.e. the generated COUNT and SUM projections) in addition to the expression-derived aggregation itself. This ensures the routed SQL contains the required derived columns, allowing setIndexes(...) to resolve their indexes correctly and enabling the merge layer to compute the final cross-shard average from the merged COUNT/SUM values.

I've also added regression tests covering:

  • SQL rewrite for expression-wrapped AVG, verifying that the generated COUNT and SUM derived projections are included in the rewritten projections.
  • setIndexes(...) to verify that the expression-derived AVG and its generated COUNT/SUM projections receive the correct result-set indexes.
  • Group-by merge for IFNULL(AVG(...), 0), verifying that the merge layer computes the final average from the merged COUNT and SUM values before evaluating the outer expression.

Additionally, the failing CI jobs appear to be unrelated to these changes, as they fail during Maven download before the build or tests are executed.

@somiljain2006 somiljain2006 requested a review from yx9o July 4, 2026 16:53
@terrymanu

Copy link
Copy Markdown
Member

Please fix GitHub actions

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu This Spotless fix is clearly failing due to an internal server error, especially since it passed previously.

Screenshot 2026-07-05 at 10 02 44 AM

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: The PR direction is aligned with the linked issue, and the current head appears to fix the previous expression-wrapped AVG derived-column rewrite concern. However, the latest expression-derived aggregation implementation still has two code-level blockers: it downgrades DISTINCT aggregations to non-distinct merge units, and it bypasses the existing guard that keeps windowed aggregations out of sharding aggregation merge.

Issues

P1: DISTINCT aggregations inside IFNULL/COALESCE lose DISTINCT semantics during merge

Problem: AggregationDistinctProjectionSegment extends AggregationProjectionSegment, so the new extractor accepts SUM(DISTINCT ...) / AVG(DISTINCT ...) inside IFNULL or COALESCE. But ProjectionEngine always wraps extracted segments as plain AggregationProjection at infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java#L136-L145. Later, both group-by merge paths choose the aggregation unit with input instanceof AggregationDistinctProjection (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByMemoryMergedResult.java#L86-L88, features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByStreamMergedResult.java#L92-L94). Because the expression-derived projection is no longer an AggregationDistinctProjection, IFNULL(SUM(DISTINCT price), 0) is merged as a normal SUM over each shard's SUM(DISTINCT price) result.

Impact: This can return incorrect cross-shard results. For example, if two shards both contain price = 10, each shard returns SUM(DISTINCT price) = 10; the correct global SUM(DISTINCT price) is 10, but the current merge path accumulates 20 and the evaluator writes that value back to the expression column. AVG(DISTINCT ...) has the same class of problem when per-shard distinct count/sum pairs contain duplicate values across shards.

Required Change: Preserve AggregationDistinctProjectionSegment as an AggregationDistinctProjection in the expression-derived aggregation path, including its existing distinct rewrite and distinct aggregation-unit contract, or explicitly exclude distinct aggregations from this new supported path instead of merging them as non-distinct. Please add regression coverage with duplicate distinct values across shards for IFNULL or COALESCE.

P1: Windowed aggregations inside IFNULL/COALESCE are reclassified as sharding merge aggregations

Problem: Existing projection binding deliberately treats windowed aggregation projections as expressions: createProjection(AggregationProjectionSegment) checks projectionSegment.getWindow().isPresent() and returns an ExpressionProjection at infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java#L174-L177; the current test suite covers that contract at infra/binder/core/src/test/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngineTest.java#L124-L132. The new nested extractor does not carry the same guard: it adds any AggregationProjectionSegment found under IFNULL/COALESCE at ProjectionEngine.java#L188-L204. Once added, ShardingDQLResultMerger sees non-empty aggregation projections and routes the query into group-by merge at features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/ShardingDQLResultMerger.java#L101-L122.

Impact: A parsed expression such as IFNULL(SUM(price) OVER (...), 0) can now get a derived projection and aggregation merge even though the existing binder contract treats windowed aggregation as an expression, not as a cross-shard aggregate to accumulate. That can change result shape and values for already-supported window aggregation projections when users wrap them with the newly supported functions.

Required Change: Keep the existing window guard in the expression-derived extraction path, for example by skipping AggregationProjectionSegment instances whose getWindow() is present. Please add a regression test proving IFNULL/COALESCE around a windowed aggregation does not populate expressionDerivedAggregations and does not trigger the new derived projection rewrite.

Multi-Round Comparison

The previous public change request about expression-wrapped AVG missing its derived COUNT/SUM rewrite appears fixed in the current head: ShardingProjectionsTokenGenerator now appends expression-derived aggregations and their generated derived aggregation projections at features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingProjectionsTokenGenerator.java#L103-L106. The two issues above are newly identified current-head blockers in adjacent expression-derived aggregation cases.

Review Details

  • Review Focus: Code Correctness Review. CI not reviewed by request.
  • Reviewed Scope: Latest PR head 927a30cea2aa2d25e8537e218685c6e1946b4445; GitHub PR base SHA 7a03f99acdc277dd809665699e296072e5e9176d; local merge-base 4004670ef7044e7bea76d4d3d29c425d4a8a0b37. Reviewed all 15 files from GitHub /pulls/38990/files across infra/binder/core, features/sharding/core, and RELEASE-NOTES.md; local triple-dot file list matched GitHub.
  • Not Reviewed Scope: GitHub Actions/check-runs/logs, full Maven reactor, E2E/native-client verification, and areas outside the PR changed-file list.
  • Verification: Fetched PR metadata/files/comments/reviews and local refs successfully; compared GitHub file list with local triple-dot diff successfully; performed static code-path review from binder extraction through SQL rewrite, index setup, group-by merge, and expression evaluation. No Maven command was run because the blockers are established by static current-head code paths; CI was not reviewed for this code-correctness review.
  • Release Note / User Docs: Release note entry is present. Additional user docs are not required for this targeted SQL merge capability once the semantic blockers are fixed.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed review.

I've addressed both issues you pointed out:

  1. DISTINCT aggregations

    • Preserved AggregationDistinctProjectionSegment as AggregationDistinctProjection in the expression-derived aggregation path instead of converting it to a plain AggregationProjection.
    • AVG(DISTINCT ...) now also preserves the existing derived COUNT/SUM rewrite through appendAverageDistinctDerivedProjection(...), so the existing distinct aggregation-unit contract is retained.
  2. Windowed aggregations

    • Added the same window-function guard used by the normal projection path. Expression extraction now skips AggregationProjectionSegments with getWindow().isPresent(), so windowed aggregations continue to be treated as expressions and are not added to expressionDerivedAggregations.

I also added regression tests covering both cases:

  • IFNULL/COALESCE with DISTINCT aggregations to verify the expression-derived projection remains an AggregationDistinctProjection.
  • IFNULL/COALESCE with windowed aggregations to verify no expression-derived aggregations are created, and the existing binder behavior is preserved.

All tests are passing after these changes.

@somiljain2006 somiljain2006 requested a review from terrymanu July 6, 2026 05:48

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: The PR direction is aligned with issue #37644, and the latest head appears to fix the previous AVG-derived-column and window-aggregation concerns. The previous DISTINCT concern is also partially fixed by preserving AggregationDistinctProjection, but one required part remains unresolved: expression-derived DISTINCT aggregations still do not enter the existing distinct SQL rewrite path.

Issues

P1: Expression-derived DISTINCT aggregations still miss the DISTINCT rewrite path

Problem: The latest head now preserves nested distinct aggregations as AggregationDistinctProjection under IFNULL / COALESCE (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java#L183-L202). That fixes the type-loss part of the previous review comment.

However, these projections are stored only in expressionDerivedAggregations, while ProjectionsContext#createAggregationDistinctProjections still collects only top-level projections (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/ProjectionsContext.java#L65-L86). The distinct rewrite generators still depend solely on getAggregationDistinctProjections() (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingDistinctProjectionPrefixTokenGenerator.java#L34-L40, features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingAggregationDistinctTokenGenerator.java#L41-L58), so they are not triggered for expression-derived distinct aggregations.

The only rewrite that handles these nested projections is the append-only expression-derived projection path, which appends the distinct inner expression as a normal derived column (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingProjectionsTokenGenerator.java#L103-L115). That is not equivalent to the existing top-level distinct aggregation rewrite contract, where the routed SQL must provide raw distinct values for the distinct merge units.

Impact: Queries such as SELECT IFNULL(SUM(DISTINCT price), 0) FROM t_order or SELECT COALESCE(AVG(DISTINCT price), 0) FROM t_order GROUP BY user_id can still get an incorrect routed result shape for cross-shard distinct aggregation merge. This is the remaining part of the previous DISTINCT blocker, not a separate new issue.

Required Change: Please make expression-derived AggregationDistinctProjections participate in the same distinct rewrite contract as top-level distinct aggregations, or explicitly exclude distinct aggregations from this new expression-derived support path. Please also add regression coverage for final SQL-token generation and a two-shard duplicate-value merge case for IFNULL or COALESCE around SUM(DISTINCT ...) and/or AVG(DISTINCT ...).

Multi-Round Comparison

The previous AVG-derived rewrite blocker appears fixed in the current head: expression-derived AVG projections and their generated COUNT/SUM children are now appended by ShardingProjectionsTokenGenerator (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/rewrite/token/generator/impl/ShardingProjectionsTokenGenerator.java#L103-L107).

The previous window-aggregation concern also appears fixed: windowed nested aggregations are skipped during extraction (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java#L205-L224) and covered by a focused test.

The previous DISTINCT concern is partially fixed: nested distinct aggregations now remain AggregationDistinctProjections. The remaining issue is that these expression-derived distinct projections still bypass the existing distinct SQL rewrite path, so the previous DISTINCT change request is not fully resolved yet.

Review Details

  • Review Focus: Code Correctness Review. CI not reviewed by request.
  • Reviewed Scope: PR #38990 latest head 4b9afb429d73e3eb978025815740703aa040889c; GitHub PR base SHA 7a03f99acdc277dd809665699e296072e5e9176d; local apache/master SHA aef7d13c0a39570e31b6b38a70239df86ec4636d; local merge-base 4004670ef7044e7bea76d4d3d29c425d4a8a0b37. Reviewed all 15 files returned by GitHub /pulls/38990/files across infra/binder/core, features/sharding/core, and RELEASE-NOTES.md; the local triple-dot file list matched GitHub.
  • Not Reviewed Scope: GitHub Actions/check-runs/logs, full Maven reactor, E2E verification, and files outside the PR changed-file list.
  • Verification: Used public GitHub REST metadata/files/comments/reviews and issue #37644; fetched local PR/base refs; compared GitHub file list with local triple-dot diff; performed static code-path review from binder extraction through SQL-token generation, index setup, group-by merge, and expression evaluation. No Maven command was run because the blocker is established by current-head static code paths, and CI was intentionally not reviewed for this Code Correctness Review.
  • Release Note / User Docs: Release note entry is present. Additional user docs are not required for this targeted capability once the distinct semantics are fixed or explicitly excluded.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed review.

I've addressed the remaining DISTINCT rewrite concern by making expression-derived AggregationDistinctProjections participate in the same rewrite path as top-level DISTINCT aggregations. Specifically, ProjectionsContext#getAggregationDistinctProjections() now also includes DISTINCT projections from expressionDerivedAggregations, allowing both ShardingDistinctProjectionPrefixTokenGenerator and ShardingAggregationDistinctTokenGenerator to process them through the existing rewrite contract instead of relying only on the append-only expression-derived projection path.

I've also added regression tests covering:

SQL token generation for expression-derived SUM(DISTINCT ...) / AVG(DISTINCT ...) wrapped by IFNULL/COALESCE.
A two-shard duplicate-value merge case verifying that DISTINCT semantics are preserved during cross-shard aggregation.

@somiljain2006 somiljain2006 requested a review from terrymanu July 7, 2026 11:11

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, I did not make the scope and trade-off clear enough in the previous review rounds. As a result, you kept fixing the issue according to each local comment, but each fix exposed the next problem in the same DISTINCT support path. That was not a good review experience, so I want to clarify the full context and the recommended direction now.

The original issue #37644 is about non-DISTINCT aggregation expressions, for example:

IFNULL(SUM(CASE ...), 0)

The core root cause is that when an aggregation function is wrapped by IFNULL / COALESCE, it was not correctly extracted, rewritten, and merged as a sharding merge aggregation. Therefore, the cross-shard result could be incorrect.

The main direction of this PR is correct: extract aggregations from expressions, append derived columns, and evaluate the wrapper expression after merge. For the original non-DISTINCT scenario in #37644, this direction can address the core problem.

However, during the review, we also touched a broader capability: IFNULL / COALESCE wrapping SUM(DISTINCT ...) or AVG(DISTINCT ...). This is no longer just the minimum fix for the original SQL in #37644; it is part of the broader expression-wrapped aggregation support.

The DISTINCT issue has been exposed in several stages:

  1. Initially, expression-derived DISTINCT aggregation was merged as a normal aggregation, so DISTINCT semantics were lost.
  2. Then the type issue was fixed, so it remained an AggregationDistinctProjection.
  3. Then we found that it did not enter the existing DISTINCT rewrite path.
  4. The latest change exposes expression-derived distinct projections through getAggregationDistinctProjections().
  5. The remaining issue is that the top-level DISTINCT rewrite token cannot be directly reused inside a nested wrapper expression.

For a top-level projection, rewriting:

SUM(DISTINCT price)

to:

price AS alias

can be valid.

But inside a nested expression, for example:

COALESCE(SUM(DISTINCT price), 0)

if only the inner SUM(DISTINCT price) segment is replaced with alias-bearing text, the result can become:

COALESCE(price AS EXPR_DERIVED_0, 0)

which is not valid SQL.

Therefore, I do not think we should keep fixing DISTINCT through small local patches. Since this PR has already started handling expression-derived DISTINCT aggregation, and users may reasonably expect SUM(DISTINCT ...) / AVG(DISTINCT ...) to work when we say IFNULL / COALESCE aggregation expressions are supported, I think the better direction is to continue supporting it, but first clarify the rewrite design.

In other words, I suggest choosing Option B: continue supporting expression-wrapped DISTINCT aggregation, but implement an expression-aware DISTINCT rewrite design instead of directly reusing the top-level DISTINCT aggregation token replacement logic.

Concretely, the fix should satisfy the following requirements:

  • the final rewritten SQL must remain syntactically valid;
  • it should still expose the raw distinct value column required by the distinct merge unit;
  • it must not generate alias-bearing text such as price AS alias inside the wrapper expression;
  • tests should verify the final rewritten SQL string through the actual rewrite pipeline;
  • tests should not only assert individual token text.

Please add final SQL rewrite tests for scenarios such as:

COALESCE(SUM(DISTINCT price), 0)
IFNULL(SUM(DISTINCT price), 0)
COALESCE(AVG(DISTINCT price), 0)

and verify that the final SQL both provides the raw values required by distinct merge and preserves valid IFNULL / COALESCE wrapper expression syntax.

In summary, the main direction of this PR is correct, but DISTINCT wrapper support should not continue through local patches based on the top-level DISTINCT rewrite path. I suggest clarifying an expression-aware DISTINCT rewrite design first, then continuing the implementation.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed explanation and for clarifying the overall design direction. I understand the distinction now between the original scope of #37644 (expression-wrapped non-DISTINCT aggregations) and the broader problem of expression-wrapped DISTINCT aggregations.

Based on your feedback, I've removed the partial DISTINCT support that attempted to reuse the existing top-level DISTINCT rewrite path, since that approach cannot correctly rewrite nested expressions and may generate invalid SQL. The current changes are focused on the original non-DISTINCT use case by extracting aggregations from wrapper expressions, appending the required derived projections, and evaluating the wrapper expression after the merge phase.

I agree that supporting IFNULL/COALESCE with SUM(DISTINCT ...) or AVG(DISTINCT ...) requires an expression-aware DISTINCT rewrite design rather than incremental fixes on the existing token replacement logic, so I'd prefer to address that separately once the rewrite strategy is clearly defined.

@somiljain2006 somiljain2006 requested a review from terrymanu July 8, 2026 05:39

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

### Summary

**Review Result: Not Mergeable**

**Feedback Mode: Change Request**

**Reason:** The non-DISTINCT `IFNULL/COALESCE + aggregation` direction is correct for #37644, but the current code still leaves DISTINCT wrapper support active even though the PR discussion says the scope was narrowed back to non-DISTINCT only. There is also a concrete memory-merge column mapping bug for duplicated expression projections.

### Issues

1. P1: DISTINCT wrapper support is still active, but this PR does not implement it correctly

The latest comment says this PR is now focused on the original non-DISTINCT case, such as:

\```sql
IFNULL(SUM(price), 0)
COALESCE(SUM(price), 0)
\```

However, the code still extracts DISTINCT aggregations from expression wrappers.

In `ProjectionEngine#createDerivedAggregationProjection`, `AggregationDistinctProjectionSegment` is still handled as an expression-derived aggregation. This means SQL such as the following is still treated as supported by this PR:

\```sql
IFNULL(AVG(DISTINCT price), 0)
COALESCE(SUM(DISTINCT price), 0)
\```

The test `assertCreateProjectionWithDistinctAggregationInExpression` also explicitly verifies that `IFNULL(AVG(DISTINCT price), 0)` produces expression-derived aggregation metadata.

This is not just “extra support”. DISTINCT wrapper support needs a complete expression-aware rewrite and merge design. The current implementation appends the raw distinct inner expression through `ShardingProjectionsTokenGenerator#getDerivedProjectionText`, but `AVG(DISTINCT price)` and `SUM(DISTINCT price)` cannot be safely handled by this partial path.

Please choose one clear scope:

- If this PR only fixes #37644’s non-DISTINCT case, please remove expression-wrapped DISTINCT support completely:
  - Do not extract `AggregationDistinctProjectionSegment` from `IFNULL/COALESCE` expressions.
  - Remove `assertCreateProjectionWithDistinctAggregationInExpression`.
  - Ensure `ShardingProjectionsTokenGenerator` cannot append expression-derived DISTINCT projections.
  - Keep the release note wording scoped to non-DISTINCT aggregation wrappers.

- If this PR still wants to support DISTINCT wrappers, please implement the full design first:
  - The final rewritten SQL must remain valid.
  - The wrapper expression must not contain alias-bearing text such as `price AS alias`.
  - The raw value required by DISTINCT merge must be exposed correctly.
  - Please add final SQL rewrite tests and merge-result tests for `SUM(DISTINCT ...)` and `AVG(DISTINCT ...)`.

2. P2: Memory merge maps duplicate expression projections to the wrong output column

`GroupByMemoryMergedResult#setExpressionValueToMemoryRow` locates the output column by comparing expression text:

\```java
if (expandProjections.get(i).getExpression().equalsIgnoreCase(exprProj.getExpression())) {
    index = i;
    break;
}
\```

This fails when the SELECT list contains the same expression more than once with different aliases, for example:

\```sql
SELECT
  IFNULL(SUM(price), 0) AS amount1,
  IFNULL(SUM(price), 0) AS amount2
FROM t_order
\```

Both `ExpressionProjection` instances have the same expression text, so memory merge maps both of them to the first matching output column. The second output column may keep the original shard value instead of the merged value.

The stream merge path already avoids this by using the actual `ExpressionProjection` object position:

\```java
expandProjections.indexOf(each) + 1
\```

Please make the memory merge path use the actual projection instance as well, or another alias/object-safe mapping. Also add a regression test with two identical `IFNULL(SUM(...), 0)` expressions using different aliases, where the shard value and merged value differ, and verify both output columns are updated correctly.

### Conclusion

The main non-DISTINCT fix direction is aligned with the issue, but the PR still contains a partial DISTINCT wrapper path and a concrete memory-merge mapping bug. Please fix these two points before the PR can be considered mergeable.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed review. I've addressed both points in this revision.

For P1, I narrowed the scope of the PR to the original non-DISTINCT use case in #37644. Expression-wrapped DISTINCT aggregations are no longer extracted as expression-derived aggregations, the DISTINCT-specific regression test has been removed, and the remaining tests only cover non-DISTINCT wrapper expressions.

For P2, I updated the memory merge path to use the actual ExpressionProjection instance to determine the output column, matching the object-based mapping used by the stream merge path instead of matching by expression text. I also added a regression test covering duplicate IFNULL(SUM(...), 0) projections with different aliases to verify that both output columns are updated correctly.

@somiljain2006 somiljain2006 requested a review from terrymanu July 8, 2026 10:23

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: Thanks for the latest update. The two issues from the previous review look addressed: this PR has removed the expression-wrapped DISTINCT aggregation path, and duplicate expression projections are now mapped by the actual ExpressionProjection instance instead of matching by expression text.

I also want to clarify one thing first: the remaining issue below was not introduced by your latest commit. This is an empty-result boundary case that I should have caught in the previous review round when checking the non-DISTINCT merge path. Sorry for the extra round here.

Issues

  1. P1: Empty non-grouped wrapper aggregations are still not evaluated correctly

Problem: For SQL like:

SELECT IFNULL(SUM(price), 0) FROM t_order

when all routed result sets are empty and the SQL has no GROUP BY, SQL aggregation semantics should still return one row, and IFNULL(SUM(price), 0) should evaluate to 0.

The current flow does not cover this boundary yet. ProjectionsContext#getAggregationProjections() adds expression-derived aggregations to the aggregation list, so this SQL enters the group-by merge path. However, in GroupByMemoryMergedResult, setExpressionValueToMemoryRow(...) only iterates existing dataMap rows. When dataMap is empty, wrapper expression evaluation is skipped. After that, getMemoryResultSetRows(...) creates the synthetic aggregate row through generateReturnData(...), but that synthetic row currently only initializes top-level COUNT projections and does not re-evaluate IFNULL / COALESCE expression projections. As a result, the visible expression column remains null.

Relevant paths:

  • infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/ProjectionsContext.java:150
  • features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByMemoryMergedResult.java:74
  • features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByMemoryMergedResult.java:145
  • features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/GroupByMemoryMergedResult.java:161

Impact: The original issue is about evaluating IFNULL / COALESCE based on merged aggregation results. This empty-result boundary still returns null instead of the wrapper fallback value, so the non-DISTINCT support is not complete yet.

Required Change: Please make the synthetic empty aggregate row go through the same wrapper expression evaluation path, or otherwise generate the correct expression value for this no-row / no-GROUP BY aggregation case. Please also add a regression test covering empty routed result sets with a projection such as IFNULL(SUM(price), 0) or COALESCE(SUM(price), 0), and verify that the fallback value is returned.

Positive Feedback

The latest commit correctly narrows the PR back to the non-DISTINCT use case and fixes the duplicate-expression column mapping issue from the previous review. The remaining issue is an empty-result boundary in the same non-DISTINCT merge path, not a new scope expansion.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu I have addressed P1 in the latest revision:

  • Expression Evaluation for Synthetic Rows: I extracted the expression evaluation logic into a helper method (evaluateExpressionValue) and now explicitly call it on the synthetic MemoryQueryResultRow generated in getMemoryResultSetRows(...) when dataMap is empty.
  • Array Sizing Fix: To support the evaluation, I updated generateReturnData(...) to dynamically size the synthetic row's backing array based on the maximum column index of all aggregations (including derived ones). This ensures that the LightweightExpressionEvaluator can successfully fetch derived columns (such as the inner SUM) without throwing an IllegalArgumentException.
  • Regression Test: Added assertMergeWithEmptyResultAndIfNullExpression to verify that an empty routed result set correctly evaluates the fallback value (returning 0) for this exact no-row / no-GROUP BY scenario.

@somiljain2006 somiljain2006 requested a review from terrymanu July 9, 2026 06:33

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: The feature direction is still aligned with #37644, but this PR touches the binder projection model, SQL rewrite, and group-by merge path, which are core execution components. Because of that, I need to apply a stricter design bar here. Please understand that this is to protect shared execution behavior, not to block the feature itself.

Issues

P1: The new expression evaluator is not a clear design boundary

Problem: LightweightExpressionEvaluator is introduced as a public production type under the group-by merge package, but its name and contract are too vague for core merge code.

The class name says Lightweight, but that is not a ShardingSphere domain concept and does not explain the supported behavior. The class Javadoc says it is an evaluator “for post-merge aggregations” (features/sharding/core/src/main/java/org/apache/shardingsphere/sharding/merge/dql/groupby/LightweightExpressionEvaluator.java#L31-L34), but the class does not actually evaluate aggregations. Aggregation values have already been produced by the merge units; this helper only reevaluates a small set of wrapper projection expressions, currently IFNULL and COALESCE, using merged aggregation columns.

The public evaluate(...) methods also look generic (LightweightExpressionEvaluator.java#L43-L55), while the implementation silently returns null for unsupported expression/function shapes (LightweightExpressionEvaluator.java#L58-L68, #L71-L82). The callers then skip setting the output cell when the result is null (GroupByMemoryMergedResult.java#L194-L204, GroupByStreamMergedResult.java#L164-L175). That makes unsupported or accidentally misclassified expressions look like a normal nullable result instead of an explicit unsupported path.

Impact: This reads like a tactical patch rather than a stable merge-layer design. Future reviewers and contributors may reasonably assume this is a general post-merge expression evaluator and extend it locally, which would spread SQL expression semantics into the group-by merge package without a clear contract.

Required Change: Please make the abstraction explicitly scoped to the behavior this PR supports. I am not asking for a full expression-evaluation framework. A minimal fix is enough: rename the type and Javadocs to describe “supported aggregation wrapper projection evaluation” rather than “lightweight expression evaluation,” keep the supported function set explicit, and make unsupported paths explicit instead of silently looking like valid NULL results.

P1: Expression-derived aggregation metadata is carried through hidden mutable side state

Problem: ProjectionEngine now stores expressionDerivedAggregations as mutable engine state (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionEngine.java#L66-L69). createProjection(ExpressionProjectionSegment) mutates that side map while returning only the ExpressionProjection (ProjectionEngine.java#L126-L149), and ProjectionsContextEngine later copies the side map into ProjectionsContext (infra/binder/core/src/main/java/org/apache/shardingsphere/infra/binder/context/segment/select/projection/engine/ProjectionsContextEngine.java#L48-L68).

That means projection creation is no longer self-contained: the caller must know that creating one projection also updates hidden engine-level state. It also makes ProjectionsContextEngine#createProjectionsContext(...) rely on state retained by its ProjectionEngine field. Even if current production construction usually creates a fresh engine per statement, the public engine method itself does not express a one-shot contract.

Impact: This is risky in shared binder code because stale or cross-call expression-derived aggregation metadata would be very hard to diagnose. The changed path is not isolated to this feature; ProjectionsContext and aggregation projection discovery are shared by rewrite, merge, pagination, and other select-statement behavior.

Required Change: Please keep the state local to one createProjectionsContext(...) invocation, or otherwise make the lifecycle explicit and reset-safe. This does not need a broad redesign. The goal is simply to avoid hidden cross-call mutable state for expression-derived aggregation metadata.

P2: Some implementation details still look generated rather than intentional

Problem: A few small code-quality details make the core-path change harder to trust:

  • LightweightExpressionEvaluator#evaluateFunction(...) copies functionSegment.getParameters() into a new ArrayList only to iterate it (LightweightExpressionEvaluator.java#L71-L80). There is no mutation or snapshot reason for this allocation.
  • ProjectionEngine uses an inline fully-qualified new java.util.LinkedHashMap<>() for the new field (ProjectionEngine.java#L68-L69) instead of a normal import, which goes against the repository coding standard for avoiding inline fully-qualified class names.
  • The evaluator returns plain null for every unsupported path, which makes control flow compact but hides the distinction between “the SQL expression evaluated to NULL” and “this evaluator does not support the expression shape.”

Impact: These are not large architectural problems by themselves, but this PR is modifying core binder/rewrite/merge behavior. Small generated-looking shortcuts in that area make the design harder to maintain and review safely.

Required Change: Please remove the unnecessary collection copy, use normal imports instead of inline fully-qualified class names, and make unsupported evaluator behavior explicit as part of the scoped evaluator contract. Please keep the fix narrow; do not introduce a general-purpose SQL expression engine in this PR.

Review Details

  • Review Focus: Code Correctness Review. CI not reviewed by request.
  • Reviewed Scope: Latest PR code around ProjectionEngine, ProjectionsContextEngine, ProjectionsContext, ShardingProjectionsTokenGenerator, GroupByMemoryMergedResult, GroupByStreamMergedResult, and LightweightExpressionEvaluator.
  • Not Reviewed Scope: GitHub Actions/check-runs/logs and full E2E validation.
  • Verification: This review is based on static code-path review of the latest PR head and the public changed files. The requested changes are design and maintainability corrections in the current implementation, not requests for broader feature scope.
  • Release Note / User Docs: No additional docs are requested here. The release note can remain scoped to the supported non-DISTINCT IFNULL / COALESCE aggregation-wrapper capability after the design issues above are fixed.

@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed review.

I've updated the implementation to address the concerns:

  • Renamed LightweightExpressionEvaluator to AggregationWrapperExpressionEvaluator and updated its Javadocs to explicitly scope it to supported aggregation wrapper projections (IFNULL/COALESCE) rather than presenting it as a general expression evaluator.
  • Made unsupported expression/function shapes explicit by throwing an exception instead of silently returning null.
  • Removed the hidden mutable state from ProjectionEngine. The expression-derived aggregation metadata is now created locally in ProjectionsContextEngine#createProjectionsContext(...) and passed through the projection creation flow, rather than retained as engine state.
  • Removed the unnecessary ArrayList allocation when iterating function parameters.
  • Replaced the inline fully-qualified LinkedHashMap usage with a normal import.

@somiljain2006 somiljain2006 requested a review from terrymanu July 10, 2026 12:19

@terrymanu terrymanu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Review Result: Not Mergeable

Feedback Mode: Change Request

Reason: Latest-head analysis found five correctness blockers and three required quality/completeness changes. This result is code-scope only; CI not reviewed by request.

Issues

P1: Derived aggregations bypass route and parameter rewriting

Problem: ShardingProjectionsTokenGenerator.java:106-123 appends AggregationProjection#getExpression() as raw SQL. ProjectionsToken.java:46-52 emits it verbatim, while RouteSQLRewriteEngine.java:155-169 retains the original parameter list.

Impact: IFNULL(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) gains another ? without another bound value. Qualified expressions such as SUM(t_order.amount) can also retain the logical owner after routing.

Required Change: Generate derived aggregations through the normal route-aware SQL/parameter rewrite path. Add prepared-CASE and qualified-owner multi-route tests.

P1: Wrapper eligibility is broader than evaluator capability

Problem: ProjectionEngine.java:124-147 registers a wrapper whenever any supported aggregation is found. AggregationWrapperExpressionEvaluator.java:57-81 then throws for parameter markers, columns, casts, binary expressions, and other legal operands.

Impact: Valid SQL such as IFNULL(SUM(price), ?) succeeds when SUM is non-null but throws IllegalArgumentException when the fallback is evaluated. A reviewer-added exact-head regression reproduced this failure.

Required Change: Make registration an all-or-nothing whole-tree capability check, or support every admitted operand with its bound/runtime value. Cover null and non-null parameter fallbacks plus mixed DISTINCT/window operands.

P1: Scalar-subquery metadata leaks into the outer query

Problem: ProjectionEngine.java:91-107 passes the outer aggregation map while recursively binding a scalar subquery. That conflicts with the separately owned subquery contexts created by SelectStatementBaseContext.java:187-195.

Impact: An inner SUM(t2.price) can be appended at the outer projection boundary, where t2 is not visible, producing invalid routed SQL or evaluating against the wrong query level.

Required Change: Keep expression-derived aggregation metadata inside the child SelectStatementContext. Add a scalar-subquery binder and final-rewrite regression proving the outer map remains empty.

P1: Empty COUNT wrappers return the fallback instead of zero

Problem: The synthetic empty-row path in GroupByMemoryMergedResult.java:145-176 initializes zero only when the visible projection is an AggregationProjection. For IFNULL(COUNT(*), 99), the visible projection is an expression, so its derived COUNT cell remains null.

Impact: IFNULL(COUNT(*), 99) returns 99 on an empty/no-route result, although COUNT must return 0. The exact-head regression produced expected 0, actual 99. See MySQL aggregate semantics.

Required Change: Initialize expression-derived COUNT cells by their actual result-set indices. Add empty-result IFNULL and COALESCE COUNT tests with nonzero fallbacks.

P1: Re-evaluation loses SQL result-type coercion

Problem: AggregationWrapperExpressionEvaluator.java:57-87 returns either the raw merged aggregation object or the parser’s literal object. Both merge implementations overwrite the backend-coerced wrapper value with that object.

Impact: IFNULL(SUM(decimal_column), 0) can expose BigDecimal when SUM wins and Integer when the fallback wins, despite one resolved SQL/JDBC column type. ShardingSphereResultSet#getObject returns this raw merged value. Existing tests compare only toString(), masking the mismatch. See MySQL IFNULL type rules and PostgreSQL COALESCE type resolution.

Required Change: Preserve or apply the database/result-metadata coercion for both branches. Assert exact returned classes and metadata in memory and stream regressions.

P2: Required root-path, stream, and documentation coverage is missing

Problem: The linked maintainer acceptance plan requests the two-shard first-empty/second-populated scenario, integration coverage, and merge documentation. The PR adds memory tests only; the modified stream path has no IFNULL/COALESCE test, and neither merge documentation file changes.

Impact: Parser → binder → routed rewrite → index mapping → stream/memory merge is not protected. The tests also omit the issue’s order-sensitive SUM scenario while COUNT remains correct.

Required Change: Add the exact two-shard integration regression, direct stream tests, and update merge.en.md/merge.cn.md with the supported operand boundary. Narrow the broad RELEASE-NOTES.md:75 claim if some legal forms remain unsupported.

P2: New Stream API use violates the high-frequency contract

Problem: ShardingProjectionsTokenGenerator.java:57,111-113 adds a Stream pipeline inside an @HighFrequencyInvocation class. CODE_OF_CONDUCT.md:76-79 explicitly forbids this.

Required Change: Replace the newly added stream/collect with ordinary iteration. The pre-existing stream elsewhere in the class does not justify adding another violation.

P2: Projection collections are copied without ownership or mutation need

Problem: GroupByMemoryMergedResult.java:163-175,186-203 copies expanded projections twice, including once per evaluated group, and indexes through a LinkedList. GroupByStreamMergedResult.java:65-70 makes another unmodified copy.

Impact: The memory path adds avoidable per-group allocation, and the synthetic-row indexed LinkedList scan is quadratic in projection count.

Required Change: Use the existing expanded-projection list directly or precompute the required expression-to-index mapping once.

Multi-Round Comparison

The latest head correctly resolves earlier requests around evaluator naming, explicit unsupported failures, invocation-local expression maps, AVG-derived projections, DISTINCT/window exclusion, duplicate expression mapping, and empty SUM synthesis. The findings above are latest-head issues; no stale prior request has been carried forward.

Review Details

  • Review Focus: Code Correctness Review. CI not reviewed by request.
  • Reviewed Scope: Exact head a9ef85f3b50567c9c413dddcc69e3cbfea512d14, merge-base 4004670ef7044e7bea76d4d3d29c425d4a8a0b37, all 20 changed files, linked issue requirements, prior review rounds, and direct binder/rewrite/merge/JDBC call paths.
  • Verification: Selected binder, OpenGauss, PostgreSQL, sharding API, and sharding-core tests passed. Scoped Spotless and Checkstyle checks passed with zero violations.
  • Reviewer Regressions: Two temporary exact-head tests produced one failure and one error: empty COUNT returned the fallback, and a parameter fallback threw IllegalArgumentException. The tests were run only in an isolated temporary clone and were fully reverted.
  • Workspace Integrity: No PR source file was modified. The shared workspace currently has 158 pre-existing dirty paths, with zero overlap against the PR’s 20 files. The isolated review clone is clean; no commit or push was performed.
  • Coverage Audit: 20/20 changed files reviewed; eight independent findings confirmed; the final fresh-context adversarial pass produced zero additional findings.
  • Not Reviewed Scope: GitHub Actions status, check runs, and CI logs. CI not reviewed by request.

@somiljain2006 somiljain2006 changed the title Support evaluation of IFNULL/COALESCE expressions over merged aggregation results Support evaluation of IFNULL/COALESCE scalar wrappers (with literal or nested-aggregation fallbacks) over merged aggregation results Jul 13, 2026
@somiljain2006

Copy link
Copy Markdown
Contributor Author

@terrymanu Thanks for the detailed review. I've addressed the reported issues in the latest revision:

  • Added rewrite regression coverage for qualified-owner aggregations and prepared CASE WHEN expressions to verify routing and parameter rewriting.
  • Tightened wrapper evaluation and expanded evaluator coverage for supported wrapper expressions across both memory and stream paths, including nested wrappers, duplicate derived aggregations, short-circuit behavior, fallback evaluation, and type coercion.
  • Isolated expression-derived aggregation metadata for scalar subqueries by using a separate aggregation map during recursive binding.
  • Fixed initialization of expression-derived COUNT aggregations for empty/no-route results and added corresponding regression tests.
  • Preserved result type coercion during wrapper evaluation and added assertions covering the returned Java types.
  • Added rewrite integration tests and updated the merger documentation to describe supported expression-derived aggregation wrappers.

@somiljain2006 somiljain2006 requested a review from terrymanu July 13, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross year aggregation failure

3 participants