Repository navigation
fix: use SUM0 in AggregateCaseToFilter D1 rewrite to avoid NULL on empty windows #20093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ea91c13
cacd9fb
2796511
98922e1
473de73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,13 +89,27 @@ public Aggregation toDruidAggregation( | |
| fieldName = virtualColumnRegistry.getOrCreateVirtualColumnForExpression(arg, aggregateCall.getType()); | ||
| } | ||
|
|
||
| return getAggregation(name, aggregateCall, macroTable, fieldName); | ||
| // 'finalizeAggregations' is also set by DruidAggregateCaseToFilterRule, to indicate that the filter of this | ||
| // aggregate call comes from the D1 rewrite of SUM(CASE WHEN COND THEN value ELSE 0 END), in which case it | ||
| // is semantically a SUM0 with 0 - rather than null - as its empty result. See that rule and | ||
| // SumZeroSqlAggregator for details. | ||
| return getAggregation(name, aggregateCall, macroTable, fieldName, finalizeAggregations); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Do not use finalization mode as the D1 marker Finding: finalizeAggregations is controlled by query nesting, not by DruidAggregateCaseToFilterRule; top-level execution explicitly calls toDruidQuery(false), while the rule only changes the AggregateCall to SUM0. Consequently this new flag is false for normal D1 rewrites, so SumZeroSqlAggregator returns the nullable native sum and a filter with no matching input still produces NULL. The new filtered_sum.iq no-match expectations of 0 therefore cannot pass for the ordinary query path. Suggestion: Carry an explicit D1-rewrite marker through aggregate translation separately from finalization, and apply the zero-empty handling only when that marker is present. |
||
| } | ||
|
|
||
| /** | ||
| * Returns the Druid {@link Aggregation} for this aggregate call. | ||
| * | ||
| * @param filteredByElseZeroRewrite true if the filter of this call was introduced by the | ||
| * {@code DruidAggregateCaseToFilterRule} rewrite of a | ||
| * {@code SUM(CASE WHEN COND THEN value ELSE 0 END)} expression, see | ||
| * {@link #toDruidAggregation}. Implementations that do not care about that | ||
| * distinction may ignore it. | ||
| */ | ||
| abstract Aggregation getAggregation( | ||
| String name, | ||
| AggregateCall aggregateCall, | ||
| ExprMacroTable macroTable, | ||
| String fieldName | ||
| String fieldName, | ||
| boolean filteredByElseZeroRewrite | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,8 +19,18 @@ | |
|
|
||
| package org.apache.druid.sql.calcite.aggregation.builtin; | ||
|
|
||
| import com.google.common.collect.Iterables; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Remove unused imports before static checks Finding: The new file imports Iterables and AggregatorFactory but uses neither. codestyle/checkstyle.xml enables the UnusedImports check, so the repository static-checks job will fail before exercising this query change. Suggestion: Remove both unused imports from SumZeroSqlAggregator.java. |
||
| import org.apache.calcite.rel.core.AggregateCall; | ||
| import org.apache.calcite.sql.SqlAggFunction; | ||
| import org.apache.calcite.sql.fun.SqlStdOperatorTable; | ||
| import org.apache.druid.math.expr.ExprMacroTable; | ||
| import org.apache.druid.query.aggregation.AggregatorFactory; | ||
| import org.apache.druid.query.aggregation.post.ExpressionPostAggregator; | ||
| import org.apache.druid.segment.column.ColumnType; | ||
| import org.apache.druid.sql.calcite.aggregation.Aggregation; | ||
| import org.apache.druid.sql.calcite.planner.Calcites; | ||
|
|
||
| import javax.annotation.Nullable; | ||
|
|
||
| public class SumZeroSqlAggregator extends SumSqlAggregator | ||
| { | ||
|
|
@@ -29,4 +39,49 @@ public SqlAggFunction calciteFunction() | |
| { | ||
| return SqlStdOperatorTable.SUM0; | ||
| } | ||
|
|
||
| @Override | ||
| @Nullable | ||
| Aggregation getAggregation( | ||
| final String name, | ||
| final AggregateCall aggregateCall, | ||
| final ExprMacroTable macroTable, | ||
| final String fieldName, | ||
| final boolean filteredByElseZeroRewrite | ||
| ) | ||
| { | ||
| final ColumnType valueType = Calcites.getColumnTypeForRelDataType(aggregateCall.getType()); | ||
| if (valueType == null) { | ||
| return null; | ||
| } | ||
|
|
||
| if (filteredByElseZeroRewrite) { | ||
| // This SUM0 only exists because DruidAggregateCaseToFilterRule rewrote the D1 shape | ||
| // SUM(CASE WHEN COND THEN value ELSE 0 END) into SUM0(value) FILTER (WHERE COND), and the | ||
| // native filtered aggregator returns null when the filter matches no row, while the | ||
| // original CASE expression returned 0. Restore that by wrapping the filtered sum in an | ||
| // expression post-aggregator that replaces null with 0. | ||
| // | ||
| // The post-aggregator is created here, instead of leaving it to Aggregation.filter, on | ||
| // purpose: the Aggregation returned by this method carries no aggregator factory, so | ||
| // Aggregation.filter never pushes the filter down into a FilteredAggregatorFactory. The | ||
| // resulting post-aggregator-only Aggregation is what Windowing accepts for window | ||
| // aggregations, while the equivalent factory-based post-aggregator | ||
| // ([expressionPostAgg, filteredAgg]) is rejected by Windowing.fromCalciteStuff. | ||
| return Aggregation.create( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Keep a real sum factory behind the post-aggregator Finding: When this branch is reached (for example, a D1 aggregate in a finalized native subquery), fieldName names the raw input or virtual column, not an existing aggregate output. Returning only nvl(fieldName, 0) removes the sum entirely. GroupByRules then applies the D1 filter, and Aggregation.filter throws "Cannot filter an Aggregation that does not contain its inputs" because the post-aggregator depends on fieldName but the factory list is empty. Thus previously executable nested CASE/SUM queries fail during planning; omitting the filter would still not compute a sum. A post-aggregator-only object also does not satisfy Windowing’s contract, which rejects every non-null post-aggregator. Suggestion: Retain a sum factory and reference its output from the post-aggregator, or implement the required semantics in an aggregate factory that satisfies both filtered aggregation and window translation contracts.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Retain the aggregate factory with the post-aggregator Finding: When finalizeAggregations is true, the new branch returns an Aggregation containing only an ExpressionPostAggregator whose expression references fieldName; no AggregatorFactory computes that field or receives the D1 filter. GroupByRules subsequently applies the rewritten CASE filter through Aggregation.filter, which rejects post-aggregator inputs absent from aggregatorFactories, so finalized subqueries using this rewrite fail during planning with an ISE. Suggestion: Keep the filtered SUM0 factory in the Aggregation and layer the null-to-zero result around its output, while handling any window-specific restriction in window translation rather than dropping the factory. |
||
| new ExpressionPostAggregator( | ||
| name, | ||
| "nvl(\"" + fieldName + "\", 0)", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Preserve NULL for all-null matching rows Finding: nvl(fieldName, 0) collapses two distinct SQL states: no rows pass the CASE condition and rows pass it but every value is NULL. The original SUM(CASE WHEN ... THEN value ELSE 0 END) returns 0 in the first state but NULL in the second, as the rewrite-disabled control in filtered_sum.iq demonstrates; this post-aggregator returns 0 for both whenever the branch is used. Suggestion: Distinguish an empty match from an all-null match before coalescing, or retain the original nullable result for the all-null case. |
||
| null, | ||
| valueType, | ||
| macroTable | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| // Plain SQL SUM0(x) and SUM0(x) FILTER (WHERE ...): keep the native nullable factory, which | ||
| // preserves the documented null-on-empty behavior for those calls. Only the D1 rewrite above | ||
| // is required to return 0. | ||
| return Aggregation.create(createSumAggregatorFactory(valueType, name, fieldName, macroTable)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -303,9 +303,13 @@ && isIntLiteral(arg2, BigDecimal.ZERO))) { | |
| newProjects.add(arg1); | ||
| newProjects.add(filter); | ||
|
|
||
| RelDataType newType = typeFactory.createTypeWithNullability(call.getType(), true); | ||
| // Use SUM0 instead of SUM to return 0 instead of NULL when filter never matches. | ||
| // This fixes the known inconsistency documented in the class javadoc where | ||
| // SUM(CASE WHEN COND THEN COL1 ELSE 0 END) would return NULL instead of 0 | ||
| // when no rows match the condition. | ||
| RelDataType newType = typeFactory.createTypeWithNullability(call.getType(), false); | ||
| return AggregateCall.create( | ||
| call.getAggregation(), | ||
| SqlStdOperatorTable.SUM0, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] SUM0 still executes as nullable SUM SumZeroSqlAggregator inherits SumSqlAggregator's nullable native Long/Float/DoubleSumAggregatorFactory. With no matching filter rows, the nullable wrapper returns NULL, so native Druid queries still produce NULL for the empty/no-match cases changed to 0 in filtered_sum.iq.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Rewrite changes all-null semantics When every row matches the condition and the value is NULL, SUM(CASE WHEN condition THEN value ELSE 0 END) returns NULL, while SUM0(value) FILTER returns 0. The rewrite applies this universally; the rewrite-disabled test still documents NULL for the 7=7,null case. |
||
| false, | ||
| false, | ||
| false, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Use an actual D1 marker instead of the query finalization flag
Finding: finalizeAggregations is not set by DruidAggregateCaseToFilterRule. It is a query-level flag for explicit finalization of subquery aggregators (see SqlAggregator and DruidOuterQueryRel); DruidRel.runQuery passes false for a top-level query, and Windowing also passes false. Consequently, the top-level D1 calls in filtered_sum.iq take the plain nullable sum-factory branch, so the no-match case still returns NULL instead of the newly expected 0. Conversely, SUM0 calls in finalized subqueries enter the new branch regardless of whether D1 produced them. This does not implement the claimed restriction and regresses the previous revision’s no-match fix.
Suggestion: Carry rewrite provenance explicitly, independently of finalizeAggregations, and cover both a top-level no-match D1 query and a finalized subquery with an unrelated SUM0.