This is an automated email from the ASF dual-hosted git repository.
englefly pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 9fc6513ac05 (fix)(eager-agg) null-to-non-null safety check and remove
redundant normalize calls in eager aggregation (#64820)
9fc6513ac05 is described below
commit 9fc6513ac056076b1876d3c810ce9940f368c5de
Author: minghong <[email protected]>
AuthorDate: Mon Jul 6 18:57:12 2026 +0800
(fix)(eager-agg) null-to-non-null safety check and remove redundant
normalize calls in eager aggregation (#64820)
### What problem does this PR solve?
1. generalize null-to-non-null safety check via NullToNonNullFunction
marker interface. (covering
CaseWhen、If、Coalesce、Nvl、NullOrEmpty、NotNullOrEmpty、 nullSafeEqual)
2. remove redundant normalize calls in eager aggregation
---
.../nereids/rules/analysis/NormalizeAggregate.java | 2 +-
.../rewrite/eageraggregation/EagerAggRewriter.java | 54 ++---
.../eageraggregation/PushDownAggContext.java | 27 ++-
.../eageraggregation/PushDownAggregation.java | 34 +--
.../doris/nereids/trees/expressions/CaseWhen.java | 2 +-
.../doris/nereids/trees/expressions/IsFalse.java | 3 +-
.../doris/nereids/trees/expressions/IsNull.java | 2 +-
.../doris/nereids/trees/expressions/IsTrue.java | 3 +-
.../nereids/trees/expressions/NullSafeEqual.java | 2 +-
.../trees/expressions/NullToNonNullFunction.java | 57 +++++
.../expressions/functions/scalar/Coalesce.java | 3 +-
.../trees/expressions/functions/scalar/If.java | 3 +-
.../functions/scalar/Ipv4StringToNumOrDefault.java | 3 +-
.../functions/scalar/Ipv6StringToNumOrDefault.java | 3 +-
.../expressions/functions/scalar/NonNullable.java | 4 +-
.../functions/scalar/NotNullOrEmpty.java | 3 +-
.../expressions/functions/scalar/NullOrEmpty.java | 3 +-
.../trees/expressions/functions/scalar/Nvl.java | 3 +-
.../functions/scalar/ToIpv4OrDefault.java | 3 +-
.../functions/scalar/ToIpv6OrDefault.java | 3 +-
.../nereids/trees/plans/logical/LogicalUnion.java | 2 +-
.../eageraggregation/EagerAggRewriterTest.java | 239 ++++++++++++++++++++-
22 files changed, 386 insertions(+), 72 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/NormalizeAggregate.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/NormalizeAggregate.java
index 83a2332290f..7b5e88060de 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/NormalizeAggregate.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/NormalizeAggregate.java
@@ -156,7 +156,7 @@ public class NormalizeAggregate implements
RewriteRuleFactory, NormalizeToSlot {
* c. alias with window-agg
*/
@SuppressWarnings("checkstyle:UnusedLocalVariable")
- public LogicalPlan normalizeAgg(LogicalAggregate<Plan> aggregate,
Optional<LogicalHaving<?>> having,
+ private LogicalPlan normalizeAgg(LogicalAggregate<Plan> aggregate,
Optional<LogicalHaving<?>> having,
CascadesContext ctx) {
// Push down exprs:
// collect group by exprs
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriter.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriter.java
index 5311cb96383..55ee5780607 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriter.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriter.java
@@ -18,18 +18,17 @@
package org.apache.doris.nereids.rules.rewrite.eageraggregation;
import org.apache.doris.common.Pair;
-import org.apache.doris.nereids.rules.analysis.NormalizeAggregate;
import org.apache.doris.nereids.rules.rewrite.StatsDerive;
import
org.apache.doris.nereids.rules.rewrite.eageraggregation.EagerAggHints.Action;
import org.apache.doris.nereids.stats.ExpressionEstimation;
import org.apache.doris.nereids.trees.expressions.Alias;
-import org.apache.doris.nereids.trees.expressions.CaseWhen;
import org.apache.doris.nereids.trees.expressions.Cast;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.Multiply;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
@@ -223,7 +222,7 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
return Pair.of(false, false);
}
if (deduplicateOnly) {
- return adjustPushSideForCaseWhen(join, context, toLeft, toRight);
+ return adjustPushSideForNullToNonNull(join, context, toLeft,
toRight);
}
if (toLeft && toRight) {
return join.getJoinType().isInnerOrCrossJoin()
@@ -231,7 +230,7 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
: Pair.of(false, false);
}
// one-side push down
- Pair<Boolean, Boolean> pushSide = adjustPushSideForCaseWhen(join,
context, toLeft, toRight);
+ Pair<Boolean, Boolean> pushSide = adjustPushSideForNullToNonNull(join,
context, toLeft, toRight);
if (!pushSide.first && !pushSide.second) {
return pushSide;
}
@@ -251,13 +250,14 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
&& hasAggNeedJoinMultiplicityRecovery(oppositeAggFunctions);
}
- private Pair<Boolean, Boolean> adjustPushSideForCaseWhen(
+ private Pair<Boolean, Boolean> adjustPushSideForNullToNonNull(
LogicalJoin<? extends Plan, ? extends Plan> join,
PushDownAggContext context,
boolean toLeft, boolean toRight) {
- // Do not push aggregation to the nullable side of outer joins when
agg function contains case-when.
- // CaseWhen expressions may produce non-null values from null-padded
rows (e.g., WHEN col IS NULL THEN -54),
+ // Do not push aggregation to the nullable side of outer joins when
agg function contains
+ // a NullToNonNullFunction (e.g. COALESCE, NVL, IF, CASE WHEN,
NULL_OR_EMPTY).
+ // These expressions may produce non-null values from null-padded rows,
// so pre-aggregation before the join loses those contributions.
- if (!(context.hasDecomposedAggIf || context.hasCaseWhen)) {
+ if (!(context.hasDecomposedAggIf || context.containsNullToNonNull)) {
return Pair.of(toLeft, toRight);
}
JoinType joinType = join.getJoinType();
@@ -274,8 +274,8 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
return Pair.of(toLeft, toRight);
}
- // Do not push agg(literal) or agg(preserved_side_col) to the nullable
side of outer joins.
- // Aggregates like count(*), sum(2), min(1) etc. aggregate over all
physical rows,
+ // Do not push count(*)/agg(literal)(e.g. sum(2), min(1)) to the nullable
side of outer joins.
+ // Aggregates without input from the nullable side aggregate over all
physical rows,
// including null-extended rows from the outer join.
// After pushdown to the nullable side, unmatched rows produce NULL for
the pre-aggregated value,
// losing the contribution of those rows (e.g. sum(2) should add 2 per
unmatched row,
@@ -383,21 +383,23 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
aggFunctions.add(newAggFunc);
}
// After pushing expressions past the project, the agg functions may
now
- // contain If/CaseWhen that were hidden behind slot references before.
- // e.g. count(#slot) where #slot = if(cond, a, b) in the project.
- // We must re-check and update hasCaseWhen accordingly.
- boolean newHasCaseWhen = context.hasCaseWhen;
- if (!newHasCaseWhen) {
+ // contain NullToNonNull expressions that were hidden behind slot
references before.
+ // e.g. count(#slot) where #slot = coalesce(a, 0) in the project.
+ // We must re-check and update containsNullToNonNull accordingly.
+ boolean newContainsNullToNonNull = context.containsNullToNonNull;
+ if (!newContainsNullToNonNull) {
for (AggregateFunction aggFunc : aggFunctions) {
- if (aggFunc.anyMatch(e -> e instanceof CaseWhen || e
instanceof If)) {
- newHasCaseWhen = true;
+ if (aggFunc.children().stream().anyMatch(
+ arg -> arg.anyMatch(e ->
+
NullToNonNullFunction.canConvertNullToNonNull((Expression) e)))) {
+ newContainsNullToNonNull = true;
break;
}
}
}
PushDownAggContext newContext = new PushDownAggContext(aggFunctions,
groupKeys, aliasMap,
context.getCascadesContext(), context.isPassThroughBigJoin(),
- context.hasDecomposedAggIf, newHasCaseWhen,
+ context.hasDecomposedAggIf, newContainsNullToNonNull,
context.getBilateralState(), context.needOutputCount());
return newContext;
}
@@ -528,7 +530,7 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
.collect(Collectors.toList());
PushDownAggContext contextForChild = new
PushDownAggContext(aggFunctionsForChild, groupKeysForChild,
aliasMapForChild, context.getCascadesContext(),
- context.isPassThroughBigJoin(),
context.hasDecomposedAggIf, context.hasCaseWhen,
+ context.isPassThroughBigJoin(),
context.hasDecomposedAggIf, context.containsNullToNonNull,
context.getBilateralState(), context.needOutputCount());
inheritHintActionsToUnionChild(context, contextForChild,
aggFunctionsForChild);
Plan newChild = child.accept(this, contextForChild);
@@ -791,21 +793,23 @@ public class EagerAggRewriter extends
DefaultPlanRewriter<PushDownAggContext> {
aggOutputExpressions.add(outputCountAlias.get());
}
LogicalAggregate genAgg = new
LogicalAggregate(context.getGroupKeys(), aggOutputExpressions, child);
- NormalizeAggregate normalizeAggregate = new NormalizeAggregate();
- Plan normalized = normalizeAggregate.normalizeAgg(genAgg,
Optional.empty(),
- context.getCascadesContext());
for (AggregateFunction func : context.getAggFunctions()) {
Alias a = context.getAliasMap().get(func);
- Slot pushedSlot = normalized.getOutput().stream()
+ Slot pushedSlot = genAgg.getOutput().stream()
.filter(slot -> slot.getExprId().equals(a.getExprId()))
.findFirst()
.orElse(a.toSlot());
context.getBilateralState().registerPushedAggFuncSlot(a.getExprId(),
pushedSlot);
}
outputCountAlias.ifPresent(
- alias ->
context.getBilateralState().registerCountSlot(normalized, alias.toSlot()));
- return normalized;
+ alias ->
context.getBilateralState().registerCountSlot(genAgg, alias.toSlot()));
+ // Return non-normalized aggregate directly. The optimizer
fix-point loop
+ // will invoke NormalizeAggregate rule in the next iteration if
needed.
+ // Explicit normalization here is redundant (the original
aggregate was
+ // already normalized) and could alter expression shapes that the
push-down
+ // logic relies on for correct NullToNonNullFunction detection.
+ return genAgg;
} else {
return child;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggContext.java
index 0d2832617e6..cd315da374a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggContext.java
@@ -44,10 +44,15 @@ public class PushDownAggContext {
// count(if(...)): if(...) push down as a whole
// sum/min/max(if(truePart, elsePart)): if(...) can be split to
sum(truePart) and sum(elsePart)
public final boolean hasDecomposedAggIf;
- // When aggFunc(if(...)) is present, pushing down the null-supplemented
side of the outer join is avoided.
- // This is because null values are highly error-prone,
- // so the push-down operation is not performed during hashCaseWhen.
- public final boolean hasCaseWhen;
+ // When aggFunc contains expressions that can convert NULL to non-NULL
+ // (e.g. COALESCE, NVL, IF, CASE WHEN), pushing down to the nullable side
of
+ // an outer join is blocked — null-extended rows would be wrongly counted.
+ //
+ // TODO: This is conservative — a per-function check (rather than a global
flag)
+ // would allow pushing non-NullToNonNull aggregate functions (e.g. sum(a))
to
+ // the nullable side even when another agg function contains a
NullToNonNull
+ // expression. Currently, one problematic agg function blocks all
push-down.
+ public final boolean containsNullToNonNull;
private final List<AggregateFunction> aggFunctions;
private final List<SlotReference> groupKeys;
private final HashMap<AggregateFunction, Alias> aliasMap;
@@ -66,10 +71,10 @@ public class PushDownAggContext {
public PushDownAggContext(List<AggregateFunction> aggFunctions,
List<SlotReference> groupKeys, Map<AggregateFunction, Alias>
aliasMap, CascadesContext cascadesContext,
- boolean passThroughBigJoin, boolean hasDecomposedAggIf, boolean
hasCaseWhen,
+ boolean passThroughBigJoin, boolean hasDecomposedAggIf, boolean
containsNullToNonNull,
BilateralState bilateralState) {
- this(aggFunctions, groupKeys, aliasMap, cascadesContext,
passThroughBigJoin, hasDecomposedAggIf, hasCaseWhen,
- bilateralState, false);
+ this(aggFunctions, groupKeys, aliasMap, cascadesContext,
passThroughBigJoin,
+ hasDecomposedAggIf, containsNullToNonNull, bilateralState,
false);
}
/**
@@ -77,7 +82,7 @@ public class PushDownAggContext {
*/
public PushDownAggContext(List<AggregateFunction> aggFunctions,
List<SlotReference> groupKeys, Map<AggregateFunction, Alias>
aliasMap, CascadesContext cascadesContext,
- boolean passThroughBigJoin, boolean hasDecomposedAggIf, boolean
hasCaseWhen,
+ boolean passThroughBigJoin, boolean hasDecomposedAggIf, boolean
containsNullToNonNull,
BilateralState bilateralState, boolean needOutputCount) {
this.groupKeys =
groupKeys.stream().distinct().collect(Collectors.toList());
this.aggFunctions = ImmutableList.copyOf(aggFunctions);
@@ -105,7 +110,7 @@ public class PushDownAggContext {
.collect(ImmutableSet.toImmutableSet());
this.passThroughBigJoin = passThroughBigJoin;
this.hasDecomposedAggIf = hasDecomposedAggIf;
- this.hasCaseWhen = hasCaseWhen;
+ this.containsNullToNonNull = containsNullToNonNull;
this.needOutputCount = needOutputCount;
this.bilateralState = Objects.requireNonNull(bilateralState,
"bilateralState cannot be null");
for (Map.Entry<AggregateFunction, Alias> entry :
this.aliasMap.entrySet()) {
@@ -143,7 +148,7 @@ public class PushDownAggContext {
public PushDownAggContext withGroupKeys(List<SlotReference> groupKeys) {
return new PushDownAggContext(aggFunctions, groupKeys, aliasMap,
- cascadesContext, passThroughBigJoin, hasDecomposedAggIf,
hasCaseWhen,
+ cascadesContext, passThroughBigJoin, hasDecomposedAggIf,
containsNullToNonNull,
bilateralState, needOutputCount);
}
@@ -154,7 +159,7 @@ public class PushDownAggContext {
Map<AggregateFunction, Alias> branchAliasMap, List<SlotReference>
groupKeys,
boolean passThroughBigJoin, boolean needOutputCount) {
return new PushDownAggContext(branchAggFunctions, groupKeys,
branchAliasMap,
- cascadesContext, passThroughBigJoin, hasDecomposedAggIf,
hasCaseWhen,
+ cascadesContext, passThroughBigJoin, hasDecomposedAggIf,
containsNullToNonNull,
bilateralState, needOutputCount);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggregation.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggregation.java
index ef75f37a18f..0cb410c1e3c 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggregation.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/PushDownAggregation.java
@@ -18,13 +18,12 @@
package org.apache.doris.nereids.rules.rewrite.eageraggregation;
import org.apache.doris.nereids.jobs.JobContext;
-import org.apache.doris.nereids.rules.analysis.NormalizeAggregate;
import org.apache.doris.nereids.rules.rewrite.AdjustNullable;
import org.apache.doris.nereids.trees.expressions.Alias;
-import org.apache.doris.nereids.trees.expressions.CaseWhen;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
@@ -57,7 +56,6 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -129,7 +127,7 @@ public class PushDownAggregation extends
DefaultPlanRewriter<JobContext> impleme
Set<AggregateFunction> aggFunctions = Sets.newHashSet();
boolean hasDecomposedAggIf = false;
- boolean hasCaseWhen = false;
+ boolean containsNullToNonNull = false;
Map<NamedExpression, List<AggregateFunction>>
aggFunctionsForOutputExpressions = Maps.newHashMap();
for (NamedExpression aggOutput : agg.getOutputExpressions()) {
List<AggregateFunction> funcs = Lists.newArrayList();
@@ -143,14 +141,18 @@ public class PushDownAggregation extends
DefaultPlanRewriter<JobContext> impleme
if (aggFunction.containsVolatileExpression()) {
return agg;
}
- // CaseWhen and If (which CASE WHEN is normalized into)
must both be checked.
- // When an agg function contains an If/CaseWhen whose
condition tests IS NULL
- // (e.g. count(if(col IS NULL, value, NULL))), pushing it
to the nullable side
- // of an outer join produces wrong results: null-extended
rows make "col IS NULL"
- // TRUE at the top level, but the pre-aggregated count
slot becomes NULL after
- // null-extension, and ifnull(sum(NULL), 0) = 0 instead of
the correct 1.
- if (!hasCaseWhen && aggFunction.anyMatch(e -> e instanceof
CaseWhen || e instanceof If)) {
- hasCaseWhen = true;
+ // NullToNonNullFunction / AlwaysNotNullable: expressions
that can convert NULL
+ // input to non-NULL output (e.g. COALESCE, NVL, IF, CASE
WHEN, Array).
+ // When an agg function contains such an expression
wrapping a column from the
+ // nullable side of an outer join, null-extended rows
would produce non-NULL values
+ // that get counted by the aggregation. But the
pre-aggregation on the base table
+ // cannot see null-extended rows (they are produced by the
join), so the push-down
+ // would lose those contributions — producing wrong
results.
+ if (!containsNullToNonNull
+ && aggFunction.children().stream().anyMatch(
+ arg -> arg.anyMatch(e ->
+
NullToNonNullFunction.canConvertNullToNonNull((Expression) e)))) {
+ containsNullToNonNull = true;
}
if (aggFunction.arity() > 0 && aggFunction.child(0)
instanceof If
&& !(aggFunction instanceof Count)) {
@@ -189,13 +191,13 @@ public class PushDownAggregation extends
DefaultPlanRewriter<JobContext> impleme
}
}
- groupKeys = groupKeys.stream().distinct().collect(Collectors.toList());
if (!checkSubTreePattern(agg.child())) {
return agg;
}
+ groupKeys = groupKeys.stream().distinct().collect(Collectors.toList());
PushDownAggContext pushDownContext = new PushDownAggContext(new
ArrayList<>(aggFunctions),
- groupKeys, null, context.getCascadesContext(), false,
hasDecomposedAggIf, hasCaseWhen,
+ groupKeys, null, context.getCascadesContext(), false,
hasDecomposedAggIf, containsNullToNonNull,
new BilateralState());
if (groupKeys.isEmpty()) {
return agg;
@@ -265,9 +267,7 @@ public class PushDownAggregation extends
DefaultPlanRewriter<JobContext> impleme
}
LogicalAggregate<Plan> eagerAgg =
agg.withAggOutputChild(newOutputExpressions, child);
- NormalizeAggregate normalizeAggregate = new
NormalizeAggregate();
- return normalizeAggregate.normalizeAgg(eagerAgg,
Optional.empty(),
- context.getCascadesContext());
+ return eagerAgg;
}
} catch (RuntimeException e) {
String msg = "PushDownAggregation failed: " + e.getMessage() +
"\n" + agg.treeString();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/CaseWhen.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/CaseWhen.java
index 4c81393e6b0..a2cd9cb4730 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/CaseWhen.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/CaseWhen.java
@@ -43,7 +43,7 @@ import java.util.function.Supplier;
*
* Children layout: [value?, WhenClause+, defaultValue?]
*/
-public class CaseWhen extends Expression implements NeedSessionVarGuard {
+public class CaseWhen extends Expression implements NeedSessionVarGuard,
NullToNonNullFunction {
private final Optional<Expression> value;
private final List<WhenClause> whenClauses;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsFalse.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsFalse.java
index fdd17c296fc..09f2a77039f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsFalse.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsFalse.java
@@ -33,7 +33,8 @@ import java.util.Objects;
/**
* expr is false predicate.
*/
-public class IsFalse extends Expression implements UnaryExpression,
AlwaysNotNullable {
+public class IsFalse extends Expression
+ implements UnaryExpression, AlwaysNotNullable, NullToNonNullFunction {
public IsFalse(Expression e) {
super(ImmutableList.of(e));
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsNull.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsNull.java
index a587ac6f8e2..56371c4c77c 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsNull.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsNull.java
@@ -33,7 +33,7 @@ import java.util.Objects;
/**
* expr is null predicate.
*/
-public class IsNull extends Expression implements UnaryExpression,
AlwaysNotNullable {
+public class IsNull extends Expression implements UnaryExpression,
AlwaysNotNullable, NullToNonNullFunction {
public IsNull(Expression e) {
super(ImmutableList.of(e));
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsTrue.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsTrue.java
index 366ab0056dc..1876a6dec54 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsTrue.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/IsTrue.java
@@ -33,7 +33,8 @@ import java.util.Objects;
/**
* expr is true predicate.
*/
-public class IsTrue extends Expression implements UnaryExpression,
AlwaysNotNullable {
+public class IsTrue extends Expression
+ implements UnaryExpression, AlwaysNotNullable, NullToNonNullFunction {
public IsTrue(Expression e) {
super(ImmutableList.of(e));
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullSafeEqual.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullSafeEqual.java
index 6eda6c738b7..5bd0769e259 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullSafeEqual.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullSafeEqual.java
@@ -29,7 +29,7 @@ import java.util.List;
* Null safe equal expression: a <=> b.
* Unlike normal equal to expression, null <=> null is true.
*/
-public class NullSafeEqual extends EqualPredicate implements AlwaysNotNullable
{
+public class NullSafeEqual extends EqualPredicate implements
AlwaysNotNullable, NullToNonNullFunction {
public NullSafeEqual(Expression left, Expression right) {
this(left, right, false);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullToNonNullFunction.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullToNonNullFunction.java
new file mode 100644
index 00000000000..e281b4f03e4
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/NullToNonNullFunction.java
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.trees.expressions;
+
+import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
+
+/**
+ * Marker interface for expressions that can convert NULL input into a
non-NULL output.
+ *
+ * For example: Coalesce(NULL, 2) → 2, Nvl(NULL, 0) → 0, NullOrEmpty(NULL) →
true.
+ *
+ * This is significant for outer-join push-down safety: when an aggregate
function contains
+ * a NullToNonNull expression wrapping a column from the nullable side of an
outer join,
+ * the aggregation must NOT be pushed down. Null-extended rows (produced by
the join for
+ * unmatched rows) have NULL for all nullable-side columns. The NullToNonNull
expression
+ * would convert those NULLs to non-NULL values, and the pre-aggregation would
miss those
+ * contributions because null-extended rows do not exist in the base table.
+ *
+ * <p>Note: {@link AlwaysNotNullable} expressions with input slots (e.g.
Array, JsonArray,
+ * JsonObject, CreateStruct, CreateMap) are also blocked from being pushed to
the nullable
+ * side of outer joins via a separate check in {@link
#canConvertNullToNonNull(Expression)}.
+ */
+public interface NullToNonNullFunction {
+
+ /**
+ * Check whether an expression can convert NULL input to non-NULL output.
+ * This covers both {@link NullToNonNullFunction} (e.g. Coalesce, Nvl, If,
CaseWhen,
+ * NullOrEmpty, IsNull, IsTrue, IsFalse, NonNullable)
+ * and {@link AlwaysNotNullable} expressions with input slots (e.g. Array,
JsonArray,
+ * CreateStruct, CreateMap), which always produce non-NULL output
regardless of NULL inputs.
+ *
+ * <p>In outer-join push-down safety checks, any expression matching this
predicate
+ * must NOT be pushed to the nullable side, because null-extended rows
(produced by the
+ * join for unmatched rows) would produce non-NULL values that get
aggregated, but the
+ * pre-aggregation on the base table cannot see those rows — resulting in
wrong results.
+ */
+ static boolean canConvertNullToNonNull(Expression e) {
+ return e instanceof NullToNonNullFunction
+ || (e instanceof AlwaysNotNullable
+ && !e.getInputSlots().isEmpty());
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Coalesce.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Coalesce.java
index 2bc8a67ff08..2c63b17ef2e 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Coalesce.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Coalesce.java
@@ -20,6 +20,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NeedSessionVarGuard;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.CustomSignature;
import org.apache.doris.nereids.trees.expressions.functions.ExpressionTrait;
import org.apache.doris.nereids.trees.expressions.functions.SearchSignature;
@@ -39,7 +40,7 @@ import java.util.stream.Collectors;
/**
* ScalarFunction 'coalesce'. This class is generated by GenerateFunction.
*/
-public class Coalesce extends ScalarFunction implements CustomSignature,
NeedSessionVarGuard {
+public class Coalesce extends ScalarFunction implements CustomSignature,
NeedSessionVarGuard, NullToNonNullFunction {
/**
* constructor with 1 or more arguments.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/If.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/If.java
index 118ed8622ec..abfb322a717 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/If.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/If.java
@@ -21,6 +21,7 @@ import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.analyzer.Unbound;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NeedSessionVarGuard;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.CustomSignature;
import org.apache.doris.nereids.trees.expressions.functions.SearchSignature;
import org.apache.doris.nereids.trees.expressions.shape.TernaryExpression;
@@ -38,7 +39,7 @@ import java.util.Optional;
* ScalarFunction 'if'. This class is generated by GenerateFunction.
*/
public class If extends ScalarFunction
- implements TernaryExpression, CustomSignature, NeedSessionVarGuard {
+ implements TernaryExpression, CustomSignature, NeedSessionVarGuard,
NullToNonNullFunction {
/**
* constructor with 3 arguments.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv4StringToNumOrDefault.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv4StringToNumOrDefault.java
index 35eedb5c621..fd837d22157 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv4StringToNumOrDefault.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv4StringToNumOrDefault.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.BinaryExpression;
@@ -36,7 +37,7 @@ import java.util.List;
* scalar function ipv4_string_to_num_or_default
*/
public class Ipv4StringToNumOrDefault extends ScalarFunction
- implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable, NullToNonNullFunction {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(BigIntType.INSTANCE).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv6StringToNumOrDefault.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv6StringToNumOrDefault.java
index b431fb0741f..beea54c451e 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv6StringToNumOrDefault.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Ipv6StringToNumOrDefault.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.BinaryExpression;
@@ -35,7 +36,7 @@ import java.util.List;
* scalar function ipv6_string_to_num_or_default
*/
public class Ipv6StringToNumOrDefault extends ScalarFunction
- implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable, NullToNonNullFunction {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(VarcharType.SYSTEM_DEFAULT).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NonNullable.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NonNullable.java
index a4bc35b414d..c5fa29c5020 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NonNullable.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NonNullable.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import org.apache.doris.nereids.trees.expressions.functions.CustomSignature;
import org.apache.doris.nereids.trees.expressions.shape.UnaryExpression;
@@ -32,7 +33,8 @@ import java.util.List;
/**
* change nullable input col to non_nullable col
*/
-public class NonNullable extends ScalarFunction implements UnaryExpression,
CustomSignature, AlwaysNotNullable {
+public class NonNullable extends ScalarFunction
+ implements UnaryExpression, CustomSignature, AlwaysNotNullable,
NullToNonNullFunction {
public NonNullable(Expression expr) {
super("non_nullable", expr);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NotNullOrEmpty.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NotNullOrEmpty.java
index fa3741b9491..a7e8e8b2f5f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NotNullOrEmpty.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NotNullOrEmpty.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.UnaryExpression;
@@ -36,7 +37,7 @@ import java.util.List;
* ScalarFunction 'not_null_or_empty'. This class is generated by
GenerateFunction.
*/
public class NotNullOrEmpty extends ScalarFunction
- implements UnaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements UnaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable, NullToNonNullFunction {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(BooleanType.INSTANCE).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NullOrEmpty.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NullOrEmpty.java
index 8b511e3f68d..68b5978c179 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NullOrEmpty.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/NullOrEmpty.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.UnaryExpression;
@@ -36,7 +37,7 @@ import java.util.List;
* ScalarFunction 'null_or_empty'. This class is generated by GenerateFunction.
*/
public class NullOrEmpty extends ScalarFunction
- implements UnaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements NullToNonNullFunction, UnaryExpression,
ExplicitlyCastableSignature, AlwaysNotNullable {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(BooleanType.INSTANCE).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Nvl.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Nvl.java
index 8cdb95a7ca1..4c522b6b333 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Nvl.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Nvl.java
@@ -20,6 +20,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NeedSessionVarGuard;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.CustomSignature;
import org.apache.doris.nereids.trees.expressions.functions.SearchSignature;
import org.apache.doris.nereids.trees.expressions.shape.BinaryExpression;
@@ -36,7 +37,7 @@ import java.util.Optional;
* ScalarFunction 'nvl'. This class is generated by GenerateFunction.
*/
public class Nvl extends ScalarFunction
- implements BinaryExpression, CustomSignature, NeedSessionVarGuard {
+ implements BinaryExpression, CustomSignature, NeedSessionVarGuard,
NullToNonNullFunction {
/**
* constructor with 2 arguments.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv4OrDefault.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv4OrDefault.java
index d9f61e8a7fb..17a06327e28 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv4OrDefault.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv4OrDefault.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.BinaryExpression;
@@ -36,7 +37,7 @@ import java.util.List;
* scalar function to_ipv4_or_default
*/
public class ToIpv4OrDefault extends ScalarFunction
- implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable, NullToNonNullFunction {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(IPv4Type.INSTANCE).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv6OrDefault.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv6OrDefault.java
index 142e35b4ca2..8b7db83d227 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv6OrDefault.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ToIpv6OrDefault.java
@@ -19,6 +19,7 @@ package
org.apache.doris.nereids.trees.expressions.functions.scalar;
import org.apache.doris.catalog.FunctionSignature;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
import org.apache.doris.nereids.trees.expressions.shape.BinaryExpression;
@@ -36,7 +37,7 @@ import java.util.List;
* scalar function to_ipv6_or_default
*/
public class ToIpv6OrDefault extends ScalarFunction
- implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable {
+ implements BinaryExpression, ExplicitlyCastableSignature,
AlwaysNotNullable, NullToNonNullFunction {
public static final List<FunctionSignature> SIGNATURES = ImmutableList.of(
FunctionSignature.ret(IPv6Type.INSTANCE).args(VarcharType.SYSTEM_DEFAULT),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalUnion.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalUnion.java
index 5f2d912a8dd..660ecef4b53 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalUnion.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalUnion.java
@@ -435,7 +435,7 @@ public class LogicalUnion extends LogicalSetOperation
implements Union, OutputPr
return changed ? castedRow.build() : row;
}
- public LogicalSetOperation withChildrenAndOutputs(List<Plan> children,
List<NamedExpression> newOuptuts,
+ public LogicalUnion withChildrenAndOutputs(List<Plan> children,
List<NamedExpression> newOuptuts,
List<List<SlotReference>> childrenOutputs) {
Preconditions.checkArgument(children.size() == childrenOutputs.size(),
"children size %s is not equals with children outputs size %s",
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriterTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriterTest.java
index 758e23a4ba0..5e58f6a8ef5 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriterTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/eageraggregation/EagerAggRewriterTest.java
@@ -272,10 +272,10 @@ class EagerAggRewriterTest extends TestWithFeService
implements MemoPatternMatch
void testNotPushCountCaseWhenWithElseToNullableSideViaProject() {
// When CASE WHEN has an ELSE clause (e.g. CASE WHEN cond THEN -121
ELSE 2 END),
// NormalizeAggregate extracts if(cond, -121, 2) into a Project as a
slot,
- // so the aggregate becomes count(#slot). At the agg level,
hasCaseWhen=false
+ // so the aggregate becomes count(#slot). At the agg level,
containsNullToNonNull=false
// because count(#slot) contains no If/CaseWhen.
//
- // EagerAggRewriter must recheck hasCaseWhen after substituting
through the
+ // EagerAggRewriter must recheck containsNullToNonNull after
substituting through the
// Project (in createContextFromProject), otherwise count(if(cond,
-121, 2))
// is incorrectly pushed to the nullable side of an outer join.
//
@@ -551,6 +551,241 @@ class EagerAggRewriterTest extends TestWithFeService
implements MemoPatternMatch
}
}
+ //
=========================================================================
+ // NullToNonNullFunction safety guard tests:
+ // Agg functions wrapping expressions that convert NULL to non-NULL values
+ // (IsNull, NullSafeEqual, COALESCE, NVL, NULL_OR_EMPTY, NOT_NULL_OR_EMPTY)
+ // must NOT be pushed to the nullable side of outer joins. Null-extended
rows
+ // would produce non-NULL values at the top level, but the pre-aggregation
+ // cannot see those rows, producing wrong results.
+ //
=========================================================================
+
+ @Test
+ void testNotPushBareIsNullToNullableSideOfOuterJoin() {
+ // count(col IS NULL): IsNull converts NULL input to TRUE,
+ // so null-extended rows contribute 1. Pushed pre-agg on the base table
+ // would miss those rows.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side (left side of RIGHT JOIN)
+ // count(t1.name IS NULL) must NOT be pushed to t1
+ String sql = "select count(t1.name IS NULL), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side (right side of LEFT JOIN)
+ // count(t2.name IS NULL) must NOT be pushed to t2
+ sql = "select count(t2.name IS NULL), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushNullSafeEqualToNullableSideOfOuterJoin() {
+ // count(col <=> value): NullSafeEqual always returns TRUE/FALSE
(never NULL),
+ // so null-extended rows contribute to the count. Pushed pre-agg would
miss them.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side
+ // count(t1.name <=> NULL) must NOT be pushed to t1
+ String sql = "select count(t1.name <=> NULL), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side
+ // count(t2.name <=> 'x') must NOT be pushed to t2
+ sql = "select count(t2.name <=> 'x'), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushCoalesceToNullableSideOfOuterJoin() {
+ // count(coalesce(col, default)): COALESCE converts NULL input to
default,
+ // so null-extended rows contribute 1. Pushed pre-agg would miss them.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side
+ String sql = "select count(coalesce(t1.name, 'default')), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side
+ sql = "select count(coalesce(t2.name, 'x')), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushNvlToNullableSideOfOuterJoin() {
+ // count(nvl(col, default)): NVL converts NULL input to default.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side
+ String sql = "select count(nvl(t1.name, 'default')), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side
+ sql = "select count(nvl(t2.name, 0)), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushNullOrEmptyToNullableSideOfOuterJoin() {
+ // count(null_or_empty(col)): NULL_OR_EMPTY converts NULL to TRUE.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side
+ String sql = "select count(null_or_empty(t1.name)), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side
+ sql = "select count(null_or_empty(t2.name)), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushNotNullOrEmptyToNullableSideOfOuterJoin() {
+ // count(not_null_or_empty(col)): NOT_NULL_OR_EMPTY converts NULL to
FALSE
+ // (non-NULL).
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side
+ String sql = "select count(not_null_or_empty(t1.name)), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side
+ sql = "select count(not_null_or_empty(t2.name)), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
+ @Test
+ void testNotPushAlwaysNotNullableToNullableSideOfOuterJoin() {
+ // count(array(col)): Array is AlwaysNotNullable (not
NullToNonNullFunction),
+ // but it still converts NULL input to non-NULL output (array(NULL) →
[NULL]),
+ // so it must be caught by the AlwaysNotNullable fallback branch in
+ // NullToNonNullFunction.canConvertNullToNonNull().
+ // Pushing pre-agg under the nullable side would miss null-extended
row contributions.
+ connectContext.getSessionVariable().setEagerAggregationMode(1);
+ connectContext.getSessionVariable().setDisableJoinReorder(true);
+ try {
+ // RIGHT JOIN: t1 is the nullable side, array(t1.name) on the
nullable side
+ String sql = "select count(array(t1.name)), t2.id2"
+ + " from t1 right join t2 on t1.id1 = t2.id2 group by
t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(logicalAggregate(), any()))
+ .printlnTree();
+
+ // LEFT JOIN: t2 is the nullable side, array(t2.name) on the
nullable side
+ sql = "select count(array(t2.name)), t1.id1"
+ + " from t1 left join t2 on t1.id1 = t2.id2 group by
t1.id1";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+ .nonMatch(logicalJoin(any(), logicalAggregate()))
+ .printlnTree();
+
+ // Verify that array on the non-nullable side CAN still be pushed.
+ // INNER JOIN: array on either side is safe because no
null-extended rows
+ sql = "select count(array(t1.name)), t2.id2"
+ + " from t1 join t2 on t1.id1 = t2.id2 group by t2.id2";
+ PlanChecker.from(connectContext)
+ .analyze(sql)
+ .rewrite()
+
.matches(logicalAggregate(logicalProject(logicalJoin(logicalAggregate(),
any()))))
+ .printlnTree();
+ } finally {
+ connectContext.getSessionVariable().setEagerAggregationMode(0);
+ connectContext.getSessionVariable().setDisableJoinReorder(false);
+ }
+ }
+
@Test
void testInvalidFilterContextFallsBackToCurrentFilter() {
connectContext.getSessionVariable().setEagerAggregationMode(1);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]