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

morrySnow 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 df088085ca1 [fix](mv) Fix NPE when materialized view aggregate rewrite 
treats a derived group by projection as a group by key (#67362)
df088085ca1 is described below

commit df088085ca1956b63d739f0cf9600f90229852d6
Author: starocean999 <[email protected]>
AuthorDate: Wed Sep 16 19:02:08 2026 +0800

    [fix](mv) Fix NPE when materialized view aggregate rewrite treats a derived 
group by projection as a group by key (#67362)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    Given a sync materialized view on `sync_tz_base`:
    
    ```sql
    CREATE MATERIALIZED VIEW sync_tz_day AS
    SELECT date_trunc(ts, 'day') AS day_ts, sum(v) AS day_sum
    FROM sync_tz_base WHERE ts IS NOT NULL
    GROUP BY date_trunc(ts, 'day');
    ```
    
    the following query fails during planning with an NPE:
    
    ```sql
    SELECT CAST(date_trunc(ts, 'day') AS STRING) AS day_ts, SUM(v)
    FROM sync_tz_base WHERE ts IS NOT NULL
    GROUP BY date_trunc(ts, 'day');
    ```
    
    ```
    java.lang.NullPointerException: Cannot invoke 
"org.apache.doris.analysis.Expr.getChildren()" because "root" is null
            at org.apache.doris.analysis.Expr.extractSlots(Expr.java:173)
            at 
org.apache.doris.nereids.glue.translator.PhysicalPlanTranslator.visitPhysicalProject(PhysicalPlanTranslator.java:2173)
    ```
    
    **Root cause**
    
    In `AbstractMaterializedViewAggregateRule.aggregateRewriteByView`, the
    group by keys of the rewritten aggregate were collected from the query
    **top plan** output expressions. For a `project -> aggregate` structure,
    `topPlanSplitToGroupAndFunction` classifies the derived projection
    `cast(date_trunc(ts, 'day') AS STRING)` as a group-by expression,
    because it is derived from the real group key. This derived projection
    was then wrongly added as a group by key of the rewritten aggregate:
    
    ```
    finalGroupExpressions = [cast(day_ts#7 as TEXT) AS #9, day_ts#7]
    finalOutputExpressions = [cast(day_ts#7 as TEXT) AS #9, sum(day_sum#8) AS 
#10]
    ```
    
    This led to two problems:
    
    1. A redundant group by key `cast(day_ts#7 as TEXT)` which is only a
    projection of the real group key `day_ts#7`.
    2. The real group key `day_ts#7` was added by the group-by compensation
    logic but was **not** present in the aggregate output expressions, so
    the top project referenced a slot that the physical aggregate never
    produced, causing the `"root" is null` NPE during physical plan
    translation.
    
    **Fix**
    
    The rewritten aggregate is now built directly from the query bottom
    aggregate:
    
    - The group by keys are the query bottom aggregate's group by
    expressions rewritten against the MV scan (always correct, so the
    previous group-by compensation is no longer needed).
    - The aggregate output contains the rewritten group keys and the
    rolled-up aggregate functions.
    - The query top plan output expressions — including derived projections
    of group keys such as `cast(date_trunc(ts, 'day') AS STRING)` — are
    recomputed by a `LogicalProject` above the rewritten aggregate when they
    cannot be produced by the aggregate directly.
    
    After the fix the rewritten plan is valid and the query returns correct
    results:
    
    ```
    Project [cast(day_ts#7 as TEXT) AS day_ts#3, sum(day_sum#8) AS SUM(v)#4]
      Aggregate [group by [day_ts#7], output [day_ts#7, sum(day_sum#8)]]
        MVScan(sync_tz_day)
    ```
---
 .../mv/AbstractMaterializedViewAggregateRule.java  | 269 ++++++++++++++++-----
 .../aggregate_without_roll_up_projection.out       |  40 +++
 .../aggregate_without_roll_up_projection.groovy    | 161 ++++++++++++
 3 files changed, 416 insertions(+), 54 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 7ee28753abc..046793de494 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
@@ -161,7 +161,7 @@ public abstract class AbstractMaterializedViewAggregateRule 
extends AbstractMate
     /**
      * Aggregate function and group by expression rewrite impl
      */
-    protected LogicalAggregate<Plan> aggregateRewriteByView(
+    protected Plan aggregateRewriteByView(
             StructInfo queryStructInfo,
             SlotMapping viewToQuerySlotMapping,
             Pair<Plan, LogicalAggregate<Plan>> queryTopPlanAndAggPair,
@@ -170,11 +170,6 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
             ExpressionRewriteMode groupByMode,
             ExpressionRewriteMode aggregateFunctionMode) {
         // try to roll up.
-        // split the query top plan expressions to group expressions and 
functions, if can not, bail out.
-        Pair<Set<? extends Expression>, Set<? extends Expression>> 
queryGroupAndFunctionPair
-                = topPlanSplitToGroupAndFunction(queryTopPlanAndAggPair, 
queryStructInfo);
-        Set<? extends Expression> queryTopPlanGroupBySet = 
queryGroupAndFunctionPair.key();
-        Set<? extends Expression> queryTopPlanFunctionSet = 
queryGroupAndFunctionPair.value();
         // try to rewrite, contains both roll up aggregate functions and 
aggregate group expression
         List<NamedExpression> finalOutputExpressions = new ArrayList<>();
         List<Expression> finalGroupExpressions = new ArrayList<>();
@@ -183,57 +178,79 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
                 
materializationContext.getShuttledExprToScanExprMapping().keyPermute(viewToQuerySlotMapping)
                         .flattenMap().get(0);
         Plan queryTopPlan = queryStructInfo.getTopPlan();
-        for (Expression topExpression : queryTopPlan.getOutput()) {
-            if (queryTopPlanFunctionSet.contains(topExpression)) {
-                // if agg function, try to roll up and rewrite
-                Expression rollupedExpression = 
tryRewriteExpression(queryStructInfo, topExpression,
-                        mvExprToMvScanExprQueryBased, aggregateFunctionMode, 
materializationContext,
-                        "Query function roll up fail",
-                        () -> String.format("queryExpression = %s,\n 
mvExprToMvScanExprQueryBased = %s",
-                                topExpression, mvExprToMvScanExprQueryBased));
-                if (rollupedExpression == null) {
-                    return null;
-                }
-                finalOutputExpressions.add(new Alias(rollupedExpression));
-            } else {
-                // if group by dimension, try to rewrite
-                Expression rewrittenGroupByExpression = 
tryRewriteExpression(queryStructInfo, topExpression,
-                        mvExprToMvScanExprQueryBased, groupByMode, 
materializationContext,
-                        "View dimensions doesn't not cover the query 
dimensions",
-                        () -> String.format("mvExprToMvScanExprQueryBased is 
%s,\n queryExpression is %s",
-                                mvExprToMvScanExprQueryBased, topExpression));
-                if (rewrittenGroupByExpression == null) {
-                    // group expr can not rewrite by view
-                    return null;
-                }
-                NamedExpression groupByExpression = rewrittenGroupByExpression 
instanceof NamedExpression
-                        ? (NamedExpression) rewrittenGroupByExpression : new 
Alias(rewrittenGroupByExpression);
-                finalOutputExpressions.add(groupByExpression);
-                finalGroupExpressions.add(groupByExpression);
-            }
-        }
         LogicalAggregate<Plan> queryAggregate = queryTopPlanAndAggPair.value();
         List<Expression> queryGroupByExpressions = 
queryAggregate.getGroupByExpressions();
-        // handle the scene that query top plan not use the group by in query 
bottom aggregate
-        if (needCompensateGroupBy(queryTopPlanGroupBySet, 
queryGroupByExpressions)) {
-            for (Expression expression : queryGroupByExpressions) {
-                if (queryTopPlanGroupBySet.contains(expression)) {
-                    continue;
+        if (queryAggregate.getSourceRepeat().isPresent()) {
+            // The group by/function classification of the query top plan 
output expressions is only
+            // used by the repeat rewrite, so it is computed lazily inside 
this branch to avoid paying
+            // the full plan lineage walk for every ordinary aggregate rewrite.
+            // split the query top plan expressions to group expressions and 
functions, if can not, bail out.
+            Pair<Set<? extends Expression>, Set<? extends Expression>> 
queryGroupAndFunctionPair
+                    = topPlanSplitToGroupAndFunction(queryTopPlanAndAggPair, 
queryStructInfo);
+            Set<? extends Expression> queryTopPlanGroupBySet = 
queryGroupAndFunctionPair.key();
+            Set<? extends Expression> queryTopPlanFunctionSet = 
queryGroupAndFunctionPair.value();
+            // try to rewrite the query top plan expressions, the query top 
plan output expressions
+            // are used as the repeat output expressions directly. The repeat 
output keeps the original top
+            // output expr id equivalence classes, see 
rewriteOutputInOriginalExprIdEquivalenceClass, so the
+            // rewritten output set size equals the query output set size and 
the repeat rewrite is not
+            // rejected by the output set size guard of 
MaterializedViewUtils.rewriteByRules.
+            Map<ExprId, NamedExpression> originalExprIdToRewrittenOutput = new 
HashMap<>();
+            Set<ExprId> usedRewrittenOutputExprIds = new HashSet<>();
+            for (Slot topPlanOutput : queryTopPlan.getOutput()) {
+                NamedExpression rewrittenOutput = 
originalExprIdToRewrittenOutput.get(topPlanOutput.getExprId());
+                if (rewrittenOutput == null) {
+                    if (queryTopPlanFunctionSet.contains(topPlanOutput)) {
+                        // if agg function, try to roll up and rewrite
+                        Expression rollupedExpression = 
tryRewriteExpression(queryStructInfo, topPlanOutput,
+                                mvExprToMvScanExprQueryBased, 
aggregateFunctionMode, materializationContext,
+                                "Query function roll up fail",
+                                () -> String.format("queryExpression = %s,\n 
mvExprToMvScanExprQueryBased = %s",
+                                        topPlanOutput, 
mvExprToMvScanExprQueryBased));
+                        if (rollupedExpression == null) {
+                            return null;
+                        }
+                        rewrittenOutput = 
rewriteOutputInOriginalExprIdEquivalenceClass(
+                                originalExprIdToRewrittenOutput, 
usedRewrittenOutputExprIds,
+                                topPlanOutput.getExprId(), rollupedExpression);
+                    } else {
+                        // if group by dimension, try to rewrite
+                        Expression rewrittenGroupByExpression = 
tryRewriteExpression(
+                                queryStructInfo, topPlanOutput, 
mvExprToMvScanExprQueryBased, groupByMode,
+                                materializationContext,
+                                "View dimensions doesn't not cover the query 
dimensions",
+                                () -> 
String.format("mvExprToMvScanExprQueryBased is %s,\n queryExpression is %s",
+                                        mvExprToMvScanExprQueryBased, 
topPlanOutput));
+                        if (rewrittenGroupByExpression == null) {
+                            // group expr can not rewrite by view
+                            return null;
+                        }
+                        rewrittenOutput = 
rewriteOutputInOriginalExprIdEquivalenceClass(
+                                originalExprIdToRewrittenOutput, 
usedRewrittenOutputExprIds,
+                                topPlanOutput.getExprId(), 
rewrittenGroupByExpression);
+                        finalGroupExpressions.add(rewrittenOutput);
+                    }
                 }
-                Expression rewrittenGroupByExpression = 
tryRewriteExpression(queryStructInfo, expression,
-                        mvExprToMvScanExprQueryBased, groupByMode, 
materializationContext,
-                        "View dimensions doesn't not cover the query 
dimensions in bottom agg ",
-                        () -> String.format("mvExprToMvScanExprQueryBased is 
%s,\n expression is %s",
-                                mvExprToMvScanExprQueryBased, expression));
-                if (rewrittenGroupByExpression == null) {
-                    return null;
+                finalOutputExpressions.add(rewrittenOutput);
+            }
+            // handle the scene that query top plan not use the group by in 
query bottom aggregate
+            if (needCompensateGroupBy(queryTopPlanGroupBySet, 
queryGroupByExpressions)) {
+                for (Expression expression : queryGroupByExpressions) {
+                    if (queryTopPlanGroupBySet.contains(expression)) {
+                        continue;
+                    }
+                    Expression rewrittenGroupByExpression = 
tryRewriteExpression(queryStructInfo, expression,
+                            mvExprToMvScanExprQueryBased, groupByMode, 
materializationContext,
+                            "View dimensions doesn't not cover the query 
dimensions in bottom agg ",
+                            () -> String.format("mvExprToMvScanExprQueryBased 
is %s,\n expression is %s",
+                                    mvExprToMvScanExprQueryBased, expression));
+                    if (rewrittenGroupByExpression == null) {
+                        return null;
+                    }
+                    NamedExpression groupByExpression = 
rewrittenGroupByExpression instanceof NamedExpression
+                            ? (NamedExpression) rewrittenGroupByExpression : 
new Alias(rewrittenGroupByExpression);
+                    finalGroupExpressions.add(groupByExpression);
                 }
-                NamedExpression groupByExpression = rewrittenGroupByExpression 
instanceof NamedExpression
-                        ? (NamedExpression) rewrittenGroupByExpression : new 
Alias(rewrittenGroupByExpression);
-                finalGroupExpressions.add(groupByExpression);
             }
-        }
-        if (queryAggregate.getSourceRepeat().isPresent()) {
             // construct group sets for repeat
             List<List<Expression>> rewrittenGroupSetsExpressions = new 
ArrayList<>();
             List<List<Expression>> groupingSets = 
queryAggregate.collectFirst(LogicalRepeat.class::isInstance)
@@ -265,7 +282,102 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
                     queryAggregate.getSourceRepeat().get().getRepeatType(), 
tempRewritedPlan);
             return NormalizeRepeat.doNormalize(repeat);
         }
-        return new LogicalAggregate<>(finalGroupExpressions, 
finalOutputExpressions, tempRewritedPlan);
+
+        // The rewritten aggregate should group by the query bottom 
aggregate's group by expressions,
+        // and its output expressions should be the rewritten group by 
expressions and the rolled up
+        // aggregate functions. The query top plan output expressions are 
recomputed by a project above
+        // the rewritten aggregate, so the projection of a group by key in the 
query top plan (such as
+        // `select cast(date_trunc(ts, 'day') as string) from t group by 
date_trunc(ts, 'day')`) will not
+        // be wrongly treated as a group by key of the rewritten aggregate.
+        // The mapping from the query bottom aggregate output slot to the new 
aggregate output expression
+        // is used to rewrite the query top plan output expressions to 
reference the new aggregate output.
+        // 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.
+        List<? extends Expression> shuttledBottomAggOutputs = 
ExpressionUtils.shuttleExpressionWithLineage(
+                queryAggregate.getOutputExpressions(), queryTopPlan);
+        List<? extends Expression> shuttledTopPlanOutputs = 
ExpressionUtils.shuttleExpressionWithLineage(
+                queryTopPlan.getOutput(), queryTopPlan);
+        Map<Expression, Expression> bottomAggOutputToNewExprMap = new 
HashMap<>();
+        Set<Expression> queryGroupByExpressionSet = new 
HashSet<>(queryGroupByExpressions);
+        List<NamedExpression> queryAggregateOutputs = 
queryAggregate.getOutputExpressions();
+        for (int i = 0; i < queryAggregateOutputs.size(); i++) {
+            NamedExpression queryAggregateOutput = 
queryAggregateOutputs.get(i);
+            Expression shuttledQueryAggregateOutput = 
shuttledBottomAggOutputs.get(i);
+            if (queryGroupByExpressionSet.contains(queryAggregateOutput)) {
+                // if it is a group by expression, rewrite it to the new 
aggregate group by key
+                Expression rewrittenGroupByExpression = 
rewriteShuttledExpression(queryStructInfo,
+                        shuttledQueryAggregateOutput, 
mvExprToMvScanExprQueryBased, groupByMode,
+                        materializationContext,
+                        "View dimensions doesn't not cover the query 
dimensions",
+                        () -> String.format("mvExprToMvScanExprQueryBased is 
%s,\n queryExpression is %s",
+                                mvExprToMvScanExprQueryBased, 
queryAggregateOutput));
+                if (rewrittenGroupByExpression == null) {
+                    return null;
+                }
+                NamedExpression groupByOutput = rewrittenGroupByExpression 
instanceof NamedExpression
+                        ? (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,
+                        shuttledQueryAggregateOutput, 
mvExprToMvScanExprQueryBased, aggregateFunctionMode,
+                        materializationContext,
+                        "Query function roll up fail",
+                        () -> String.format("queryExpression = %s,\n 
mvExprToMvScanExprQueryBased = %s",
+                                queryAggregateOutput, 
mvExprToMvScanExprQueryBased));
+                if (rewrittenFunction == null) {
+                    return null;
+                }
+                NamedExpression functionOutput = new Alias(rewrittenFunction);
+                finalOutputExpressions.add(functionOutput);
+                bottomAggOutputToNewExprMap.put(shuttledQueryAggregateOutput, 
functionOutput.toSlot());
+            }
+        }
+
+        LogicalAggregate<Plan> rewrittenAggregate =
+                new LogicalAggregate<>(finalGroupExpressions, 
finalOutputExpressions, tempRewritedPlan);
+
+        // 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
+        // by a project above the rewritten aggregate.
+        List<NamedExpression> topProjectExpressions = new ArrayList<>();
+        // Preserve the original top output expr id equivalence classes, see
+        // rewriteOutputInOriginalExprIdEquivalenceClass for the details.
+        List<Slot> queryTopPlanOutputs = queryTopPlan.getOutput();
+        Map<ExprId, NamedExpression> originalExprIdToRewritten = new 
HashMap<>();
+        Set<ExprId> usedRewrittenExprIds = new HashSet<>();
+        for (int i = 0; i < shuttledTopPlanOutputs.size(); i++) {
+            ExprId originalExprId = queryTopPlanOutputs.get(i).getExprId();
+            NamedExpression groupRewrittenExpression = 
originalExprIdToRewritten.get(originalExprId);
+            if (groupRewrittenExpression == null) {
+                groupRewrittenExpression = 
rewriteOutputInOriginalExprIdEquivalenceClass(
+                        originalExprIdToRewritten, usedRewrittenExprIds, 
originalExprId,
+                        ExpressionUtils.replace(shuttledTopPlanOutputs.get(i), 
bottomAggOutputToNewExprMap));
+            }
+            topProjectExpressions.add(groupRewrittenExpression);
+        }
+        // If the query top plan output expressions can be produced by the 
rewritten aggregate directly,
+        // return the aggregate, otherwise compute them by a project above the 
rewritten aggregate.
+        // Note the query top plan output may be a strict matching prefix of 
the rewritten aggregate output
+        // (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();
+        for (int i = 0; i < topProjectExpressions.size(); i++) {
+            if (i >= finalOutputExpressions.size()
+                    || 
!topProjectExpressions.get(i).toSlot().equals(finalOutputExpressions.get(i).toSlot()))
 {
+                needTopProject = true;
+                break;
+            }
+        }
+        if (!needTopProject) {
+            return rewrittenAggregate;
+        }
+        return new LogicalProject<>(topProjectExpressions, rewrittenAggregate);
     }
 
     /**
@@ -304,6 +416,43 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
         return 
!queryTopPlanGroupByUseNamedExpressions.containsAll(queryGroupByUseNamedExpressions);
     }
 
+    /**
+     * Build one rewritten output of a query top plan output position, keeping 
the rewritten output
+     * consistent with the original top output expr id equivalence classes.
+     * <p>
+     * Positions which share one original expr id (such as the unaliased 
`select k, k from t group by k`,
+     * where both positions reference the same top output slot) must share one 
rewritten output expr id,
+     * otherwise the rewritten output set is inflated. Conversely, positions 
with distinct original expr ids
+     * which are rewritten to the same view output (such as `select k as k1, k 
as k2 from t group by k`,
+     * where both positions rewrite to the same view slot) must keep distinct 
rewritten output expr ids,
+     * otherwise the rewritten output set is collapsed.
+     * <p>
+     * Both directions matter because the rewritten output set size must equal 
the query output set size,
+     * which is the guard of MaterializedViewUtils.rewriteByRules. When the 
guard is hit, the whole-tree
+     * normalization and partition pruning are skipped, and the repeat rewrite 
returns an aggregate with
+     * normalized=false whose derived projections are still inside the 
unnormalized aggregate, so that the
+     * physical translation can not resolve them when the top plan output is 
projected.
+     *
+     * @param originalExprIdToRewritten rewritten output of the original expr 
id, reused when present
+     * @param usedRewrittenExprIds rewritten output expr ids which are used by 
other expr id classes already
+     * @param originalExprId original expr id of the query top plan output 
position
+     * @param rewrittenExpression expression which the query top plan output 
position is rewritten to
+     * @return rewritten output expression of the query top plan output 
position
+     */
+    private static NamedExpression 
rewriteOutputInOriginalExprIdEquivalenceClass(
+            Map<ExprId, NamedExpression> originalExprIdToRewritten, 
Set<ExprId> usedRewrittenExprIds,
+            ExprId originalExprId, Expression rewrittenExpression) {
+        NamedExpression rewrittenOutput = rewrittenExpression instanceof 
NamedExpression
+                ? (NamedExpression) rewrittenExpression : new 
Alias(rewrittenExpression);
+        if (!usedRewrittenExprIds.add(rewrittenOutput.getExprId())) {
+            // The rewritten expr id is used by another original expr id 
equivalence class already, keep a
+            // distinct output expr id for this class.
+            rewrittenOutput = new Alias(rewrittenExpression);
+        }
+        originalExprIdToRewritten.put(originalExprId, rewrittenOutput);
+        return rewrittenOutput;
+    }
+
     /**
      * Try to rewrite query expression by view, contains both group by 
dimension and aggregate function
      */
@@ -313,10 +462,22 @@ public abstract class 
AbstractMaterializedViewAggregateRule extends AbstractMate
         Expression queryFunctionShuttled = 
ExpressionUtils.shuttleExpressionWithLineage(
                 queryExpression,
                 queryStructInfo.getTopPlan());
+        return rewriteShuttledExpression(queryStructInfo, 
queryFunctionShuttled, mvShuttledExprToMvScanExprQueryBased,
+                rewriteMode, materializationContext, summaryIfFail, 
detailIfFail);
+    }
+
+    /**
+     * Rewrite the shuttled query expression by view, contains both group by 
dimension and aggregate
+     * function. The query expression is expected to be shuttled by lineage 
already, so the top plan is
+     * not traversed again here and the batched lineage result can be reused.
+     */
+    private Expression rewriteShuttledExpression(StructInfo queryStructInfo, 
Expression queryShuttledExpression,
+            Map<Expression, Expression> mvShuttledExprToMvScanExprQueryBased, 
ExpressionRewriteMode rewriteMode,
+            MaterializationContext materializationContext, String 
summaryIfFail, Supplier<String> detailIfFail) {
         AggregateExpressionRewriteContext expressionRewriteContext = new 
AggregateExpressionRewriteContext(
                 rewriteMode, mvShuttledExprToMvScanExprQueryBased, 
queryStructInfo.getTopPlan(),
                 queryStructInfo.getGroupingId());
-        Expression rewrittenExpression = 
queryFunctionShuttled.accept(AGGREGATE_EXPRESSION_REWRITER,
+        Expression rewrittenExpression = 
queryShuttledExpression.accept(AGGREGATE_EXPRESSION_REWRITER,
                 expressionRewriteContext);
         if (!expressionRewriteContext.isValid()) {
             materializationContext.recordFailReason(queryStructInfo, 
summaryIfFail, detailIfFail);
diff --git 
a/regression-test/data/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.out
 
b/regression-test/data/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.out
new file mode 100644
index 00000000000..76d7df7aef9
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.out
@@ -0,0 +1,40 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !select_mv --
+2024-01-01 00:00:00.000000+08:00       7
+2024-01-02 00:00:00.000000+08:00       3
+
+-- !select_mv_leading_subset --
+2024-01-01 00:00:00.000000+08:00
+2024-01-01 00:00:00.000000+08:00
+2024-01-02 00:00:00.000000+08:00
+
+-- !select_mv_dup_sum --
+2024-01-01 00:00:00.000000+08:00       7       7
+2024-01-02 00:00:00.000000+08:00       3       3
+
+-- !select_mv_dup_group --
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
7
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
3
+
+-- !select_mv_dup_bare_group --
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
7
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
3
+
+-- !select_mv_grouping_sets --
+\N     \N      \N      7
+\N     \N      4       7
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
\N      7
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
1       3
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
2       4
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
\N      3
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
3       3
+
+-- !select_mv_grouping_sets_bare_group --
+\N     \N      7
+\N     \N      7
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
3
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
4
+2024-01-01 00:00:00.000000+08:00       2024-01-01 00:00:00.000000+08:00        
7
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
3
+2024-01-02 00:00:00.000000+08:00       2024-01-02 00:00:00.000000+08:00        
3
+
diff --git 
a/regression-test/suites/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.groovy
 
b/regression-test/suites/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.groovy
new file mode 100644
index 00000000000..bf8d55e2e2f
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/mv/agg_without_roll_up/aggregate_without_roll_up_projection.groovy
@@ -0,0 +1,161 @@
+// 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("aggregate_without_roll_up_projection") {
+    String db = context.config.getDbNameByFile(context.file)
+    sql "use ${db}"
+    // Pin the session time zone: the test inserts offset-less TIMESTAMPTZ 
values and the golden output
+    // hard-codes +08:00, so on a non +08:00 runner the rendered values would 
differ for environmental reasons.
+    sql "set time_zone = '+08:00'"
+    sql "set pre_materialized_view_rewrite_strategy = TRY_IN_RBO"
+    // Disable the plain aggregate rule so that the query is rewritten by the
+    // MATERIALIZED_VIEW_PROJECT_FILTER_AGGREGATE rule only, which reproduces 
the bug that a
+    // projection of the group by key in the query top plan is wrongly treated 
as a group by key.
+    sql "set disable_nereids_rules='MATERIALIZED_VIEW_ONLY_AGGREGATE'"
+
+    sql """ DROP TABLE IF EXISTS sync_tz_base; """
+
+    sql """
+        create table sync_tz_base(
+            id int null,
+            ts timestamptz(6) null,
+            v int null
+        )
+        duplicate key (id)
+        distributed BY hash(id) buckets 3
+        properties("replication_num" = "1");
+    """
+
+    sql "insert into sync_tz_base values (1, '2024-01-01 10:00:00', 3), (2, 
'2024-01-01 12:00:00', 4), (3, '2024-01-02 08:00:00', 3), (4, null, 7);"
+
+    // the sync mv contains the same `ts is not null` predicate as the query, 
so it can be covered
+    // by the mv and no predicate compensation is needed
+    create_sync_mv(db, "sync_tz_base", "sync_tz_day",
+            "select date_trunc(ts, 'day') as day_ts, sum(v) as day_sum from 
sync_tz_base "
+                    + "where ts is not null group by date_trunc(ts, 'day');")
+    // The mv groups by (day, id), while a query selecting only `day` is 
grouped by (day, id) and its top
+    // project is a strict matching prefix of the normalized aggregate 
outputs. The redundant aggregate
+    // output must be projected away, otherwise the rewritten plan output 
count differs from the query
+    // and the rewrite is rejected. The select aliases avoid column name 
conflicts with the base table
+    // and the existing mv on the same table.
+    create_sync_mv(db, "sync_tz_base", "sync_tz_day_id",
+            "select date_trunc(ts, 'day') as day_ts2, id as id2, sum(v) as 
day_sum2 from sync_tz_base "
+                    + "where ts is not null group by date_trunc(ts, 'day'), 
id;")
+
+    sql "analyze table sync_tz_base with sync;"
+    sql """alter table sync_tz_base modify column id set stats 
('row_count'='4');"""
+
+    // The query has a derived projection `cast(date_trunc(ts, 'day') as 
string)` of the group by key
+    // `date_trunc(ts, 'day')` in the top project. The derived projection 
should be recomputed by a
+    // project above the rewritten aggregate, and must not be treated as a 
group by key of the
+    // rewritten aggregate.
+    mv_rewrite_success("select cast(date_trunc(ts, 'day') as string) as 
day_ts, sum(v) "
+            + "from sync_tz_base where ts is not null group by date_trunc(ts, 
'day');", "sync_tz_day")
+    order_qt_select_mv """select cast(date_trunc(ts, 'day') as string) as 
day_ts, sum(v)
+            from sync_tz_base where ts is not null group by date_trunc(ts, 
'day') order by 1;"""
+
+    // The query top project is a strict matching prefix of the normalized 
aggregate outputs
+    // (`select day from ... group by day, id`), and the leading subset 
exactly matches the mv
+    // outputs, which reproduces the boundary where the redundant aggregate 
output must be
+    // projected away by a top project above the rewritten aggregate.
+    mv_rewrite_success("select date_trunc(ts, 'day') from sync_tz_base where 
ts is not null "
+            + "group by date_trunc(ts, 'day'), id;", "sync_tz_day_id")
+    order_qt_select_mv_leading_subset """select date_trunc(ts, 'day') from 
sync_tz_base
+            where ts is not null group by date_trunc(ts, 'day'), id order by 
1;"""
+
+    // Multiple query top plan expressions can be rewritten to the same 
aggregate output slot
+    // (`sum(v) as s1` and `sum(v) as s2` both reference the same bottom sum 
slot). Each top
+    // project position must keep a distinct output expr id, otherwise the 
rewritten output set
+    // collapses and the rewritten plan is skipped before normalization.
+    mv_rewrite_success("select cast(date_trunc(ts, 'day') as string) as 
day_ts, sum(v) as s1, "
+            + "sum(v) as s2 from sync_tz_base where ts is not null "
+            + "group by date_trunc(ts, 'day');", "sync_tz_day")
+    order_qt_select_mv_dup_sum """select cast(date_trunc(ts, 'day') as string) 
as day_ts, sum(v) as s1,
+            sum(v) as s2 from sync_tz_base where ts is not null group by 
date_trunc(ts, 'day') order by 1;"""
+
+    // The same duplicate-collapse can also happen for separately aliased 
duplicates of the group by
+    // expression itself.
+    mv_rewrite_success("select date_trunc(ts, 'day') as d1, date_trunc(ts, 
'day') as d2, sum(v) as s "
+            + "from sync_tz_base where ts is not null group by date_trunc(ts, 
'day');", "sync_tz_day")
+    order_qt_select_mv_dup_group """select date_trunc(ts, 'day') as d1, 
date_trunc(ts, 'day') as d2, sum(v) as s
+            from sync_tz_base where ts is not null group by date_trunc(ts, 
'day') order by 1;"""
+
+    // A repeated unaliased bare output references the same original output 
slot twice
+    // (`select day, day, sum(v) ... group by day`). The two positions rewrite 
to the same mv slot and
+    // must keep the single original expr id multiplicity: forcing a fresh 
alias on the repeated
+    // position would inflate the rewritten output set and skip the whole-tree 
normalization and
+    // partition pruning in MaterializedViewUtils.rewriteByRules.
+    mv_rewrite_success("select date_trunc(ts, 'day'), date_trunc(ts, 'day'), 
sum(v) "
+            + "from sync_tz_base where ts is not null group by date_trunc(ts, 
'day');", "sync_tz_day")
+    order_qt_select_mv_dup_bare_group """select date_trunc(ts, 'day'), 
date_trunc(ts, 'day'), sum(v)
+            from sync_tz_base where ts is not null group by date_trunc(ts, 
'day') order by 1;"""
+
+    // The grouping sets rewrite is a separate path of the aggregate rule: the 
rewritten repeat outputs
+    // are built from the query top plan outputs directly, so the same 
duplicate aliasing has to keep the
+    // original top output expr id equivalence classes there too. Re-enable 
the aggregate rule, as the
+    // cases above only exercise the project filter aggregate rule.
+    sql "set disable_nereids_rules=''"
+
+    // A dedicated table and mv without any predicate, because the repeated 
group sets are rewritten by
+    // the mv group sets directly and the mv predicate is not compensated on 
this path.
+    sql """ DROP TABLE IF EXISTS sync_tz_gs; """
+
+    sql """
+        create table sync_tz_gs(
+            id int null,
+            ts timestamptz(6) null,
+            v int null
+        )
+        duplicate key (id)
+        distributed BY hash(id) buckets 3
+        properties("replication_num" = "1");
+    """
+
+    sql "insert into sync_tz_gs values (1, '2024-01-01 10:00:00', 3), (2, 
'2024-01-01 12:00:00', 4), (3, '2024-01-02 08:00:00', 3), (4, null, 7);"
+
+    create_sync_mv(db, "sync_tz_gs", "sync_tz_gs_mv",
+            "select date_trunc(ts, 'day') as day_ts, id as id2, sum(v) as 
day_sum from sync_tz_gs "
+                    + "group by date_trunc(ts, 'day'), id;")
+
+    sql "analyze table sync_tz_gs with sync;"
+    sql """alter table sync_tz_gs modify column id set stats 
('row_count'='4');"""
+
+    // Two separately aliased duplicates of the group by key in a grouping 
sets query (`d1` and `d2`)
+    // both rewrite to the same mv slot. If every position keeps that slot 
expr id, the rewritten repeat
+    // output set collapses from 4 to 3 members, so 
MaterializedViewUtils.rewriteByRules returns at its
+    // output set size guard before the whole-tree normalization. The derived 
`cast(id as string)`
+    // projection then stays inside the repeat aggregate, which 
NormalizeRepeat returns as
+    // normalized=false, so the physical translation can not resolve that 
output and the query fails
+    // with a null pointer exception while translating the final projection.
+    mv_rewrite_success("select date_trunc(ts, 'day') as d1, date_trunc(ts, 
'day') as d2, "
+            + "cast(id as string) as id_s, sum(v) as s from sync_tz_gs "
+            + "group by grouping sets ((date_trunc(ts, 'day'), id), 
(date_trunc(ts, 'day')));",
+            "sync_tz_gs_mv")
+    order_qt_select_mv_grouping_sets """select date_trunc(ts, 'day') as d1, 
date_trunc(ts, 'day') as d2,
+            cast(id as string) as id_s, sum(v) as s from sync_tz_gs
+            group by grouping sets ((date_trunc(ts, 'day'), id), 
(date_trunc(ts, 'day'))) order by 1, 3, 4;"""
+
+    // The opposite direction of the same equivalence class: a repeated 
unaliased bare output references
+    // the same original output slot twice, and both positions must reuse one 
rewritten repeat output.
+    // Forcing a fresh alias on the repeated position would inflate the 
rewritten output set instead.
+    mv_rewrite_success("select date_trunc(ts, 'day'), date_trunc(ts, 'day'), 
sum(v) from sync_tz_gs "
+            + "group by grouping sets ((date_trunc(ts, 'day'), id), 
(date_trunc(ts, 'day')));",
+            "sync_tz_gs_mv")
+    order_qt_select_mv_grouping_sets_bare_group """select date_trunc(ts, 
'day'), date_trunc(ts, 'day'), sum(v)
+            from sync_tz_gs
+            group by grouping sets ((date_trunc(ts, 'day'), id), 
(date_trunc(ts, 'day'))) order by 1, 3;"""
+}


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

Reply via email to