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

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 5e7134d193c [MaterializedView] Add scalar function support to 
AggregationSubsumptionStrategy (#18681)
5e7134d193c is described below

commit 5e7134d193ca242c3c3ee0e8c8e05c74d1ed826b
Author: Hongkun Xu <[email protected]>
AuthorDate: Thu Aug 6 14:25:39 2026 +0800

    [MaterializedView] Add scalar function support to 
AggregationSubsumptionStrategy (#18681)
    
    * Add scalar function support to AggregationSubsumptionStrategy projection 
match
    
    Allow scalar GROUP BY expressions to match projected materialized-view
    columns while preserving each projected grouping expression as an atomic
    key during residual validation.
    
    Previously, a view grouped by UPPER(UniqueCarrier) was considered to expose
    the raw UniqueCarrier input. A query filtering UniqueCarrier could therefore
    be rewritten against a view that did not contain that column. Reject these
    rewrites so they fall back to the base table.
    
    Cover exact scalar matching, alias remapping, hidden-source rejection, and
    the end-to-end rewrite and fallback paths.
    
    Signed-off-by: Hongkun Xu <[email protected]>
    Co-authored-by: Xiang Fu <[email protected]>
    
    * Reject MV candidates whose GROUP BY keys are not materialized
    
    buildResult rewrites every user GROUP BY key to the MV column holding it.
    When the MV groups by a key it never projects, that lookup returned null and
    the rewritten query carried `GROUP BY <null identifier>`, failing on the
    server instead of falling back to the base table.
    
    Treating a scalar as a direct projection hit newly exposed this path: an MV
    such as `SELECT UPPER(city) AS uc, SUM(revenue) AS sum_rev FROM orders GROUP
    BY city` now matches at the projection stage, so the unmaterialized `city`
    key reached the GROUP BY rewrite. The same hole was already reachable
    through a plain identifier key.
    
    Pass the projection map into groupByMatches and require every user GROUP BY
    key to be projected by the MV. Whole-table re-aggregation is unaffected
    because it remaps no key.
    
    Also address review feedback: assert the plan match type instead of the
    exact cost constant in the two residual tests, and fail loudly if the source
    aggregation seeding the scalar MV segment ever reaches its row limit.
    
    ---------
    
    Signed-off-by: Hongkun Xu <[email protected]>
    Co-authored-by: Xiang Fu <[email protected]>
    Co-authored-by: Xiang Fu <[email protected]>
---
 .../MaterializedViewClusterIntegrationTest.java    | 159 ++++++++++++
 .../strategy/AbstractSubsumptionStrategy.java      |  18 +-
 .../strategy/AggregationSubsumptionStrategy.java   |  92 +++++--
 .../rewrite/strategy/ExactSubsumptionStrategy.java |   7 +-
 .../rewrite/strategy/ScanSubsumptionStrategy.java  |   5 +-
 .../AggregationSubsumptionStrategyTest.java        | 285 +++++++++++++++++++++
 6 files changed, 536 insertions(+), 30 deletions(-)

diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MaterializedViewClusterIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MaterializedViewClusterIntegrationTest.java
index e15316d293f..99f84cb8bc3 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MaterializedViewClusterIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MaterializedViewClusterIntegrationTest.java
@@ -87,6 +87,10 @@ import static org.testng.Assert.*;
 ///       ORDER BY + LIMIT + OFFSET; verify correct pagination and ordering.
 /// 3. [#testMultipleMaterializedViewCostSelection()] — two MVs match the same 
query at
 ///       different costs; verify the lower-cost MV is selected.
+/// 4. [#testScalarGroupingFunctionRewrite()] and
+///       [#testScalarGroupingFunctionResidualOnSourceColumnFallsBack()] — a 
finer-grained MV
+///       groups by a scalar expression; verify both re-aggregation and safe 
fallback when a
+///       residual references the unprojected source column.
 ///
 /// **Why this extends [BaseClusterIntegrationTest] directly instead of
 /// 
[org.apache.pinot.integration.tests.custom.CustomDataQueryClusterIntegrationTest]
 :**
@@ -105,12 +109,15 @@ public class MaterializedViewClusterIntegrationTest 
extends BaseClusterIntegrati
   private static final String MATERIALIZED_VIEW_COLD_TABLE_NAME = 
"materializedViewColdTable";
   private static final String MATERIALIZED_VIEW_SCAN_TABLE_NAME = 
"materializedViewScanTable";
   private static final String MATERIALIZED_VIEW_COST_TABLE_NAME = 
"materializedViewCostTable";
+  private static final String MATERIALIZED_VIEW_SCALAR_TABLE_NAME = 
"materializedViewScalarTable";
 
   private static final String MATERIALIZED_VIEW_FULL_TABLE_OFFLINE = 
MATERIALIZED_VIEW_FULL_TABLE_NAME + "_OFFLINE";
   private static final String MATERIALIZED_VIEW_SPLIT_TABLE_OFFLINE = 
MATERIALIZED_VIEW_SPLIT_TABLE_NAME + "_OFFLINE";
   private static final String MATERIALIZED_VIEW_COLD_TABLE_OFFLINE = 
MATERIALIZED_VIEW_COLD_TABLE_NAME + "_OFFLINE";
   private static final String MATERIALIZED_VIEW_SCAN_TABLE_OFFLINE = 
MATERIALIZED_VIEW_SCAN_TABLE_NAME + "_OFFLINE";
   private static final String MATERIALIZED_VIEW_COST_TABLE_OFFLINE = 
MATERIALIZED_VIEW_COST_TABLE_NAME + "_OFFLINE";
+  private static final String MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE =
+      MATERIALIZED_VIEW_SCALAR_TABLE_NAME + "_OFFLINE";
 
   private static final String TIME_COLUMN = "DaysSinceEpoch";
 
@@ -125,6 +132,12 @@ public class MaterializedViewClusterIntegrationTest 
extends BaseClusterIntegrati
 
   private static final String[] CARRIERS = {"AA", "DL", "UA", "WN", "US", 
"B6", "OO", "EV", "MQ", "NK"};
 
+  /// Row cap for the source aggregation that seeds an MV segment.  The MV 
must hold *every* group
+  /// or the row-for-row comparison against the rewrite-disabled baseline 
would validate a
+  /// truncated view; callers assert the returned row count stays strictly 
below this cap so a
+  /// dataset that outgrows it fails loudly instead of silently materializing 
a prefix.
+  private static final int MATERIALIZED_VIEW_SOURCE_ROW_LIMIT = 10000;
+
   private PinotHelixResourceManager _helixResourceManager;
   private HelixPropertyStore<ZNRecord> _propertyStore;
 
@@ -205,6 +218,7 @@ public class MaterializedViewClusterIntegrationTest extends 
BaseClusterIntegrati
     setupColdStartMv();
     setupScanMv();
     setupCostCompetitorMv();
+    setupScalarGroupingMv();
 
     /// Wait for the broker's MaterializedViewMetadataCache to register every 
newly-published MV
     /// by polling a sentinel query that should be served by the full-rewrite 
MV.  Polling on a
@@ -213,6 +227,9 @@ public class MaterializedViewClusterIntegrationTest extends 
BaseClusterIntegrati
     /// race the timeout.
     waitForMaterializedViewRegistered(MATERIALIZED_VIEW_FULL_TABLE_OFFLINE,
         "SELECT Carrier, SUM(ArrDelayMinutes) FROM " + SOURCE_TABLE_NAME + " 
GROUP BY Carrier");
+    waitForMaterializedViewRegistered(MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE,
+        "SELECT UPPER(UniqueCarrier), SUM(ArrDelayMinutes) FROM " + 
SOURCE_TABLE_NAME
+            + " GROUP BY UPPER(UniqueCarrier)");
   }
 
   /// Polls a query that is known to be rewritable to the given MV until the 
broker's metadata
@@ -243,6 +260,7 @@ public class MaterializedViewClusterIntegrationTest extends 
BaseClusterIntegrati
     cleanupMaterializedViewMetadata(MATERIALIZED_VIEW_COLD_TABLE_OFFLINE);
     cleanupMaterializedViewMetadata(MATERIALIZED_VIEW_SCAN_TABLE_OFFLINE);
     cleanupMaterializedViewMetadata(MATERIALIZED_VIEW_COST_TABLE_OFFLINE);
+    cleanupMaterializedViewMetadata(MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE);
 
     /// Drop MV tables before the base table so the controller's 
referential-integrity check
     /// (introduced in pinot-controller to prevent orphaned MVs) does not 
block the base drop.
@@ -251,6 +269,7 @@ public class MaterializedViewClusterIntegrationTest extends 
BaseClusterIntegrati
     dropOfflineTable(MATERIALIZED_VIEW_COLD_TABLE_NAME);
     dropOfflineTable(MATERIALIZED_VIEW_SCAN_TABLE_NAME);
     dropOfflineTable(MATERIALIZED_VIEW_COST_TABLE_NAME);
+    dropOfflineTable(MATERIALIZED_VIEW_SCALAR_TABLE_NAME);
     dropOfflineTable(SOURCE_TABLE_NAME);
 
     stopServer();
@@ -834,6 +853,79 @@ public class MaterializedViewClusterIntegrationTest 
extends BaseClusterIntegrati
         "Full MV (EXACT, cost 0.0) should be selected over cost competitor 
(AGG_REAGG, cost 6.0)");
   }
 
+  /// -----------------------------------------------------------------------
+  ///  Phase 3, Test 10: Scalar grouping function with re-aggregation
+  /// -----------------------------------------------------------------------
+
+  /// Verifies that `AggregationSubsumptionStrategy` can match a scalar 
grouping
+  /// expression. The MV is finer grained (`UPPER(UniqueCarrier), Origin`) 
than the user
+  /// query (`UPPER(UniqueCarrier)`), so the MV aggregate must be 
re-aggregated.
+  @Test
+  public void testScalarGroupingFunctionRewrite()
+      throws Exception {
+    String query = ""
+        + "SELECT UPPER(UniqueCarrier) AS upper_UniqueCarrier, "
+        + "SUM(ArrDelayMinutes) AS sum_ArrDelayMinutes "
+        + "FROM " + SOURCE_TABLE_NAME + " "
+        + "GROUP BY UPPER(UniqueCarrier) "
+        + "ORDER BY UPPER(UniqueCarrier) "
+        + "LIMIT 10000";
+
+    JsonNode materializedViewResponse = postQuery(query);
+    assertNoExceptions(materializedViewResponse);
+    assertEquals(getMaterializedViewQueried(materializedViewResponse), 
MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE,
+        "Expected the finer-grained scalar grouping MV to be selected");
+
+    JsonNode directResponse = postQuery("SET 
enableMaterializedViewRewrite='false'; " + query);
+    assertNoExceptions(directResponse);
+    assertNull(getMaterializedViewQueried(directResponse),
+        "Rewrite-disabled baseline must query the base table");
+
+    JsonNode materializedViewResult = 
materializedViewResponse.get("resultTable");
+    JsonNode directResult = directResponse.get("resultTable");
+    assertNotNull(materializedViewResult);
+    assertNotNull(directResult);
+    assertTrue(materializedViewResult.get("rows").size() > 0, "Result should 
have rows");
+    assertEquals(materializedViewResult.get("dataSchema"), 
directResult.get("dataSchema"),
+        "MV rewrite should preserve the complete result schema");
+    assertEquals(materializedViewResult.get("rows"), directResult.get("rows"),
+        "MV rewrite should return the same complete, ordered result as the 
base table");
+  }
+
+  /// Verifies that a residual on the raw source column hidden inside a scalar 
grouping
+  /// expression rejects the MV and safely falls back to the base table. 
`UniqueCarrier` is not
+  /// projected by any other MV in this test, so an MV hit would expose the 
invalid rewrite.
+  @Test
+  public void testScalarGroupingFunctionResidualOnSourceColumnFallsBack()
+      throws Exception {
+    String query = ""
+        + "SELECT UPPER(UniqueCarrier) AS upper_UniqueCarrier, "
+        + "SUM(ArrDelayMinutes) AS sum_ArrDelayMinutes "
+        + "FROM " + SOURCE_TABLE_NAME + " "
+        + "WHERE UniqueCarrier = 'AA' "
+        + "GROUP BY UPPER(UniqueCarrier) "
+        + "ORDER BY UPPER(UniqueCarrier) "
+        + "LIMIT 10000";
+
+    JsonNode fallbackResponse = postQuery(query);
+    assertNoExceptions(fallbackResponse);
+    assertNull(getMaterializedViewQueried(fallbackResponse),
+        "A residual on the unprojected raw source column must reject the 
scalar-grouping MV");
+
+    JsonNode directResponse = postQuery("SET 
enableMaterializedViewRewrite='false'; " + query);
+    assertNoExceptions(directResponse);
+
+    JsonNode fallbackResult = fallbackResponse.get("resultTable");
+    JsonNode directResult = directResponse.get("resultTable");
+    assertNotNull(fallbackResult);
+    assertNotNull(directResult);
+    assertTrue(fallbackResult.get("rows").size() > 0, "Fallback result should 
have rows");
+    assertEquals(fallbackResult.get("dataSchema"), 
directResult.get("dataSchema"),
+        "Fallback should preserve the complete result schema");
+    assertEquals(fallbackResult.get("rows"), directResult.get("rows"),
+        "Fallback should return the same complete, ordered result as the 
rewrite-disabled query");
+  }
+
   /// -----------------------------------------------------------------------
   ///  MV table setup
   /// -----------------------------------------------------------------------
@@ -1107,6 +1199,73 @@ public class MaterializedViewClusterIntegrationTest 
extends BaseClusterIntegrati
     MaterializedViewRuntimeMetadataUtils.persist(_propertyStore, runtime, -1);
   }
 
+  /// Scalar-grouping MV: groups by `UPPER(UniqueCarrier), Origin` with full 
coverage.
+  /// The extra `Origin` key makes it finer grained than the query exercised by
+  /// [#testScalarGroupingFunctionRewrite()], forcing aggregate re-aggregation.
+  private void setupScalarGroupingMv()
+      throws Exception {
+    Schema materializedViewSchema = new Schema.SchemaBuilder()
+        .setSchemaName(MATERIALIZED_VIEW_SCALAR_TABLE_NAME)
+        .addSingleValueDimension("upper_UniqueCarrier", 
FieldSpec.DataType.STRING)
+        .addSingleValueDimension("Origin", FieldSpec.DataType.STRING)
+        .addMetric("sum_ArrDelayMinutes", FieldSpec.DataType.DOUBLE)
+        .build();
+    addSchema(materializedViewSchema);
+
+    TableConfig materializedViewTableConfig = new 
TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(MATERIALIZED_VIEW_SCALAR_TABLE_NAME)
+        .setNumReplicas(1)
+        .build();
+    addTableConfig(materializedViewTableConfig);
+
+    /// Materialize the exact source-table aggregates so the rewrite can be 
checked
+    /// row-for-row against a rewrite-disabled baseline rather than synthetic 
values.
+    String sourceAggregationQuery = ""
+        + "SET enableMaterializedViewRewrite='false'; "
+        + "SELECT UPPER(UniqueCarrier), Origin, SUM(ArrDelayMinutes) "
+        + "FROM " + SOURCE_TABLE_NAME + " "
+        + "GROUP BY UPPER(UniqueCarrier), Origin "
+        + "ORDER BY UPPER(UniqueCarrier), Origin "
+        + "LIMIT " + MATERIALIZED_VIEW_SOURCE_ROW_LIMIT;
+    JsonNode sourceAggregationResponse = postQuery(sourceAggregationQuery);
+    assertNoExceptions(sourceAggregationResponse);
+
+    JsonNode sourceRows = 
sourceAggregationResponse.get("resultTable").get("rows");
+    assertTrue(sourceRows.size() > 0, "Source aggregation should produce 
rows");
+    assertTrue(sourceRows.size() < MATERIALIZED_VIEW_SOURCE_ROW_LIMIT,
+        "Source aggregation hit the row limit, so the MV would hold only a 
prefix of the groups "
+            + "and the row-for-row comparison would validate a truncated view. 
Raise "
+            + "MATERIALIZED_VIEW_SOURCE_ROW_LIMIT.");
+    List<GenericRow> rows = new ArrayList<>(sourceRows.size());
+    for (JsonNode sourceRow : sourceRows) {
+      GenericRow row = new GenericRow();
+      row.putValue("upper_UniqueCarrier", sourceRow.get(0).asText());
+      row.putValue("Origin", sourceRow.get(1).asText());
+      row.putValue("sum_ArrDelayMinutes", sourceRow.get(2).asDouble());
+      rows.add(row);
+    }
+    buildAndUploadSegment(materializedViewTableConfig, materializedViewSchema, 
rows,
+        MATERIALIZED_VIEW_SCALAR_TABLE_NAME, "materializedViewScalarSeg");
+
+    waitForAnyDocLoaded(MATERIALIZED_VIEW_SCALAR_TABLE_NAME, 60_000L);
+
+    String definedSql = "SELECT UPPER(UniqueCarrier) AS upper_UniqueCarrier, 
Origin, "
+        + "SUM(ArrDelayMinutes) AS sum_ArrDelayMinutes "
+        + "FROM " + SOURCE_TABLE_NAME + " "
+        + "GROUP BY UPPER(UniqueCarrier), Origin";
+    MaterializedViewDefinitionMetadata definition = new 
MaterializedViewDefinitionMetadata(
+        MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE,
+        List.of(SOURCE_TABLE_NAME),
+        definedSql,
+        Map.of(),
+        null);
+    MaterializedViewDefinitionMetadataUtils.persist(_propertyStore, 
definition, -1);
+
+    MaterializedViewRuntimeMetadata runtime = new 
MaterializedViewRuntimeMetadata(
+        MATERIALIZED_VIEW_SCALAR_TABLE_OFFLINE, DATA_MAX_TIME_MS, new 
HashMap<>());
+    MaterializedViewRuntimeMetadataUtils.persist(_propertyStore, runtime, -1);
+  }
+
   /// -----------------------------------------------------------------------
   ///  Segment building helpers
   /// -----------------------------------------------------------------------
diff --git 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AbstractSubsumptionStrategy.java
 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AbstractSubsumptionStrategy.java
index debc829f4f0..e9eacaf9bdf 100644
--- 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AbstractSubsumptionStrategy.java
+++ 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AbstractSubsumptionStrategy.java
@@ -91,14 +91,17 @@ public abstract class AbstractSubsumptionStrategy 
implements MaterializedViewMat
       return null;
     }
 
+    /// The projection map cached on the entry is needed from Step 2 onward: a 
GROUP BY key is only
+    /// usable if the MV actually materialized a column for it.
+    Map<Expression, String> viewProjectionMap = 
candidateEntry.getViewProjectionMap();
+
     /// Step 2: GROUP BY
-    if (!groupByMatches(userQuery, viewQuery)) {
+    if (!groupByMatches(userQuery, viewQuery, viewProjectionMap)) {
       LOGGER.debug("MV match [{}] strategy={}: rejected at GROUP_BY", 
materializedViewName, strategyName);
       return null;
     }
 
-    /// Step 3: projection subsumption — use the projection map cached on the 
entry.
-    Map<Expression, String> viewProjectionMap = 
candidateEntry.getViewProjectionMap();
+    /// Step 3: projection subsumption
     if (!projectionSubsumes(userQuery.getSelectList(), viewProjectionMap)) {
       LOGGER.debug("MV match [{}] strategy={}: rejected at PROJECTION", 
materializedViewName, strategyName);
       return null;
@@ -171,7 +174,14 @@ public abstract class AbstractSubsumptionStrategy 
implements MaterializedViewMat
 
   /// Returns `true` if the user query's GROUP BY clause is compatible
   /// with the MV's GROUP BY clause.
-  protected abstract boolean groupByMatches(PinotQuery userQuery, PinotQuery 
viewQuery);
+  ///
+  /// @param userQuery         the user's compiled query
+  /// @param viewQuery         the MV's compiled query
+  /// @param viewProjectionMap alias-stripped expression &rarr; MV column 
name.  Strategies that
+  ///                          rewrite the user's GROUP BY to MV columns must 
use this to reject
+  ///                          keys the MV groups by but does not materialize 
a column for.
+  protected abstract boolean groupByMatches(PinotQuery userQuery, PinotQuery 
viewQuery,
+      Map<Expression, String> viewProjectionMap);
 
   /// Returns `true` if the MV's projection (SELECT list) covers all
   /// expressions required by the user query.
diff --git 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AggregationSubsumptionStrategy.java
 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AggregationSubsumptionStrategy.java
index 1fc786d1671..55960a910f3 100644
--- 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AggregationSubsumptionStrategy.java
+++ 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/AggregationSubsumptionStrategy.java
@@ -69,7 +69,8 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
   }
 
   @Override
-  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery) 
{
+  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery,
+      Map<Expression, String> viewProjectionMap) {
     List<Expression> userGroupBy = userQuery.getGroupByList();
     List<Expression> materializedViewGroupBy = viewQuery.getGroupByList();
 
@@ -90,7 +91,22 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
 
     Set<Expression> userSet = new HashSet<>(userGroupBy);
     Set<Expression> materializedViewSet = new 
HashSet<>(materializedViewGroupBy);
-    return materializedViewSet.containsAll(userSet);
+    if (!materializedViewSet.containsAll(userSet)) {
+      return false;
+    }
+
+    /// The MV grouping by a key is not enough — buildResult rewrites every 
user GROUP BY key to the
+    /// MV column that holds it, so the MV must also *project* each key.  An 
MV such as
+    /// `SELECT UPPER(city) AS uc, SUM(revenue) AS sum_rev FROM orders GROUP 
BY city` groups by
+    /// `city` without materializing it: the rewritten query would carry 
`GROUP BY <null>` and fail
+    /// on the server instead of falling back to the base table.  Reject here 
so the user query
+    /// simply runs against the base table.
+    for (Expression userGroupByExpr : userSet) {
+      if (!viewProjectionMap.containsKey(userGroupByExpr)) {
+        return false;
+      }
+    }
+    return true;
   }
 
   @Override
@@ -101,19 +117,20 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
     }
     for (Expression expr : userSelectList) {
       Expression stripped = MaterializedViewMatchUtils.stripAlias(expr);
-      /// Plain column reference: direct MV projection hit is sufficient.
-      if (stripped.getFunctionCall() == null) {
+      boolean isAggregate = CalciteSqlParser.isAggregateExpression(stripped);
+      if (isAggregate) {
+        /// Aggregate (incl. ROUND(SUM(x))): rewritable only if a registered 
equivalence exists,
+        /// else rejected. No new aggregates supported here.
+        if (findEquivalentMaterializedViewEntry(stripped, viewProjectionMap) 
== null) {
+          return false;
+        }
+      } else {
+        /// Plain column OR scalar grouping function (e.g. DATETRUNC('DAY', 
ts)): a direct MV
+        /// projection hit is sufficient — the containsKey check below 
confirms the MV
+        /// materialized this exact expression.
         if (!viewProjectionMap.containsKey(stripped)) {
           return false;
         }
-        continue;
-      }
-      /// Aggregate function: an exact projection match is NOT enough on its 
own. We need an
-      /// AggregationEquivalence rule to re-aggregate the pre-computed MV 
column correctly.
-      /// Without a rule we would fall back to a bare column reference (e.g. 
AVG(revenue) →
-      /// avg_rev), which produces wrong results for non-distributive 
functions.
-      if (findEquivalentMaterializedViewEntry(stripped, viewProjectionMap) == 
null) {
-        return false;
       }
     }
     return true;
@@ -192,13 +209,34 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
       return false;
     }
 
-    Set<String> groupByColumnNames = new 
HashSet<>(materializedViewGroupBy.size());
-    for (Expression gbExpr : materializedViewGroupBy) {
-      
groupByColumnNames.addAll(MaterializedViewMatchUtils.collectReferencedColumns(gbExpr));
-    }
+    return residualReferencesProjectedGroupKeys(residualFilter, new 
HashSet<>(materializedViewGroupBy),
+        viewProjectionMap);
+  }
 
-    Set<String> residualColumns = 
MaterializedViewMatchUtils.collectReferencedColumns(residualFilter);
-    return groupByColumnNames.containsAll(residualColumns);
+  private static boolean residualReferencesProjectedGroupKeys(Expression expr,
+      Set<Expression> materializedViewGroupBy, Map<Expression, String> 
viewProjectionMap) {
+    /// Treat a projected GROUP BY expression as an atomic value. For example,
+    /// DATETRUNC('DAY', ts) can be remapped to the MV's `day` column, but its 
source column `ts`
+    /// is not available at the original granularity and cannot be used in a 
bare residual filter.
+    if (materializedViewGroupBy.contains(expr) && 
viewProjectionMap.containsKey(expr)) {
+      return true;
+    }
+    if (expr.getType() == ExpressionType.LITERAL) {
+      return true;
+    }
+    if (expr.getType() == ExpressionType.IDENTIFIER) {
+      return false;
+    }
+    Function function = expr.getFunctionCall();
+    if (function == null || function.getOperands() == null) {
+      return false;
+    }
+    for (Expression operand : function.getOperands()) {
+      if (!residualReferencesProjectedGroupKeys(operand, 
materializedViewGroupBy, viewProjectionMap)) {
+        return false;
+      }
+    }
+    return true;
   }
 
   @Override
@@ -280,6 +318,8 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
     if (userGroupBy != null && !userGroupBy.isEmpty()) {
       List<Expression> remappedGroupBy = new ArrayList<>(userGroupBy.size());
       for (Expression gbExpr : userGroupBy) {
+        /// Never null: groupByMatches rejected the candidate unless every 
user GROUP BY key is
+        /// projected by the MV.  A null here would silently emit `GROUP BY 
<null identifier>`.
         String materializedViewCol = viewProjectionMap.get(gbExpr);
         
remappedGroupBy.add(RequestUtils.getIdentifierExpression(materializedViewCol));
       }
@@ -346,11 +386,15 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
       String userAlias = MaterializedViewMatchUtils.extractUserAlias(expr);
       Expression rewritten;
 
-      if (viewProjectionMap.containsKey(stripped)
-          && stripped.getFunctionCall() == null) {
+      boolean isAggregate = CalciteSqlParser.isAggregateExpression(stripped);
+      if (!isAggregate && viewProjectionMap.containsKey(stripped)) {
+        /// Plain column OR scalar grouping function: project the MV column 
directly.
         rewritten = 
RequestUtils.getIdentifierExpression(viewProjectionMap.get(stripped));
-      } else {
+      } else if (isAggregate) {
         rewritten = rewriteAggregationExpression(stripped, viewProjectionMap);
+      } else {
+        throw new IllegalStateException(
+            "Cannot rewrite non-aggregate expression without MV projection: " 
+ stripped);
       }
 
       /// Preserve the user's expected result-column name. Bare-identifier 
dimensions that map 1:1
@@ -391,11 +435,13 @@ public class AggregationSubsumptionStrategy extends 
AbstractSubsumptionStrategy
 
   private Expression remapExpressionWithEquivalence(Expression expr,
       Map<Expression, String> viewProjectionMap) {
-    if (viewProjectionMap.containsKey(expr) && expr.getFunctionCall() == null) 
{
+    /// Plain column OR scalar grouping function with a direct MV hit: map to 
the MV column.
+    boolean isAggregate = CalciteSqlParser.isAggregateExpression(expr);
+    if (!isAggregate && viewProjectionMap.containsKey(expr)) {
       return RequestUtils.getIdentifierExpression(viewProjectionMap.get(expr));
     }
 
-    if (expr.getFunctionCall() != null) {
+    if (isAggregate && expr.getFunctionCall() != null) {
       if (viewProjectionMap.containsKey(expr)) {
         return rewriteAggregationExpression(expr, viewProjectionMap);
       }
diff --git 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ExactSubsumptionStrategy.java
 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ExactSubsumptionStrategy.java
index 6264556996d..896b4e06cb0 100644
--- 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ExactSubsumptionStrategy.java
+++ 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ExactSubsumptionStrategy.java
@@ -63,9 +63,12 @@ public class ExactSubsumptionStrategy extends 
AbstractSubsumptionStrategy {
     return true;
   }
 
-  /// Requires GROUP BY lists to be identical (same expressions, same order).
+  /// Requires GROUP BY lists to be identical (same expressions, same order).  
The projection map
+  /// is not consulted: [#projectionSubsumes] already requires the SELECT 
lists to be equal, so an
+  /// identical GROUP BY key is necessarily materialized.
   @Override
-  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery) 
{
+  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery,
+      Map<Expression, String> viewProjectionMap) {
     List<Expression> userList = userQuery.getGroupByList();
     List<Expression> materializedViewList = viewQuery.getGroupByList();
     if (userList == null && materializedViewList == null) {
diff --git 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ScanSubsumptionStrategy.java
 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ScanSubsumptionStrategy.java
index e091b7fe068..b94dd815154 100644
--- 
a/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ScanSubsumptionStrategy.java
+++ 
b/pinot-materialized-view/src/main/java/org/apache/pinot/materializedview/rewrite/strategy/ScanSubsumptionStrategy.java
@@ -55,8 +55,11 @@ public class ScanSubsumptionStrategy extends 
AbstractSubsumptionStrategy {
         && MaterializedViewQueryShape.classify(viewQuery) == 
MaterializedViewQueryShape.SCAN;
   }
 
+  /// Scan queries have no GROUP BY on either side, so there is no key to 
remap and the projection
+  /// map is not consulted.
   @Override
-  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery) 
{
+  protected boolean groupByMatches(PinotQuery userQuery, PinotQuery viewQuery,
+      Map<Expression, String> viewProjectionMap) {
     return !userQuery.isSetGroupByList() && !viewQuery.isSetGroupByList();
   }
 
diff --git 
a/pinot-materialized-view/src/test/java/org/apache/pinot/materializedview/rewrite/AggregationSubsumptionStrategyTest.java
 
b/pinot-materialized-view/src/test/java/org/apache/pinot/materializedview/rewrite/AggregationSubsumptionStrategyTest.java
index a902f0ffce6..e20a511a126 100644
--- 
a/pinot-materialized-view/src/test/java/org/apache/pinot/materializedview/rewrite/AggregationSubsumptionStrategyTest.java
+++ 
b/pinot-materialized-view/src/test/java/org/apache/pinot/materializedview/rewrite/AggregationSubsumptionStrategyTest.java
@@ -892,4 +892,289 @@ public class AggregationSubsumptionStrategyTest {
         .getFunctionCall().getOperands().get(0).getIdentifier().getName(), 
"raw_hll_FlightNum");
     
assertTrue(rewritten.getOrderByList().get(0).toString().contains("raw_hll_FlightNum"));
   }
+
+  /// =======================================================================
+  ///  Scalar grouping function support (e.g. DATETRUNC)
+  /// =======================================================================
+
+  /// A scalar grouping function (DATETRUNC) in the SELECT list must be 
treated as a plain
+  /// projection — a direct MV column hit is sufficient. It must NOT be routed 
through the
+  /// aggregation-equivalence path (which would reject it for lack of a 
re-aggregation rule).
+  @Test
+  public void testScalarGroupingFunctionExactMatch() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders GROUP BY 
DATETRUNC('DAY', ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "Scalar grouping function should match via direct MV 
projection");
+
+    PinotQuery rewritten = result.getMaterializedViewQuery();
+
+    /// GROUP BY remapped to the MV column.
+    assertNotNull(rewritten.getGroupByList());
+    assertEquals(rewritten.getGroupByList().size(), 1);
+    assertEquals(rewritten.getGroupByList().get(0).getIdentifier().getName(), 
"day");
+
+    List<Expression> selectList = rewritten.getSelectList();
+    assertEquals(selectList.size(), 2);
+
+    /// DATETRUNC('DAY', ts) → day AS datetrunc('DAY', ts) (direct projection, 
alias preserves name).
+    Function dayAlias = selectList.get(0).getFunctionCall();
+    assertNotNull(dayAlias);
+    assertEquals(dayAlias.getOperator(), "as");
+    assertEquals(dayAlias.getOperands().get(0).getIdentifier().getName(), 
"day");
+
+    /// SUM(revenue) → SUM(sum_rev) AS sum(revenue).
+    Function sumAlias = selectList.get(1).getFunctionCall();
+    assertNotNull(sumAlias);
+    assertEquals(sumAlias.getOperator(), "as");
+    Function rewrittenSum = sumAlias.getOperands().get(0).getFunctionCall();
+    assertNotNull(rewrittenSum);
+    assertEquals(rewrittenSum.getOperator(), "sum");
+    assertEquals(rewrittenSum.getOperands().get(0).getIdentifier().getName(), 
"sum_rev");
+  }
+
+  /// A scalar grouping function used in ORDER BY must remap to the MV column.
+  @Test
+  public void testScalarGroupingFunctionInOrderBy() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders "
+            + "GROUP BY DATETRUNC('DAY', ts) ORDER BY DATETRUNC('DAY', ts) 
DESC");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result);
+    PinotQuery rewritten = result.getMaterializedViewQuery();
+    assertNotNull(rewritten.getOrderByList());
+    assertEquals(rewritten.getOrderByList().size(), 1);
+    Function orderByFunction = 
rewritten.getOrderByList().get(0).getFunctionCall();
+    assertNotNull(orderByFunction);
+    assertEquals(orderByFunction.getOperator(), "desc");
+    
assertEquals(orderByFunction.getOperands().get(0).getIdentifier().getName(), 
"day");
+  }
+
+  /// A scalar grouping function used in HAVING must remap to the MV column.
+  @Test
+  public void testScalarGroupingFunctionInHaving() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders "
+            + "GROUP BY DATETRUNC('DAY', ts) HAVING DATETRUNC('DAY', ts) > 0");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result);
+    PinotQuery rewritten = result.getMaterializedViewQuery();
+    assertNotNull(rewritten.getHavingExpression());
+    Function havingFunction = 
rewritten.getHavingExpression().getFunctionCall();
+    assertNotNull(havingFunction);
+    assertEquals(havingFunction.getOperator(), "GREATER_THAN");
+    
assertEquals(havingFunction.getOperands().get(0).getIdentifier().getName(), 
"day");
+  }
+
+  /// A scalar function not present in the MV projection (different truncation 
unit) must reject.
+  @Test
+  public void testNoMatchScalarFunctionNotMaterialized() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('MONTH', ts), SUM(revenue) FROM orders GROUP BY 
DATETRUNC('MONTH', ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNull(result, "DATETRUNC('MONTH', ts) is not a materialized 
projection and must be rejected");
+  }
+
+  /// MV is grouped by a superset of the user keys (DATETRUNC('DAY', ts), 
city) while the user
+  /// groups only by DATETRUNC('DAY', ts). Re-aggregation is non-trivial here 
(multiple MV rows
+  /// collapse per day), and the scalar grouping function must still resolve 
as a direct MV hit.
+  @Test
+  public void testScalarGroupingFunctionFinerMaterializedViewGranularity() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, city, SUM(revenue) AS sum_rev 
FROM orders "
+            + "GROUP BY DATETRUNC('DAY', ts), city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders GROUP BY 
DATETRUNC('DAY', ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "Finer MV granularity with a scalar grouping key 
should re-aggregate");
+
+    PinotQuery rewritten = result.getMaterializedViewQuery();
+    assertNotNull(rewritten.getGroupByList());
+    assertEquals(rewritten.getGroupByList().size(), 1);
+    assertEquals(rewritten.getGroupByList().get(0).getIdentifier().getName(), 
"day");
+
+    List<Expression> selectList = rewritten.getSelectList();
+    assertEquals(selectList.size(), 2);
+    Function dayAlias = selectList.get(0).getFunctionCall();
+    assertNotNull(dayAlias);
+    assertEquals(dayAlias.getOperator(), "as");
+    assertEquals(dayAlias.getOperands().get(0).getIdentifier().getName(), 
"day");
+
+    Function sumAlias = selectList.get(1).getFunctionCall();
+    assertNotNull(sumAlias);
+    Function rewrittenSum = sumAlias.getOperands().get(0).getFunctionCall();
+    assertNotNull(rewrittenSum);
+    assertEquals(rewrittenSum.getOperands().get(0).getIdentifier().getName(), 
"sum_rev");
+  }
+
+  /// The raw source column inside a scalar grouping expression is not 
available in the MV.
+  /// Filtering on `ts` after it has been truncated to `day` would either 
reference a missing
+  /// column or incorrectly apply an intra-day predicate to a whole-day bucket.
+  @Test
+  public void testScalarGroupingFunctionRejectsResidualOnSourceColumn() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders WHERE ts >= 
1000 "
+            + "GROUP BY DATETRUNC('DAY', ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNull(result, "Bare source column 'ts' is not projected by the 
materialized view");
+  }
+
+  /// The complete scalar grouping expression is available as one atomic MV 
column, so a residual
+  /// on that exact expression is safe and must be remapped without exposing 
its source column.
+  @Test
+  public void 
testScalarGroupingFunctionAllowsResidualOnMaterializedExpression() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, SUM(revenue) AS sum_rev FROM 
orders "
+            + "GROUP BY DATETRUNC('DAY', ts)";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders "
+            + "WHERE DATETRUNC('DAY', ts) >= 1000 GROUP BY DATETRUNC('DAY', 
ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "The complete materialized grouping expression is a 
valid residual");
+    assertEquals(result.getMatchType(), MatchType.AGG_REAGG);
+    Expression residual = 
result.getMaterializedViewQuery().getFilterExpression();
+    assertNotNull(residual, "The residual must be retained on the MV side, not 
dropped");
+    
assertEquals(residual.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
 "day");
+  }
+
+  /// A scalar grouping key must not prevent residual filtering on another 
grouping key that the
+  /// MV projects directly. The aliased projection also verifies that the 
residual is remapped to
+  /// the MV-side column name.
+  @Test
+  public void 
testScalarGroupingFunctionAllowsResidualOnProjectedIdentifierGroupKey() {
+    String definedSql =
+        "SELECT DATETRUNC('DAY', ts) AS day, city AS mv_city, SUM(revenue) AS 
sum_rev FROM orders "
+            + "GROUP BY DATETRUNC('DAY', ts), city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DATETRUNC('DAY', ts), SUM(revenue) FROM orders WHERE city = 
'NYC' "
+            + "GROUP BY DATETRUNC('DAY', ts)");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "Directly projected identifier group keys remain 
valid residuals");
+    assertEquals(result.getMatchType(), MatchType.AGG_REAGG);
+    Expression residual = 
result.getMaterializedViewQuery().getFilterExpression();
+    assertNotNull(residual, "The residual must be retained on the MV side, not 
dropped");
+    
assertEquals(residual.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
 "mv_city");
+  }
+
+  /// Regression: an MV may group by a key it never materializes — here `city` 
is only visible
+  /// through `UPPER(city)`. Matching such an MV would rewrite the user's 
`GROUP BY city` to the
+  /// missing column and emit `GROUP BY <null identifier>`, failing on the 
server instead of
+  /// falling back to the base table. Treating the scalar as a direct 
projection hit must not
+  /// bypass that check.
+  @Test
+  public void testNoMatchScalarProjectedButGroupKeyNotMaterialized() {
+    String definedSql =
+        "SELECT UPPER(city) AS uc, SUM(revenue) AS sum_rev FROM orders GROUP 
BY city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT UPPER(city), SUM(revenue) FROM orders GROUP BY city");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNull(result, "'city' is grouped by the MV but not materialized as a 
column");
+  }
+
+  /// The same invariant for a plain identifier grouping key: an MV that 
groups by `city` without
+  /// projecting it cannot answer a query grouped by `city`.
+  @Test
+  public void testNoMatchGroupKeyNotMaterialized() {
+    String definedSql = "SELECT SUM(revenue) AS sum_rev FROM orders GROUP BY 
city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT SUM(revenue) FROM orders GROUP BY city");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNull(result, "'city' is grouped by the MV but not materialized as a 
column");
+  }
+
+  /// Counterpart to the two rejections above: once the MV also projects the 
grouping key, the
+  /// scalar projection resolves and the key remaps to the MV column.
+  @Test
+  public void testScalarProjectedWithMaterializedGroupKey() {
+    String definedSql =
+        "SELECT city, UPPER(city) AS uc, SUM(revenue) AS sum_rev FROM orders 
GROUP BY city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT UPPER(city), SUM(revenue) FROM orders GROUP BY city");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "Both the grouping key and the scalar projection are 
materialized");
+    PinotQuery rewritten = result.getMaterializedViewQuery();
+    assertEquals(rewritten.getGroupByList().size(), 1);
+    assertEquals(rewritten.getGroupByList().get(0).getIdentifier().getName(), 
"city");
+
+    Function upperAlias = rewritten.getSelectList().get(0).getFunctionCall();
+    assertNotNull(upperAlias);
+    assertEquals(upperAlias.getOperator(), "as");
+    assertEquals(upperAlias.getOperands().get(0).getIdentifier().getName(), 
"uc");
+  }
+
+  /// A whole-table user aggregate re-aggregates the MV's per-group rows, so 
no GROUP BY key needs
+  /// remapping — the unmaterialized MV grouping key must not reject this 
rewrite.
+  @Test
+  public void testWholeTableAggregateOverUnmaterializedGroupKey() {
+    String definedSql = "SELECT SUM(revenue) AS sum_rev FROM orders GROUP BY 
city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery("SELECT 
SUM(revenue) FROM orders");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNotNull(result, "Whole-table re-aggregation does not reference the 
MV grouping key");
+    assertNull(result.getMaterializedViewQuery().getGroupByList());
+  }
+
+  /// Regression: a nested aggregate such as ROUND(SUM(x)) must stay on the 
aggregate path. There is
+  /// no re-aggregation rule for ROUND, so it must be rejected — never treated 
as a scalar direct hit.
+  @Test
+  public void testNoMatchNestedAggregateRoundSum() {
+    String definedSql = "SELECT city, SUM(revenue) AS sum_rev FROM orders 
GROUP BY city";
+    MaterializedViewCacheEntry entry = createEntry("mv_orders_OFFLINE", 
"orders", definedSql);
+
+    PinotQuery userQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT city, ROUND(SUM(revenue)) FROM orders GROUP BY city");
+    MaterializedViewRewritePlan result = _strategy.match(userQuery, entry);
+
+    assertNull(result, "ROUND(SUM(revenue)) has no re-aggregation rule and 
must be rejected");
+  }
 }


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

Reply via email to