This is an automated email from the ASF dual-hosted git repository.

yujun777 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 0f6600d2053 [fix](mv rewrite) Keep the scalar aggregate when a grouped 
MV answers it (#68861)
0f6600d2053 is described below

commit 0f6600d2053fe14a7836a48ea04f4722410d8a68
Author: yujun <[email protected]>
AuthorDate: Thu Oct 22 21:56:32 2026 +0800

    [fix](mv rewrite) Keep the scalar aggregate when a grouped MV answers it 
(#68861)
    
    A scalar aggregate query can be answered by a **grouped** aggregate
    materialized view.
    `AbstractMaterializedViewAggregateRule#isGroupByEquals` treats the query
    and the view grouping as
    equal when a uniform function dependency proves the filtered view holds
    at most one group, and then
    eliminates the aggregate entirely, leaving a bare view scan. With
    
    ```sql
    select count(*) from base where k = 9999;   -- k = 9999 matches no row
    ```
    
    the rewritten plan was `filter -> OlapScan[mv]` with no aggregate node
    at all, so the query returned
    an **empty result set** instead of one row of `0`. "At most one group"
    does not imply "one group
    exists", while a scalar aggregate must always emit exactly one row.
    
    COUNT is the function that hits this: its argument is not a real column,
    so it is the only aggregate
    whose output the fast path can substitute directly with a view slot.
    `SUM`/`MIN`/`MAX` over the same
    view already fall through to the roll up path and return the correct
    single row.
    
    The roll up path has a second, related gap: the slots the project above
    the rewritten aggregate is
    built from come from the rolled up expressions before
    `LogicalAggregate`'s constructor forces a
    scalar `NullableAggregateFunction` to nullable, so `AdjustNullable`
    rejects the nullability flip and,
    with `fe_debug` on, throws away the whole rewrite candidate.
    
    ### What is changed and how does it work?
    
    1. `isGroupByEquals`: reject the aggregate-eliminating fast path when
    the query is a scalar aggregate
    and the view is grouped, so the rewrite goes through the roll up path
    and keeps the aggregate node.
    The `query scalar + view scalar` combination is untouched — there the
    view always holds exactly one
       row, so eliminating the aggregate is safe.
    2. roll up: build the query output to rewritten output mapping, and the
    `needTopProject` comparison,
    from the constructed aggregate's output expressions, so the slots carry
    the nullability the node
       actually produces.
    3. `Predicates.compensatePredicates`: stop pruning a compensation
    candidate as redundant merely because
    the view states the same condition, when that candidate constrains an
    aggregate or a window output and
    the rewrite computes that value again. A view with a `HAVING` of its own
    enforced the condition over the
    value *it* aggregated, while the query needs it over the value *its own*
    aggregate produces from the
    view rows, and a roll up changes that value: over the empty row set it
    is `0`, which the retained scalar
    aggregate would answer with a row while the query yields none. Such a
    predicate now stays in the
    compensation, where the existing `rejectUnsafeResidualCompensation`
    check sees it and refuses the
    rewrite, so a view carrying a `HAVING` is rejected for these queries the
    same way it already is for
    every other query against it. Whether the value is computed again comes
    from a new
    `aggregateOutputIsRecomputed` hook which the aggregate rule sets for
    exactly the shape item 1 keeps the
    aggregate for, a query without grouping over a view which groups by
    something; a query and a view that
    group by the same dimensions and repeat the same condition still read
    the stored value, so the view
       still answers them.
    
    The empty-input guarantee itself needs no compensation here: #68863,
    already merged, made
    `Count.constructRollUp` produce `SUM0`, which is a
    `NotNullableAggregateFunction` and returns `0` for
    an empty input just like `COUNT` does. That is why this PR no longer
    wraps the rolled up aggregate in
    `Nvl` — it would only add a redundant `ifnull` on top of a roll up that
    already guarantees the value.
    
    A query `HAVING` over the retained scalar aggregate keeps working for a
    view without a `HAVING` of
    its own: the filter stays above the aggregate and is evaluated on the
    rolled up value, so a `HAVING`
    the single rolled up row does not pass yields no row at all. For a view
    which does have one, the query
    is now answered by the base table instead of by that view (see 3 above);
    before this change such a
    pair was rewritten by dropping the query `HAVING`, which answered
    `select count(*) from t where k = 9999 having count(*) > 0` with a row
    of `0` where the query yields
    no row.
    
    Before / after for the query above:
    
    ```
    empty set
    ```
    
    ```
    +----------+
    | count(*) |
    +----------+
    |        0 |
    +----------+
    ```
    
    and the plan now keeps the aggregate over the view:
    
    ```
    PhysicalResultSink
    --PhysicalProject
    ----hashAgg[GLOBAL]
    ------PhysicalProject
    --------filter((mv.k = 9999))
    ----------PhysicalOlapScan[mv]
    ```
    
    ### Release note
    
    Two related changes for scalar aggregates rewritten by a grouped
    aggregate materialized view:
    
    - `select count(*) from t where <predicate matching no row>` now returns
    `0` instead of an empty result
      set.
    - a scalar query whose `HAVING` repeats the `HAVING` of an aggregate
    materialized view is no longer
    rewritten by that view, because the roll up does not preserve the
    condition the view filtered on.
---
 .../mv/AbstractMaterializedViewAggregateRule.java  |  47 +++++-
 .../mv/AbstractMaterializedViewRule.java           |  12 +-
 .../nereids/rules/exploration/mv/Predicates.java   |  83 +++++++---
 .../apache/doris/nereids/mv/PredicatesTest.java    |  47 +++++-
 .../exploration/mv/MvExplorationSuiteTest.java     |  55 +++++++
 .../agg_scalar_empty_input.out                     | 138 +++++++++++++++++
 .../agg_scalar_empty_input.groovy                  | 171 +++++++++++++++++++++
 7 files changed, 527 insertions(+), 26 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewAggregateRule.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewAggregateRule.java
index 046793de494..77e3cde0117 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewAggregateRule.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewAggregateRule.java
@@ -294,6 +294,7 @@ public abstract class AbstractMaterializedViewAggregateRule 
extends AbstractMate
         // The shuttled query bottom aggregate outputs are used as the map 
keys and are passed to the
         // rewriter directly, and the shuttled query top plan outputs restore 
the projection expressions,
         // so the whole top plan is traversed only twice instead of once per 
output expression.
+        // The map values are filled after the rewritten aggregate is 
constructed, see the comment there.
         List<? extends Expression> shuttledBottomAggOutputs = 
ExpressionUtils.shuttleExpressionWithLineage(
                 queryAggregate.getOutputExpressions(), queryTopPlan);
         List<? extends Expression> shuttledTopPlanOutputs = 
ExpressionUtils.shuttleExpressionWithLineage(
@@ -319,7 +320,6 @@ public abstract class AbstractMaterializedViewAggregateRule 
extends AbstractMate
                         ? (NamedExpression) rewrittenGroupByExpression : new 
Alias(rewrittenGroupByExpression);
                 finalGroupExpressions.add(groupByOutput);
                 finalOutputExpressions.add(groupByOutput);
-                bottomAggOutputToNewExprMap.put(shuttledQueryAggregateOutput, 
groupByOutput.toSlot());
             } else {
                 // if it is an aggregate function, try to roll up and rewrite
                 Expression rewrittenFunction = 
rewriteShuttledExpression(queryStructInfo,
@@ -333,13 +333,23 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
                 }
                 NamedExpression functionOutput = new Alias(rewrittenFunction);
                 finalOutputExpressions.add(functionOutput);
-                bottomAggOutputToNewExprMap.put(shuttledQueryAggregateOutput, 
functionOutput.toSlot());
             }
         }
 
         LogicalAggregate<Plan> rewrittenAggregate =
                 new LogicalAggregate<>(finalGroupExpressions, 
finalOutputExpressions, tempRewritedPlan);
 
+        // Each rewritten aggregate output corresponds to one query aggregate 
output, so map the query
+        // aggregate output to the new output slot by position. The slot must 
be taken from the constructed
+        // aggregate: its constructor forces a NullableAggregateFunction of an 
aggregate without group by
+        // to nullable, since a scalar aggregate over an empty input returns 
NULL, and a slot built from
+        // the expression before that adjustment keeps the old nullability, 
which AdjustNullable rejects.
+        List<NamedExpression> rewrittenAggregateOutputs = 
rewrittenAggregate.getOutputExpressions();
+        for (int i = 0; i < queryAggregateOutputs.size(); i++) {
+            NamedExpression rewrittenOutput = rewrittenAggregateOutputs.get(i);
+            bottomAggOutputToNewExprMap.put(shuttledBottomAggOutputs.get(i), 
rewrittenOutput.toSlot());
+        }
+
         // rewrite the query top plan output expressions to reference the 
rewritten aggregate output,
         // the query top plan output slot is shuttled by lineage firstly to 
restore the projection
         // expression, so a projection of the group by key in the query top 
plan can be recomputed
@@ -366,10 +376,10 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
         // (e.g. `select k1 from t group by k1, k2`), in which case the 
redundant aggregate outputs must be
         // projected away. Otherwise the rewritten plan output count differs 
from the query and the candidate
         // is rejected by MaterializedViewUtils.normalizeExpressions, so a 
valid sync MV is silently not used.
-        boolean needTopProject = topProjectExpressions.size() != 
finalOutputExpressions.size();
+        boolean needTopProject = topProjectExpressions.size() != 
rewrittenAggregateOutputs.size();
         for (int i = 0; i < topProjectExpressions.size(); i++) {
-            if (i >= finalOutputExpressions.size()
-                    || 
!topProjectExpressions.get(i).toSlot().equals(finalOutputExpressions.get(i).toSlot()))
 {
+            if (i >= rewrittenAggregateOutputs.size()
+                    || 
!topProjectExpressions.get(i).toSlot().equals(rewrittenAggregateOutputs.get(i).toSlot()))
 {
                 needTopProject = true;
                 break;
             }
@@ -570,6 +580,23 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
         return true;
     }
 
+    @Override
+    protected boolean aggregateOutputIsRecomputed(StructInfo queryStructInfo, 
StructInfo viewStructInfo) {
+        // A predicate the view enforces over its own aggregate output answers 
the query only while the query
+        // reads the value the view stored. isGroupByEquals keeps the 
aggregate for a query without grouping
+        // over a view which groups by something, and that roll up aggregates 
the stored values, of no row at
+        // all when the filter matches none. The roll ups of the other shapes 
are the ones upstream has.
+        Pair<Plan, LogicalAggregate<Plan>> queryTopPlanAndAggPair = 
splitToTopPlanAndAggregate(queryStructInfo);
+        Pair<Plan, LogicalAggregate<Plan>> viewTopPlanAndAggPair = 
splitToTopPlanAndAggregate(viewStructInfo);
+        if (queryTopPlanAndAggPair == null || viewTopPlanAndAggPair == null) {
+            // Without an aggregate on both sides, the view has no aggregate 
output of its own to have
+            // enforced a predicate over, so there is no recomputed value to 
protect.
+            return false;
+        }
+        return queryTopPlanAndAggPair.value().getGroupByExpressions().isEmpty()
+                && 
!viewTopPlanAndAggPair.value().getGroupByExpressions().isEmpty();
+    }
+
     private boolean isGroupByEquals(Pair<Plan, LogicalAggregate<Plan>> 
queryTopPlanAndAggPair,
             Pair<Plan, LogicalAggregate<Plan>> viewTopPlanAndAggPair,
             SlotMapping viewToQuerySlotMapping,
@@ -588,6 +615,16 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
         LogicalAggregate<Plan> queryAggregate = queryTopPlanAndAggPair.value();
         LogicalAggregate<Plan> viewAggregate = viewTopPlanAndAggPair.value();
 
+        // If the query is a scalar aggregate but the view groups by extra 
dimensions, the dimensions
+        // removed by the uniform function dependency below only guarantee at 
most one group, not that a
+        // group exists. Eliminating the aggregate into a bare project would 
lose the single row a scalar
+        // aggregate must always emit when the filter matches zero rows. Keep 
the aggregate by rejecting
+        // this fast path and falling back to the roll up rewrite.
+        if (queryAggregate.getGroupByExpressions().isEmpty()
+                && !viewAggregate.getGroupByExpressions().isEmpty()) {
+            return false;
+        }
+
         Set<Expression> queryGroupByShuttledExpression = new 
HashSet<>(ExpressionUtils.shuttleExpressionWithLineage(
                 queryAggregate.getGroupByExpressions(), queryTopPlan));
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
index 1302176bd9a..2b4abd0aca7 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
@@ -853,13 +853,23 @@ public abstract class AbstractMaterializedViewRule 
implements ExplorationRuleFac
         }
         PredicateCompensation finalPredicateCompensation =
                 Predicates.compensatePredicates(queryStructInfo, 
viewStructInfo,
-                        viewToQuerySlotMapping, comparisonResult, 
cascadesContext);
+                        viewToQuerySlotMapping, comparisonResult, 
cascadesContext,
+                        aggregateOutputIsRecomputed(queryStructInfo, 
viewStructInfo));
         if (finalPredicateCompensation == null) {
             return SplitPredicate.INVALID_INSTANCE;
         }
         return finalPredicateCompensation.toSplitPredicate();
     }
 
+    /**
+     * Whether the rewrite answers the query by aggregating the view rows 
again, instead of reading the
+     * aggregate values that the view stored. A predicate the view enforced 
over its own aggregate output
+     * says nothing about such a recomputed value, so compensation can not 
treat it as redundant.
+     */
+    protected boolean aggregateOutputIsRecomputed(StructInfo queryStructInfo, 
StructInfo viewStructInfo) {
+        return false;
+    }
+
     /**
      * Check whether query-side null-reject evidence covers each required 
view-side slot set.
      *
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
index 10088fd1b7a..7b1412a168b 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
@@ -283,13 +283,33 @@ public class Predicates {
         return new PredicateCompensation(equalCandidates, rangeCandidates, 
residualCandidates);
     }
 
-    /** Compensate predicates in one step. */
+    /**
+     * Compensate predicates in one step, for a rewrite which reads the 
aggregate values of the view as the
+     * view stored them.
+     */
     public static PredicateCompensation compensatePredicates(
             StructInfo queryStructInfo,
             StructInfo viewStructInfo,
             SlotMapping viewToQuerySlotMapping,
             ComparisonResult comparisonResult,
             CascadesContext cascadesContext) {
+        return compensatePredicates(queryStructInfo, viewStructInfo, 
viewToQuerySlotMapping, comparisonResult,
+                cascadesContext, false);
+    }
+
+    /**
+     * Compensate predicates in one step.
+     *
+     * @param aggregateOutputRecomputed whether the rewrite answers the query 
by aggregating the view rows
+     *         again, rather than reading the aggregate values that the view 
already stored
+     */
+    public static PredicateCompensation compensatePredicates(
+            StructInfo queryStructInfo,
+            StructInfo viewStructInfo,
+            SlotMapping viewToQuerySlotMapping,
+            ComparisonResult comparisonResult,
+            CascadesContext cascadesContext,
+            boolean aggregateOutputRecomputed) {
         PredicateCompensation compensationCandidates = 
collectCompensationCandidates(
                 queryStructInfo, viewStructInfo, viewToQuerySlotMapping, 
comparisonResult, cascadesContext);
         if (compensationCandidates == null) {
@@ -297,13 +317,12 @@ public class Predicates {
         }
         Set<Expression> queryBasedViewResidualPredicates = 
collectNonInferredQueryBasedExpressions(
                 
viewStructInfo.getSplitPredicate().getResidualPredicateMap().keySet(), 
viewToQuerySlotMapping);
-        Set<Expression> exactCoveredPredicates = new LinkedHashSet<>();
-        exactCoveredPredicates.addAll(Sets.intersection(
-                compensationCandidates.getEquals().keySet(), 
queryBasedViewResidualPredicates));
-        exactCoveredPredicates.addAll(Sets.intersection(
-                compensationCandidates.getRanges().keySet(), 
queryBasedViewResidualPredicates));
-        exactCoveredPredicates.addAll(Sets.intersection(
-                compensationCandidates.getResiduals().keySet(), 
queryBasedViewResidualPredicates));
+        // A candidate the view already answers is pruned here; one over a 
value the query computes again is
+        // kept, so that the check at the end refuses the rewrite instead of 
dropping the filter.
+        Set<Expression> prunableCandidates = collectPrunableCandidates(
+                compensationCandidates, aggregateOutputRecomputed);
+        Set<Expression> exactCoveredPredicates = new LinkedHashSet<>(
+                Sets.intersection(prunableCandidates, 
queryBasedViewResidualPredicates));
         Set<Expression> remainingQueryBasedViewResidualPredicates = new 
LinkedHashSet<>(
                 Sets.difference(queryBasedViewResidualPredicates, 
exactCoveredPredicates));
 
@@ -333,12 +352,12 @@ public class Predicates {
             }
 
             PredicateCompensation finalCompensation = new 
PredicateCompensation(
-                    removePredicatesImpliedByViewResidual(
-                            exactPrunedCompensationCandidates.getEquals(), 
combinedQueryBasedViewResidual),
-                    removePredicatesImpliedByViewResidual(
-                            exactPrunedCompensationCandidates.getRanges(), 
combinedQueryBasedViewResidual),
-                    removePredicatesImpliedByViewResidual(
-                            exactPrunedCompensationCandidates.getResiduals(), 
combinedQueryBasedViewResidual));
+                    
removePredicatesImpliedByViewResidual(exactPrunedCompensationCandidates.getEquals(),
+                            combinedQueryBasedViewResidual, 
aggregateOutputRecomputed),
+                    
removePredicatesImpliedByViewResidual(exactPrunedCompensationCandidates.getRanges(),
+                            combinedQueryBasedViewResidual, 
aggregateOutputRecomputed),
+                    
removePredicatesImpliedByViewResidual(exactPrunedCompensationCandidates.getResiduals(),
+                            combinedQueryBasedViewResidual, 
aggregateOutputRecomputed));
             return rejectUnsafeResidualCompensation(finalCompensation);
         } catch (DnfBranchOverflowException e) {
             // DNF branch expansion may explode exponentially; fail 
compensation conservatively.
@@ -356,10 +375,27 @@ public class Predicates {
         return ImmutableMap.copyOf(residualCandidates);
     }
 
+    private static Set<Expression> collectPrunableCandidates(
+            PredicateCompensation candidates, boolean 
aggregateOutputRecomputed) {
+        Set<Expression> prunableCandidates = new LinkedHashSet<>();
+        for (Map<Expression, ExpressionInfo> predicateMap : ImmutableList.of(
+                candidates.getEquals(), candidates.getRanges(), 
candidates.getResiduals())) {
+            for (Expression candidate : predicateMap.keySet()) {
+                if (isPrunable(candidate, aggregateOutputRecomputed)) {
+                    prunableCandidates.add(candidate);
+                }
+            }
+        }
+        return prunableCandidates;
+    }
+
+    private static boolean constrainsAggregateOrWindowOutput(Expression 
expression) {
+        return expression.containsType(AggregateFunction.class, 
WindowExpression.class);
+    }
+
     private static PredicateCompensation 
rejectUnsafeResidualCompensation(PredicateCompensation compensation) {
         for (Expression expression : compensation.getResiduals().keySet()) {
-            if (expression.anyMatch(WindowExpression.class::isInstance)
-                    || 
expression.anyMatch(AggregateFunction.class::isInstance)) {
+            if (constrainsAggregateOrWindowOutput(expression)) {
                 // Aggregate and window residuals are not safe as detail-MV 
compensation predicates.
                 return null;
             }
@@ -399,18 +435,27 @@ public class Predicates {
 
     private static Map<Expression, ExpressionInfo> 
removePredicatesImpliedByViewResidual(
             Map<Expression, ExpressionInfo> predicates,
-            Expression viewResidual) {
+            Expression viewResidual,
+            boolean aggregateOutputRecomputed) {
         // Remove candidates already implied by the view residual predicate.
         Map<Expression, ExpressionInfo> remainingPredicates = new 
LinkedHashMap<>();
         for (Map.Entry<Expression, ExpressionInfo> entry : 
predicates.entrySet()) {
-            // If viewResidual => candidate, candidate is redundant and can be 
dropped.
-            if (!impliesByDnf(viewResidual, entry.getKey())) {
+            if (!isPrunable(entry.getKey(), aggregateOutputRecomputed)
+                    || !impliesByDnf(viewResidual, entry.getKey())) {
                 remainingPredicates.put(entry.getKey(), entry.getValue());
             }
         }
         return ImmutableMap.copyOf(remainingPredicates);
     }
 
+    /**
+     * Whether the view already answers the candidate. A candidate over an 
aggregate or window output does
+     * not follow from the view while the query computes that value again from 
the view rows.
+     */
+    private static boolean isPrunable(Expression candidate, boolean 
aggregateOutputRecomputed) {
+        return !(aggregateOutputRecomputed && 
constrainsAggregateOrWindowOutput(candidate));
+    }
+
     private static Map<Expression, ExpressionInfo> 
removeExactCoveredPredicates(
             Map<Expression, ExpressionInfo> predicates,
             Set<Expression> exactCoveredPredicates) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/PredicatesTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/PredicatesTest.java
index 4c70811dcb5..c3dad1506dc 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/PredicatesTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/PredicatesTest.java
@@ -29,6 +29,7 @@ import 
org.apache.doris.nereids.rules.exploration.mv.mapping.SlotMapping;
 import org.apache.doris.nereids.sqltest.SqlTestBase;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.WindowExpression;
+import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.util.PlanChecker;
 
@@ -365,6 +366,44 @@ public class PredicatesTest extends SqlTestBase {
         
Assertions.assertTrue(finalPredicateCompensation.getResiduals().isEmpty());
     }
 
+    @Test
+    public void 
testCoveredAggregateResidualIsRejectedWhenAggregateOutputIsRecomputed() {
+        // The view groups by id and has count(*) > 10; the scalar query 
aggregates the view rows again.
+        PredicateRewriteContext rewriteContext = buildRewriteContext(
+                "select id, count(*) as c from T1 group by id having count(*) 
> 10",
+                "select count(*) from T1 where id = 5 having count(*) > 10");
+
+        PredicateCompensation compensationCandidates = 
Predicates.collectCompensationCandidates(
+                rewriteContext.queryStructInfo,
+                rewriteContext.viewStructInfo,
+                rewriteContext.viewToQuerySlotMapping,
+                rewriteContext.comparisonResult,
+                rewriteContext.queryContext);
+        Assertions.assertNotNull(compensationCandidates);
+        
Assertions.assertTrue(compensationCandidates.getResiduals().keySet().stream()
+                .anyMatch(expression -> 
expression.containsType(AggregateFunction.class)));
+
+        // The view enforced count(*) > 10 over the values it stored, while 
the roll up produces a new
+        // value, zero when the filter matches no view row. The covered 
residual can not be pruned, so the
+        // unsafe residual check refuses the rewrite.
+        Assertions.assertNull(compensatePredicates(rewriteContext, true));
+
+        // A rewrite which reads the stored values may prune the covered 
residual, the filter stays above a
+        // single aggregate output in both the query and the view.
+        PredicateCompensation valuePreservingCompensation = 
compensatePredicates(rewriteContext, false);
+        Assertions.assertNotNull(valuePreservingCompensation);
+        
Assertions.assertTrue(valuePreservingCompensation.getResiduals().isEmpty());
+    }
+
+    @Test
+    public void 
testCoveredWindowResidualIsRejectedWhenWindowOutputIsRecomputed() {
+        String sql = "select id, score from T1 qualify sum(score) over 
(partition by id) > 10";
+        PredicateRewriteContext rewriteContext = buildRewriteContext(sql, sql);
+
+        // The same condition over a window output which the query computes 
again is not covered by the view.
+        Assertions.assertNull(compensatePredicates(rewriteContext, true));
+    }
+
     @Test
     public void testQueryOnlyWindowResidualIsRejectedAsCompensation() {
         PredicateRewriteContext rewriteContext = buildRewriteContext(
@@ -507,12 +546,18 @@ public class PredicatesTest extends SqlTestBase {
     }
 
     private PredicateCompensation compensatePredicates(PredicateRewriteContext 
rewriteContext) {
+        return compensatePredicates(rewriteContext, false);
+    }
+
+    private PredicateCompensation compensatePredicates(PredicateRewriteContext 
rewriteContext,
+            boolean aggregateOutputRecomputed) {
         return Predicates.compensatePredicates(
                 rewriteContext.queryStructInfo,
                 rewriteContext.viewStructInfo,
                 rewriteContext.viewToQuerySlotMapping,
                 rewriteContext.comparisonResult,
-                rewriteContext.queryContext);
+                rewriteContext.queryContext,
+                aggregateOutputRecomputed);
     }
 
     private void assertResidualCompensationSucceeds(String viewPredicate, 
String queryPredicate,
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/MvExplorationSuiteTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/MvExplorationSuiteTest.java
index b59982b082b..ade4a3aeeff 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/MvExplorationSuiteTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/MvExplorationSuiteTest.java
@@ -973,6 +973,61 @@ public class MvExplorationSuiteTest extends SqlTestBase {
         Assertions.assertTrue(compensatePredicates.isInvalid());
     }
 
+    // 
-------------------------------------------------------------------------
+    // scalar aggregate over a grouped view
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void testAggregateOutputRecomputedForScalarQueryOverGroupedView() {
+        connectContext.getSessionVariable().setDisableNereidsRules(
+                "OLAP_SCAN_PARTITION_PRUNE"
+                        + ",PRUNE_EMPTY_PARTITION"
+                        + ",ELIMINATE_GROUP_BY_KEY_BY_UNIFORM"
+                        + ",ELIMINATE_CONST_JOIN_CONDITION"
+                        + ",CONSTANT_PROPAGATION"
+        );
+        TestAggregateMaterializedViewRule rule = new 
TestAggregateMaterializedViewRule();
+        StructInfo groupedView = buildStructInfo("select id, count(*) as c 
from T1 group by id");
+
+        // The query has no grouping, so the roll up aggregates the values the 
view stored again.
+        Assertions.assertTrue(rule.aggregateOutputIsRecomputedForTest(
+                buildStructInfo("select count(*) from T1"), groupedView));
+
+        // The same grouping reads those values as they are stored.
+        Assertions.assertFalse(rule.aggregateOutputIsRecomputedForTest(
+                buildStructInfo("select id, count(*) as c from T1 group by 
id"), groupedView));
+
+        // A query without an aggregate has no aggregate output to compute 
again.
+        Assertions.assertFalse(rule.aggregateOutputIsRecomputedForTest(
+                buildStructInfo("select id from T1"), groupedView));
+
+        // A scalar view stores the single value which the scalar query reads.
+        Assertions.assertFalse(rule.aggregateOutputIsRecomputedForTest(
+                buildStructInfo("select count(*) from T1"),
+                buildStructInfo("select count(*) as c from T1")));
+    }
+
+    private StructInfo buildStructInfo(String sql) {
+        CascadesContext cascadesContext = createCascadesContext(sql, 
connectContext);
+        Plan plan = PlanChecker.from(cascadesContext)
+                .analyze()
+                .rewrite()
+                .getPlan().child(0);
+        return StructInfo.of(plan, plan, cascadesContext);
+    }
+
+    private static class TestAggregateMaterializedViewRule extends 
AbstractMaterializedViewAggregateRule {
+        @Override
+        public List<Rule> buildRules() {
+            return ImmutableList.of();
+        }
+
+        private boolean aggregateOutputIsRecomputedForTest(StructInfo 
queryStructInfo,
+                StructInfo viewStructInfo) {
+            return aggregateOutputIsRecomputed(queryStructInfo, 
viewStructInfo);
+        }
+    }
+
     private static boolean isNotNullOnSlot(Expression expression, String 
slotName) {
         if (!(expression instanceof Not) || ((Not) 
expression).isGeneratedIsNotNull()
                 || !(((Not) expression).child() instanceof IsNull)) {
diff --git 
a/regression-test/data/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.out
 
b/regression-test/data/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.out
new file mode 100644
index 00000000000..390a29f6030
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.out
@@ -0,0 +1,138 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !count_off_empty --
+0
+
+-- !count_off_matched --
+20
+
+-- !count_off_all --
+2000
+
+-- !count_off_projected --
+1
+
+-- !count_off_distinct --
+0
+
+-- !sum_off_empty --
+\N
+
+-- !sum_off_matched --
+19020
+
+-- !sum_off_all --
+1999000
+
+-- !min_off_empty --
+\N
+
+-- !min_off_matched --
+1
+
+-- !max_off_empty --
+\N
+
+-- !max_off_matched --
+1901
+
+-- !count_on_empty --
+0
+
+-- !count_on_matched --
+20
+
+-- !count_on_all --
+2000
+
+-- !count_on_projected --
+1
+
+-- !count_on_distinct --
+0
+
+-- !sum_on_empty --
+\N
+
+-- !sum_on_matched --
+19020
+
+-- !sum_on_all --
+1999000
+
+-- !min_on_empty --
+\N
+
+-- !min_on_matched --
+1
+
+-- !max_on_empty --
+\N
+
+-- !max_on_matched --
+1901
+
+-- !shape_count_on --
+PhysicalResultSink
+--PhysicalProject
+----hashAgg[GLOBAL]
+------PhysicalProject
+--------filter((scalar_agg_empty_mv.k = 9999))
+----------PhysicalOlapScan[scalar_agg_empty_mv]
+
+-- !shape_min_on --
+PhysicalResultSink
+--PhysicalProject
+----hashAgg[GLOBAL]
+------PhysicalProject
+--------filter((scalar_agg_empty_mv.k = 9999))
+----------PhysicalOlapScan[scalar_agg_empty_mv]
+
+-- !having_off_passes --
+20
+
+-- !having_off_rejects --
+
+-- !having_off_empty --
+
+-- !having_on_passes --
+20
+
+-- !having_on_rejects --
+
+-- !having_on_empty --
+
+-- !shape_having --
+PhysicalResultSink
+--PhysicalProject
+----filter((sum0(cnt) > 100000))
+------hashAgg[GLOBAL]
+--------PhysicalProject
+----------filter((scalar_agg_empty_mv.k = 1))
+------------PhysicalOlapScan[scalar_agg_empty_mv]
+
+-- !having_mv_off_matched --
+20
+
+-- !having_mv_off_empty --
+
+-- !having_mv_on_matched --
+20
+
+-- !having_mv_on_empty --
+
+-- !uniform_off --
+1      1
+
+-- !uniform_off_empty --
+
+-- !uniform_on --
+1      1
+
+-- !uniform_on_empty --
+
+-- !shape_uniform --
+PhysicalResultSink
+--PhysicalProject
+----filter((scalar_agg_uniform_mv.v = 1001))
+------PhysicalOlapScan[scalar_agg_uniform_mv]
+
diff --git 
a/regression-test/suites/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.groovy
 
b/regression-test/suites/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.groovy
new file mode 100644
index 00000000000..10b07d38d13
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/mv/agg_scalar_empty_input/agg_scalar_empty_input.groovy
@@ -0,0 +1,171 @@
+// 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.
+
+suite("agg_scalar_empty_input") {
+    String db = context.config.getDbNameByFile(context.file)
+    sql "use ${db}"
+    sql "set pre_materialized_view_rewrite_strategy = TRY_IN_RBO"
+    sql "set runtime_filter_mode=OFF"
+    sql "SET ignore_shape_nodes='PhysicalDistribute'"
+    // The same query text runs once with the rewrite off and once with it on, 
so the cached result of the
+    // first run would be replayed for the second one instead of the rewritten 
plan being executed.
+    sql "SET enable_sql_cache=false"
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS scalar_agg_empty_mv"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS scalar_agg_uniform_mv"""
+    sql """DROP TABLE IF EXISTS scalar_agg_empty_base"""
+
+    sql """
+    CREATE TABLE scalar_agg_empty_base (k INT NOT NULL, v INT NOT NULL)
+    DUPLICATE KEY(k)
+    DISTRIBUTED BY HASH(k) BUCKETS 1
+    PROPERTIES ("replication_num" = "1")
+    """
+
+    // 2000 rows over 100 distinct k values, so the pre-aggregated mv is much 
cheaper to read than
+    // the base table and the rewritten plan is the one the optimizer picks. k 
= 9999 matches nothing,
+    // and k = 1 holds v = 1, 101, ... 1901.
+    sql """INSERT INTO scalar_agg_empty_base SELECT number % 100, number FROM 
numbers("number" = "2000")"""
+    sql """ANALYZE TABLE scalar_agg_empty_base WITH SYNC"""
+
+    def mvSql = """select k, count(*) as cnt, sum(v) as s, min(v) as mn, 
max(v) as mx """ +
+            """from scalar_agg_empty_base group by k"""
+
+    def qCountEmpty = """select count(*) from scalar_agg_empty_base where k = 
9999"""
+    def qCountMatched = """select count(*) from scalar_agg_empty_base where k 
= 1"""
+    def qCountAll = """select count(*) from scalar_agg_empty_base"""
+    def qCountProjected = """select count(*) + 1 from scalar_agg_empty_base 
where k = 9999"""
+    def qCountDistinct = """select count(distinct v) from 
scalar_agg_empty_base where k = 9999"""
+    def qSumEmpty = """select sum(v) from scalar_agg_empty_base where k = 
9999"""
+    def qSumMatched = """select sum(v) from scalar_agg_empty_base where k = 
1"""
+    def qSumAll = """select sum(v) from scalar_agg_empty_base"""
+    def qMinEmpty = """select min(v) from scalar_agg_empty_base where k = 
9999"""
+    def qMinMatched = """select min(v) from scalar_agg_empty_base where k = 
1"""
+    def qMaxEmpty = """select max(v) from scalar_agg_empty_base where k = 
9999"""
+    def qMaxMatched = """select max(v) from scalar_agg_empty_base where k = 
1"""
+
+    // Reference results, computed on the base table: a scalar aggregate 
always emits exactly one
+    // row, COUNT of an empty input is 0 while SUM/MIN/MAX of an empty input 
are NULL.
+    sql "set enable_materialized_view_rewrite=false"
+    order_qt_count_off_empty "${qCountEmpty}"
+    order_qt_count_off_matched "${qCountMatched}"
+    order_qt_count_off_all "${qCountAll}"
+    order_qt_count_off_projected "${qCountProjected}"
+    order_qt_count_off_distinct "${qCountDistinct}"
+    order_qt_sum_off_empty "${qSumEmpty}"
+    order_qt_sum_off_matched "${qSumMatched}"
+    order_qt_sum_off_all "${qSumAll}"
+    order_qt_min_off_empty "${qMinEmpty}"
+    order_qt_min_off_matched "${qMinMatched}"
+    order_qt_max_off_empty "${qMaxEmpty}"
+    order_qt_max_off_matched "${qMaxMatched}"
+
+    // The mv groups by k while these queries are scalar, so the mv dimensions 
may only be dropped
+    // if the aggregate node is kept: rolling COUNT up must yield 0 and 
SUM/MIN/MAX must yield NULL.
+    sql "set enable_materialized_view_rewrite=true"
+    async_mv_rewrite_success(db, mvSql, qCountEmpty, "scalar_agg_empty_mv")
+    sql """ANALYZE TABLE scalar_agg_empty_mv WITH SYNC"""
+    mv_rewrite_success(qCountMatched, "scalar_agg_empty_mv")
+    mv_rewrite_success(qCountProjected, "scalar_agg_empty_mv")
+    mv_rewrite_success(qSumEmpty, "scalar_agg_empty_mv")
+    mv_rewrite_success(qSumMatched, "scalar_agg_empty_mv")
+    mv_rewrite_success(qSumAll, "scalar_agg_empty_mv")
+    mv_rewrite_success(qMinEmpty, "scalar_agg_empty_mv")
+    mv_rewrite_success(qMinMatched, "scalar_agg_empty_mv")
+    mv_rewrite_success(qMaxEmpty, "scalar_agg_empty_mv")
+    mv_rewrite_success(qMaxMatched, "scalar_agg_empty_mv")
+    // count(distinct v) is stored in the mv as an already computed value and 
can not be rolled up,
+    // so the mv must not answer this query at all.
+    mv_rewrite_fail(qCountDistinct, "scalar_agg_empty_mv")
+    // count(*) without a predicate is answered straight from the FE table 
metadata by
+    // REWRITE_SIMPLE_AGG_TO_CONSTANT, so the mv is not expected to be used 
for it.
+
+    // The scalar aggregate must survive the rewrite: an mv scan alone would 
return no row at all.
+    order_qt_count_on_empty "${qCountEmpty}"
+    order_qt_count_on_matched "${qCountMatched}"
+    order_qt_count_on_all "${qCountAll}"
+    order_qt_count_on_projected "${qCountProjected}"
+    order_qt_count_on_distinct "${qCountDistinct}"
+    order_qt_sum_on_empty "${qSumEmpty}"
+    order_qt_sum_on_matched "${qSumMatched}"
+    order_qt_sum_on_all "${qSumAll}"
+    order_qt_min_on_empty "${qMinEmpty}"
+    order_qt_min_on_matched "${qMinMatched}"
+    order_qt_max_on_empty "${qMaxEmpty}"
+    order_qt_max_on_matched "${qMaxMatched}"
+    // Whether the scalar aggregate is split into a local and a global 
aggregate is a cost based
+    // choice which depends on the number of backends, so pin the one phase 
aggregation here to keep
+    // the shape assertion about the aggregate itself independent of the 
cluster it runs on.
+    sql "set agg_phase=1"
+    qt_shape_count_on """explain shape plan ${qCountEmpty}"""
+    qt_shape_min_on """explain shape plan ${qMinEmpty}"""
+
+    // A having clause filters the rows the top aggregate produces. Keeping 
the aggregate is what makes the
+    // roll up possible, and the filter then stays above it and is evaluated 
on the rolled up value, so the
+    // mv is still used and a having which the single rolled up row does not 
pass yields no row at all.
+    def qHavingPasses = """select count(*) from scalar_agg_empty_base where k 
= 1 having count(*) > 0"""
+    def qHavingRejects = """select count(*) from scalar_agg_empty_base where k 
= 1 having count(*) > 100000"""
+    def qHavingEmptyInput = """select count(*) from scalar_agg_empty_base 
where k = 9999 having count(*) > 0"""
+    sql "set enable_materialized_view_rewrite=false"
+    order_qt_having_off_passes "${qHavingPasses}"
+    order_qt_having_off_rejects "${qHavingRejects}"
+    order_qt_having_off_empty "${qHavingEmptyInput}"
+    sql "set enable_materialized_view_rewrite=true"
+    mv_rewrite_success(qHavingPasses, "scalar_agg_empty_mv")
+    mv_rewrite_success(qHavingRejects, "scalar_agg_empty_mv")
+    mv_rewrite_success(qHavingEmptyInput, "scalar_agg_empty_mv")
+    order_qt_having_on_passes "${qHavingPasses}"
+    order_qt_having_on_rejects "${qHavingRejects}"
+    order_qt_having_on_empty "${qHavingEmptyInput}"
+    qt_shape_having """explain shape plan ${qHavingRejects}"""
+
+    // An mv with a having clause of its own stores only the groups which 
passed that clause, so the clause
+    // is a condition on the value the mv aggregated. A scalar query which 
repeats the very same condition
+    // does not ask for the same value: its aggregate is computed over the mv 
rows its own filter selects,
+    // and over nothing at all an aggregate rolls up to 0 while the query 
computed on the base table yields
+    // no row. The mv can not answer such a query, so it must not be used for 
it.
+    def havingMvSql = """select k, count(*) as cnt from scalar_agg_empty_base 
group by k having count(*) > 0"""
+    def qHavingMvMatched = """select count(*) from scalar_agg_empty_base where 
k = 1 having count(*) > 0"""
+    def qHavingMvEmpty = """select count(*) from scalar_agg_empty_base where k 
= 9999 having count(*) > 0"""
+    async_mv_rewrite_fail(db, havingMvSql, qHavingMvEmpty, 
"scalar_agg_having_mv")
+    sql """ANALYZE TABLE scalar_agg_having_mv WITH SYNC"""
+    mv_rewrite_fail(qHavingMvMatched, "scalar_agg_having_mv")
+    sql "set enable_materialized_view_rewrite=false"
+    order_qt_having_mv_off_matched "${qHavingMvMatched}"
+    order_qt_having_mv_off_empty "${qHavingMvEmpty}"
+    sql "set enable_materialized_view_rewrite=true"
+    order_qt_having_mv_on_matched "${qHavingMvMatched}"
+    order_qt_having_mv_on_empty "${qHavingMvEmpty}"
+
+    // A query which keeps one of the mv dimensions may still drop the 
aggregate node: the equality
+    // filter makes the remaining mv dimension uniform, so the mv holds 
exactly one row per group.
+    def uniformMvSql = """select k, v, count(*) as cnt from 
scalar_agg_empty_base group by k, v"""
+    def qUniform = """select k, count(*) from scalar_agg_empty_base where v = 
1001 group by k"""
+    def qUniformEmpty = """select k, count(*) from scalar_agg_empty_base where 
v = 999999 group by k"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS scalar_agg_empty_mv"""
+    async_mv_rewrite_success(db, uniformMvSql, qUniform, 
"scalar_agg_uniform_mv")
+    sql """ANALYZE TABLE scalar_agg_uniform_mv WITH SYNC"""
+    mv_rewrite_success(qUniformEmpty, "scalar_agg_uniform_mv")
+
+    sql "set enable_materialized_view_rewrite=false"
+    order_qt_uniform_off "${qUniform}"
+    order_qt_uniform_off_empty "${qUniformEmpty}"
+    sql "set enable_materialized_view_rewrite=true"
+    order_qt_uniform_on "${qUniform}"
+    order_qt_uniform_on_empty "${qUniformEmpty}"
+    qt_shape_uniform """explain shape plan ${qUniform}"""
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to