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 06d6079e49f Stop the single-stage engine from silently ignoring the 
HAVING clause (#19554)
06d6079e49f is described below

commit 06d6079e49fbf726c55136eaed54b603cc2d2002
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Thu Sep 17 02:54:48 2026 +0200

    Stop the single-stage engine from silently ignoring the HAVING clause 
(#19554)
    
    * Stop the single-stage engine from silently ignoring the HAVING clause
    
    The single-stage engine evaluates a HAVING filter only while reducing a
    GROUP BY aggregation. Every other shape answered as if the clause were
    absent, so the predicate was discarded with no error and no warning:
    
      SELECT city FROM t GROUP BY city HAVING city > 'B'   -- every city
      SELECT COUNT(*) FROM t HAVING COUNT(*) > 100         -- the count anyway
      SELECT city FROM t HAVING city > 'B'                 -- every row
    
    A third group of shapes failed instead with an internal error naming a
    clause the user did not write ("Failed to find SELECT expression: city in
    the GROUP-BY clause").
    
    The multi-stage engine answers all of these correctly or rejects them, so
    the two engines disagreed on the same query.
    
    Three changes, each where the predicate was lost:
    
    CalciteSqlParser.validateHavingClause() rejects what the engine cannot
    evaluate. A HAVING clause imposes grouping semantics: without a GROUP BY
    the whole table becomes a single group, so every expression in HAVING, and
    in the SELECT list of a query with no GROUP BY, must be an aggregation, a
    literal, or functionally dependent on the GROUP BY columns. This is the
    rule Calcite applies for the multi-stage engine ("Expression 'x' is not
    being grouped"), so both engines now reject the same queries. Validation
    lives in validate() rather than in a new QueryRewriter because
    QueryRewriterFactory.init() replaces the rewriter list wholesale, so an
    operator with a custom list would not get the check.
    
    NonAggregationGroupByToDistinctQueryRewriter leaves a query carrying a
    HAVING clause alone. The rewrite drops the GROUP BY list, and DISTINCT has
    no reduce step that evaluates a HAVING filter, so the predicate used to
    disappear there. Keeping the GROUP BY puts the query back on the reduce
    path that does evaluate it, which also fixes the internal errors above.
    
    A GROUP BY with no aggregation anywhere is rejected rather than answered.
    Moving the predicate into WHERE would be equivalent for a single-valued
    grouping column, but not for a multi-valued one: GROUP BY builds one group
    per value while WHERE keeps whole rows, so a row whose values straddle the
    predicate would contribute values the predicate excludes. The rewrite runs
    before any schema is available, so the two cases cannot be told apart. The
    error names the WHERE rewrite and the multi-stage engine.
    
    AggregationDataTableReducer applies the filter to the single group of an
    aggregation without GROUP BY. Null awareness is requested unconditionally
    rather than from requiresNullAwareKeyEvaluation(): that flag only reports
    the query's null-handling option, but this reducer materializes a null
    final result either way, because an aggregation over an empty whole-table
    group has no value to report. The flag gates the "a null never matches"
    early return in PredicateRowMatcher, so without it a null would be unboxed
    and throw. The result rewriters run even when the row is filtered away,
    since they may replace the DataSchema and the schema must not depend on
    the data.
    
    QueryValidationTest's GROOVY-in-HAVING fixture used a shape that is now
    rejected; it is rewritten as a legal aggregate query, and compilation is
    moved inside the try so a compile failure is reported rather than thrown.
    
    * Reject a HAVING predicate that references an ungrouped column next to an 
aggregate
    
    validateHavingClause() used expressionOutsideGroupByList(), which accepts an
    expression as soon as it contains an aggregation anywhere. That is too
    permissive for HAVING: in
    
      SELECT COUNT(*) FROM t HAVING COUNT(*) > amount
      SELECT city FROM t GROUP BY city HAVING COUNT(*) > amount
    
    the predicate contains COUNT(*), so it passed validation, but `amount` still
    has no single value per group. The query then failed during broker reduce 
with
    "Failed to find SELECT expression: amount in the GROUP-BY clause" -- an
    internal error naming a clause the user did not write, and in the first case
    naming a GROUP BY clause the query does not have.
    
    findUngroupedReference() walks the predicate and stops at an aggregation
    instead of short-circuiting on one, so an identifier is accepted only when 
it
    is a grouping column or an argument of an aggregation. It returns the 
offending
    expression so the message names the column rather than the whole predicate.
    
    A filtered aggregation is resolved like any other aggregation, and its 
FILTER
    predicate is evaluated per row while aggregating, so it may reference 
columns
    that are not grouped and is not descended into.
    
    ---------
    
    Co-authored-by: Gonzalo Ortiz <[email protected]>
---
 .../broker/requesthandler/QueryValidationTest.java |   8 +-
 .../apache/pinot/sql/parsers/CalciteSqlParser.java | 109 ++++++++++++
 ...nAggregationGroupByToDistinctQueryRewriter.java |   7 +
 .../query/reduce/AggregationDataTableReducer.java  |  26 ++-
 .../apache/pinot/queries/HavingQueriesTest.java    | 186 +++++++++++++++++++++
 5 files changed, 329 insertions(+), 7 deletions(-)

diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryValidationTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryValidationTest.java
index f3ebe5a647b..9512ebf8e15 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryValidationTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/QueryValidationTest.java
@@ -111,9 +111,10 @@ public class QueryValidationTest {
     testValidateGroovyQuery(
         "SELECT COUNT(colA) FROM bar GROUP BY 
GROOVY('{\"returnType\":\"STRING\",\"isSingleValue\":true}', "
             + "'arg0 + arg1', colA, colB)", true);
+    // HAVING imposes grouping semantics, so both the predicate and the SELECT 
list have to be aggregated.
     testValidateGroovyQuery(
-        "SELECT foo FROM bar HAVING 
GROOVY('{\"returnType\":\"STRING\",\"isSingleValue\":true}', 'arg0 + arg1', 
colA,"
-            + " colB) = 'foobarval'", true);
+        "SELECT COUNT(*) FROM bar HAVING 
GROOVY('{\"returnType\":\"STRING\",\"isSingleValue\":true}', "
+            + "'arg0 + arg1', MAX(colA), MIN(colB)) = 'foobarval'", true);
 
     testValidateGroovyQuery("SELECT foo FROM bar", false);
   }
@@ -167,9 +168,8 @@ public class QueryValidationTest {
   }
 
   private void testValidateGroovyQuery(String query, boolean 
queryContainsGroovy) {
-    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
-
     try {
+      PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(query);
       BaseSingleStageBrokerRequestHandler.validateGroovyScript(pinotQuery, 
queryContainsGroovy);
       if (queryContainsGroovy) {
         fail("Query should have failed since groovy was found in query: " + 
pinotQuery);
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
index ed16a9d85ad..d50f0f1e8dd 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
@@ -247,6 +247,7 @@ public class CalciteSqlParser {
       throws SqlCompilationException {
     boolean hasGroupByClause = pinotQuery.getGroupByList() != null;
     Set<Expression> groupByExprs = hasGroupByClause ? new 
HashSet<>(pinotQuery.getGroupByList()) : null;
+    validateHavingClause(pinotQuery, hasGroupByClause, groupByExprs);
     int aggregateExprCount = 0;
     for (Expression selectExpression : pinotQuery.getSelectList()) {
       if (isAggregateExpression(selectExpression)) {
@@ -301,6 +302,114 @@ public class CalciteSqlParser {
     }
   }
 
+  /// Rejects a HAVING clause that the single-stage engine would otherwise 
drop.
+  ///
+  /// HAVING is evaluated only while reducing a GROUP BY aggregation. Applied 
to any other shape the predicate used to
+  /// be discarded silently, so the query answered as if the clause were 
absent.
+  ///
+  /// A HAVING clause imposes grouping semantics: without a GROUP BY the whole 
table becomes a single group. Every
+  /// expression in HAVING, and in the SELECT list of a query with no GROUP 
BY, must therefore be an aggregation, a
+  /// literal, or functionally dependent on the GROUP BY columns -- the rule 
the multi-stage engine applies through
+  /// Calcite ("Expression 'x' is not being grouped").
+  ///
+  /// A GROUP BY carrying no aggregation anywhere is rejected on top of that 
rule. The engine has no grouping operator
+  /// for such a query: 
[org.apache.pinot.sql.parsers.rewriter.NonAggregationGroupByToDistinctQueryRewriter]
 turns it
+  /// into a DISTINCT, which has no reduce step that can evaluate a HAVING 
filter. Moving the predicate into WHERE
+  /// would be equivalent for a single-valued grouping column, but not for a 
multi-valued one -- GROUP BY builds one
+  /// group per value while WHERE keeps whole rows -- and the rewriter has no 
schema to tell them apart. The
+  /// multi-stage engine does support this shape.
+  private static void validateHavingClause(PinotQuery pinotQuery, boolean 
hasGroupByClause,
+      @Nullable Set<Expression> groupByExprs)
+      throws SqlCompilationException {
+    Expression havingExpression = pinotQuery.getHavingExpression();
+    if (havingExpression == null) {
+      return;
+    }
+    Set<Expression> groupedExprs = hasGroupByClause ? groupByExprs : Set.of();
+    Expression ungrouped = findUngroupedReference(havingExpression, 
groupedExprs);
+    if (ungrouped != null) {
+      throw new SqlCompilationException("'" + 
RequestUtils.prettyPrint(ungrouped) + "' in HAVING clause must "
+          + (hasGroupByClause ? "be inside an aggregate or functionally 
dependent on the columns used in GROUP BY "
+              + "clause." : "be inside an aggregate: with no GROUP BY clause 
the whole table is a single group."));
+    }
+    if (!hasGroupByClause) {
+      for (Expression selectExpression : pinotQuery.getSelectList()) {
+        Expression ungroupedSelect = findUngroupedReference(selectExpression, 
groupedExprs);
+        if (ungroupedSelect != null) {
+          throw new SqlCompilationException("'" + 
RequestUtils.prettyPrint(ungroupedSelect) + "' must be inside an "
+              + "aggregate: with a HAVING clause and no GROUP BY clause the 
whole table is a single group.");
+        }
+      }
+      return;
+    }
+    if (!hasAggregation(pinotQuery)) {
+      throw new SqlCompilationException("HAVING is not supported on a GROUP BY 
query without an aggregation in the "
+          + "single-stage query engine. Move the predicate to the WHERE 
clause, or use the multi-stage query engine.");
+    }
+  }
+
+  /// Returns the first identifier the engine cannot resolve once rows have 
been grouped: one that is neither a
+  /// grouping column nor an argument of an aggregation. Returns `null` when 
every reference is resolvable.
+  ///
+  /// This deliberately differs from [#expressionOutsideGroupByList], which 
accepts an expression as soon as it
+  /// *contains* an aggregation anywhere. That is too permissive for HAVING: 
`HAVING COUNT(*) > amount` contains
+  /// COUNT(*), but `amount` still has no single value per group, and the 
reducer fails on it at run time with a
+  /// message naming the GROUP BY clause the user did not write.
+  @Nullable
+  private static Expression findUngroupedReference(Expression expr, 
Set<Expression> groupByExprs) {
+    if (expr.getType() == ExpressionType.LITERAL || 
groupByExprs.contains(expr)) {
+      return null;
+    }
+    Function function = expr.getFunctionCall();
+    if (function == null) {
+      // An identifier that is not a grouping column.
+      return expr;
+    }
+    if (AggregationFunctionType.isAggregationFunction(function.getOperator())) 
{
+      // Arguments are aggregated away, so they do not have to be grouping 
columns.
+      return null;
+    }
+    if (function.getOperator().equalsIgnoreCase(SqlKind.FILTER.lowerName)) {
+      // A filtered aggregation, COUNT(*) FILTER (WHERE ...). The engine 
resolves it like any other aggregation, and
+      // its predicate is evaluated per row while aggregating, so it may 
reference columns that are not grouped.
+      return null;
+    }
+    List<Expression> operands = function.getOperands();
+    if (operands == null) {
+      return null;
+    }
+    // For an alias only the aliased value matters; the alias itself is not a 
column reference.
+    List<Expression> toCheck = function.getOperator().equals("as") ? 
operands.subList(0, 1) : operands;
+    for (Expression operand : toCheck) {
+      Expression ungrouped = findUngroupedReference(operand, groupByExprs);
+      if (ungrouped != null) {
+        return ungrouped;
+      }
+    }
+    return null;
+  }
+
+  /// Returns `true` if an aggregation appears anywhere the engine would 
compute one: the SELECT list, the HAVING
+  /// clause or the ORDER-BY list.
+  private static boolean hasAggregation(PinotQuery pinotQuery) {
+    for (Expression selectExpression : pinotQuery.getSelectList()) {
+      if (isAggregateExpression(selectExpression)) {
+        return true;
+      }
+    }
+    if (pinotQuery.getHavingExpression() != null && 
isAggregateExpression(pinotQuery.getHavingExpression())) {
+      return true;
+    }
+    if (pinotQuery.getOrderByList() != null) {
+      for (Expression orderByExpression : pinotQuery.getOrderByList()) {
+        if (isAggregateExpression(orderByExpression)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   /// Recursively rejects GROUPING() / GROUPING_ID() calls with more than
   /// [GroupingSets#MAX_GROUPING_FUNCTION_ARGS] arguments (the packed INT bit 
width).
   private static void validateGroupingFunctionArgs(Expression expression) {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/NonAggregationGroupByToDistinctQueryRewriter.java
 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/NonAggregationGroupByToDistinctQueryRewriter.java
index c318341c2ab..6bbb5d576bd 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/NonAggregationGroupByToDistinctQueryRewriter.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/NonAggregationGroupByToDistinctQueryRewriter.java
@@ -55,6 +55,13 @@ public class NonAggregationGroupByToDistinctQueryRewriter 
implements QueryRewrit
     if (pinotQuery.getGroupingSets() != null) {
       return pinotQuery;
     }
+    // DISTINCT has no reduce step that evaluates a HAVING filter, and the 
rewrite drops the GROUP BY list the filter
+    // is validated against, so a query carrying one is left alone. 
CalciteSqlParser.validateHavingClause() then
+    // either accepts it -- the aggregation keeps it on the GROUP BY reduce 
path, which does evaluate HAVING -- or
+    // rejects it, rather than letting the predicate disappear here.
+    if (pinotQuery.getHavingExpression() != null) {
+      return pinotQuery;
+    }
     for (Expression select : pinotQuery.getSelectList()) {
       if (CalciteSqlParser.isAggregateExpression(select)) {
         return pinotQuery;
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
index 96f53690f5d..5c8143975f9 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
@@ -24,6 +24,7 @@ import java.util.List;
 import java.util.Map;
 import org.apache.pinot.common.datatable.DataTable;
 import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.request.context.FilterContext;
 import org.apache.pinot.common.response.broker.BrokerResponseNative;
 import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.common.utils.DataSchema;
@@ -211,10 +212,29 @@ public class AggregationDataTableReducer implements 
DataTableReducer {
   private ResultTable reduceToResultTable(DataSchema dataSchema, Object[] 
finalResults) {
     PostAggregationHandler postAggregationHandler = new 
PostAggregationHandler(_queryContext, dataSchema);
     DataSchema resultDataSchema = postAggregationHandler.getResultDataSchema();
-    Object[] row = postAggregationHandler.getResult(finalResults);
 
-    RewriterResult resultRewriterResult =
-        ResultRewriteUtils.rewriteResult(resultDataSchema, 
List.<Object[]>of(row));
+    // An aggregation without GROUP BY produces a single group covering the 
whole table, and HAVING filters that
+    // group away or keeps it. The predicate is evaluated on the row before 
post-aggregation, the same way
+    // GroupByDataTableReducer does it.
+    //
+    // Null awareness is requested unconditionally rather than from 
requiresNullAwareKeyEvaluation(): that flag only
+    // reports the query's null-handling option, but this reducer materializes 
a null final result whichever way the
+    // option is set, because an aggregation over an empty whole-table group 
has no value to report. The flag only
+    // gates the "a null never matches" early return in PredicateRowMatcher, 
so without it a null result would be
+    // unboxed and throw.
+    List<Object[]> matchedRows;
+    FilterContext havingFilter = _queryContext.getHavingFilter();
+    if (havingFilter != null && !new HavingFilterHandler(havingFilter, 
postAggregationHandler, true).isMatch(
+        finalResults)) {
+      matchedRows = List.of();
+    } else {
+      matchedRows = 
List.<Object[]>of(postAggregationHandler.getResult(finalResults));
+    }
+
+    // The result rewriters may replace the DataSchema 
(ParentAggregationResultRewriter drops the internal parent
+    // columns), so they have to run even when HAVING filtered the single row 
away. Otherwise the same query would
+    // report a different schema depending on its data.
+    RewriterResult resultRewriterResult = 
ResultRewriteUtils.rewriteResult(resultDataSchema, matchedRows);
     resultDataSchema = resultRewriterResult.getDataSchema();
     List<Object[]> rows = resultRewriterResult.getRows();
 
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/HavingQueriesTest.java 
b/pinot-core/src/test/java/org/apache/pinot/queries/HavingQueriesTest.java
new file mode 100644
index 00000000000..81b2f06f03c
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/queries/HavingQueriesTest.java
@@ -0,0 +1,186 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.queries;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.sql.parsers.SqlCompilationException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// End-to-end tests for the HAVING clause in the single-stage engine.
+///
+/// The engine used to evaluate a HAVING filter only when reducing a GROUP BY 
aggregation. Every other shape answered
+/// as if the clause were absent, so these queries returned rows that the 
predicate excludes. Each expectation here
+/// matches what the multi-stage engine produces for the same query.
+public class HavingQueriesTest {
+  private static final TableConfig TABLE_CONFIG =
+      new 
TableConfigBuilder(TableType.OFFLINE).setTableName("testTable").build();
+  // Columns are read positionally in schema order, which sorts "amount" 
before "city".
+  private static final Schema SCHEMA = new 
Schema.SchemaBuilder().setSchemaName("testTable")
+      .addMetric("amount", FieldSpec.DataType.INT, 0)
+      .addSingleValueDimension("city", FieldSpec.DataType.STRING)
+      .build();
+
+  /// Same shape but with a nullable metric, for the null-aware predicate path.
+  private static final Schema NULLABLE_SCHEMA = new 
Schema.SchemaBuilder().setSchemaName("testTable")
+      .setEnableColumnBasedNullHandling(true)
+      .addMetric("amount", FieldSpec.DataType.INT, 0)
+      .addSingleValueDimension("city", FieldSpec.DataType.STRING)
+      .build();
+
+  private File _baseDir;
+
+  @BeforeClass
+  void createBaseDir()
+      throws IOException {
+    _baseDir = Files.createTempDirectory(getClass().getSimpleName()).toFile();
+  }
+
+  @AfterClass
+  void destroyBaseDir()
+      throws IOException {
+    if (_baseDir != null) {
+      FileUtils.deleteDirectory(_baseDir);
+    }
+  }
+
+  /// Each instance holds the same three rows, so the queried table has Athens 
twice over and Madrid twice over:
+  /// Athens has 4 rows summing to 6, Madrid has 2 rows summing to 20.
+  private FluentQueryTest.OnSecondInstance givenTable() {
+    return FluentQueryTest.withBaseDir(_baseDir).givenTable(SCHEMA, 
TABLE_CONFIG)
+        .onFirstInstance(new Object[]{1, "Athens"}, new Object[]{2, "Athens"}, 
new Object[]{10, "Madrid"})
+        .andOnSecondInstance(new Object[]{1, "Athens"}, new Object[]{2, 
"Athens"}, new Object[]{10, "Madrid"});
+  }
+
+  /// An aggregation without GROUP BY is a single group over the whole table. 
When the predicate rejects that group the
+  /// result is empty; the engine used to return the unfiltered aggregate.
+  @Test
+  public void testHavingOnAggregationWithoutGroupBy() {
+    givenTable().whenQuery("SELECT COUNT(*) FROM testTable HAVING COUNT(*) > 
100").thenResultIs(new Object[0][]);
+    givenTable().whenQuery("SELECT COUNT(*) FROM testTable HAVING COUNT(*) > 
1")
+        .thenResultIs(new Object[]{6L});
+    givenTable().whenQuery("SELECT SUM(amount) FROM testTable HAVING 
SUM(amount) > 100").thenResultIs(new Object[0][]);
+    givenTable().whenQuery("SELECT SUM(amount) FROM testTable HAVING 
SUM(amount) > 1")
+        .thenResultIs(new Object[]{26.0});
+    // The predicate may reference an aggregate that is not in the SELECT list.
+    givenTable().whenQuery("SELECT COUNT(*) FROM testTable HAVING SUM(amount) 
> 100").thenResultIs(new Object[0][]);
+  }
+
+  /// A GROUP BY whose SELECT list holds no aggregate is rewritten to a 
DISTINCT, which has no reduce step that can
+  /// evaluate a HAVING filter, so the predicate used to be dropped. An 
aggregate predicate keeps the query on the
+  /// GROUP BY reduce path instead.
+  @Test
+  public void testHavingOnGroupByWithoutAggregateInSelectList() {
+    givenTable().whenQuery("SELECT city FROM testTable GROUP BY city HAVING 
COUNT(*) > 2")
+        .thenResultIs(new Object[]{"Athens"});
+    givenTable().whenQuery("SELECT city FROM testTable GROUP BY city HAVING 
SUM(amount) > 10")
+        .thenResultIs(new Object[]{"Madrid"});
+  }
+
+  /// The shape that always worked must keep working.
+  @Test
+  public void testHavingOnGroupByWithAggregateInSelectList() {
+    givenTable().whenQuery("SELECT city, COUNT(*) FROM testTable GROUP BY city 
HAVING COUNT(*) > 2")
+        .thenResultIs(new Object[]{"Athens", 4L});
+  }
+
+  /// A HAVING clause imposes grouping semantics, so a predicate on a 
non-grouped column has no single value to test
+  /// and a bare SELECT column has no single value to report. The multi-stage 
engine rejects all of these too.
+  @Test
+  public void testInvalidHavingIsRejected() {
+    assertRejected("SELECT city FROM testTable HAVING city > 'B'");
+    assertRejected("SELECT DISTINCT city FROM testTable HAVING city > 'B'");
+    assertRejected("SELECT city FROM testTable HAVING COUNT(*) > 1");
+    assertRejected("SELECT DISTINCT city FROM testTable HAVING COUNT(*) > 1");
+    assertRejected("SELECT COUNT(*) FROM testTable HAVING amount > 1");
+    assertRejected("SELECT city FROM testTable GROUP BY city HAVING amount > 
1");
+    // An aggregate elsewhere in the predicate does not make a bare column 
resolvable: the reducer has no single value
+    // per group for it, and used to fail at run time naming a GROUP BY clause 
the user never wrote.
+    assertRejected("SELECT COUNT(*) FROM testTable HAVING COUNT(*) > amount");
+    assertRejected("SELECT city FROM testTable GROUP BY city HAVING COUNT(*) > 
amount");
+    assertRejected("SELECT city, COUNT(*) FROM testTable GROUP BY city HAVING 
COUNT(*) > amount");
+    // A column inside an aggregate stays legal, and so does a grouping column 
next to one.
+    givenTable().whenQuery("SELECT city, COUNT(*) FROM testTable GROUP BY city 
HAVING SUM(amount) > MIN(amount)")
+        .thenResultIs(new Object[]{"Athens", 4L}, new Object[]{"Madrid", 2L});
+    givenTable().whenQuery("SELECT city, COUNT(*) FROM testTable GROUP BY city 
HAVING COUNT(*) > 2 AND city > 'B'")
+        .thenResultIs(new Object[0][]);
+  }
+
+  /// A GROUP BY with no aggregation anywhere has no grouping operator to 
filter: the query becomes a DISTINCT, whose
+  /// reduce step cannot evaluate HAVING. Folding the predicate into WHERE 
would be equivalent for a single-valued
+  /// grouping column but not for a multi-valued one, and the rewrite happens 
before any schema is available, so the
+  /// shape is rejected rather than answered wrongly.
+  @Test
+  public void testHavingOnGroupByWithoutAnyAggregationIsRejected() {
+    assertRejected("SELECT city FROM testTable GROUP BY city HAVING city > 
'B'");
+    // The SELECT list being a strict subset of the GROUP BY list reaches the 
same reduce path.
+    assertRejected("SELECT city FROM testTable GROUP BY city, amount HAVING 
city > 'B'");
+    // An aggregation anywhere -- SELECT list, HAVING or ORDER BY -- keeps the 
GROUP BY reduce path and is accepted.
+    givenTable().whenQuery("SELECT city, COUNT(*) FROM testTable GROUP BY city 
HAVING city > 'B'")
+        .thenResultIs(new Object[]{"Madrid", 2L});
+  }
+
+  /// The HAVING predicate is matched against the row before post-aggregation, 
so its operands are resolved through
+  /// PostAggregationHandler rather than by position in the SELECT list. These 
shapes break if that mapping is wrong.
+  @Test
+  public void testHavingOperandsAreMappedThroughPostAggregation() {
+    // Post-aggregation expression in the SELECT list shifts the result 
columns away from the aggregate positions.
+    givenTable().whenQuery("SELECT SUM(amount) - COUNT(*) FROM testTable 
HAVING SUM(amount) > 1")
+        .thenResultIs(new Object[]{20.0});
+    givenTable().whenQuery("SELECT SUM(amount) - COUNT(*) FROM testTable 
HAVING SUM(amount) > 100")
+        .thenResultIs(new Object[0][]);
+    // An aggregate used only by the predicate is not in the SELECT list at 
all.
+    givenTable().whenQuery("SELECT COUNT(*) FROM testTable HAVING SUM(amount) 
> 1")
+        .thenResultIs(new Object[]{6L});
+    givenTable().whenQuery("SELECT MIN(amount), MAX(amount) FROM testTable 
HAVING MIN(amount) < MAX(amount)")
+        .thenResultIs(new Object[]{1.0, 10.0});
+  }
+
+  /// With null handling enabled an aggregation over an all-null column 
returns null, and the predicate must treat it
+  /// as non-matching instead of unboxing it. The reduce path materializes 
such a null whichever way the
+  /// null-handling option is set, so both modes are covered.
+  @Test
+  public void testHavingOnNullAggregateResult() {
+    for (boolean nullHandling : new boolean[]{false, true}) {
+      
FluentQueryTest.withBaseDir(_baseDir).withNullHandling(nullHandling).givenTable(NULLABLE_SCHEMA,
 TABLE_CONFIG)
+          .onFirstInstance(new Object[]{null, "Athens"}, new Object[]{null, 
"Madrid"})
+          .whenQuery("SELECT MAX(amount) FROM testTable WHERE city = 'Nowhere' 
HAVING MAX(amount) > 1")
+          .thenResultIs(new Object[0][]);
+    }
+  }
+
+  private void assertRejected(String query) {
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
givenTable().whenQuery(query));
+    assertTrue(e.getMessage().contains("HAVING"), "Unexpected message for '" + 
query + "': " + e.getMessage());
+  }
+}


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

Reply via email to