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

xuzifu666 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/main by this push:
     new e3b5c5600f [CALCITE-6104] Aggregate function that references outer 
column should be evaluated in outer query
e3b5c5600f is described below

commit e3b5c5600f2d0d88408953e4a77654457a9b9f3c
Author: Yu Xu <[email protected]>
AuthorDate: Fri Jun 26 10:45:13 2026 +0800

    [CALCITE-6104] Aggregate function that references outer column should be 
evaluated in outer query
---
 .../apache/calcite/runtime/CalciteResource.java    |   3 +
 .../sql/validate/SqlAbstractConformance.java       |   4 +
 .../calcite/sql/validate/SqlConformance.java       |  16 +
 .../calcite/sql/validate/SqlConformanceEnum.java   |  10 +
 .../sql/validate/SqlDelegatingConformance.java     |   4 +
 .../calcite/sql/validate/SqlValidatorImpl.java     | 328 +++++++++++++++++++++
 .../calcite/runtime/CalciteResource.properties     |   1 +
 .../org/apache/calcite/test/SqlValidatorTest.java  |  38 +++
 core/src/test/resources/sql/agg.iq                 | 227 ++++++++++++++
 9 files changed, 631 insertions(+)

diff --git a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java 
b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
index 01ea9cbda0..c6e1a4dbdc 100644
--- a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
+++ b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
@@ -930,6 +930,9 @@ ExInst<CalciteException> 
illegalArgumentForTableFunctionCall(String a0,
   @BaseMessage("Extended columns not allowed under the current SQL conformance 
level")
   ExInst<SqlValidatorException> extendNotAllowed();
 
+  @BaseMessage("Aggregate function referencing outer column is not allowed 
under the current SQL conformance level")
+  ExInst<SqlValidatorException> correlatedAggregateNotAllowed();
+
   @BaseMessage("Rolled up column ''{0}'' is not allowed in {1}")
   ExInst<SqlValidatorException> rolledUpNotAllowed(String column, String 
context);
 
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
index 84cf77e3c9..7734f1b2fe 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
@@ -184,4 +184,8 @@ public abstract class SqlAbstractConformance implements 
SqlConformance {
   @Override public boolean isDistinctOnAllowed() {
     return SqlConformanceEnum.DEFAULT.isDistinctOnAllowed();
   }
+
+  @Override public boolean isCorrelatedAggregateAllowed() {
+    return SqlConformanceEnum.DEFAULT.isCorrelatedAggregateAllowed();
+  }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
index db0dd693a9..703dfd4a14 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
@@ -731,4 +731,20 @@ default boolean isColonFieldAccessAllowed() {
    * false otherwise.
    */
   boolean isDistinctOnAllowed();
+
+  /**
+   * Whether an aggregate function inside a scalar sub-query is allowed to
+   * reference columns from an outer query.
+   *
+   * <p>This is not allowed by the SQL standard, but is supported by some
+   * databases, including SQLite, DuckDB and SQL Server.
+   *
+   * <p>Among the built-in conformance levels, true in
+   * {@link SqlConformanceEnum#BABEL},
+   * {@link SqlConformanceEnum#LENIENT};
+   * false otherwise.
+   */
+  default boolean isCorrelatedAggregateAllowed() {
+    return false;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
index 4ed6cb93c2..681835724f 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
@@ -562,4 +562,14 @@ public enum SqlConformanceEnum implements SqlConformance {
       return false;
     }
   }
+
+  @Override public boolean isCorrelatedAggregateAllowed() {
+    switch (this) {
+    case BABEL:
+    case LENIENT:
+      return true;
+    default:
+      return false;
+    }
+  }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
index e142893891..7bed86a410 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
@@ -189,4 +189,8 @@ protected SqlDelegatingConformance(SqlConformance delegate) 
{
   @Override public boolean isDistinctOnAllowed() {
     return delegate.isDistinctOnAllowed();
   }
+
+  @Override public boolean isCorrelatedAggregateAllowed() {
+    return delegate.isCorrelatedAggregateAllowed();
+  }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
index 331c8849a6..5d667fe502 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
@@ -5618,6 +5618,17 @@ protected RelDataType validateSelectList(final 
SqlNodeList selectItems,
     // First pass, ensure that aliases are unique. "*" and "TABLE.*" items
     // are ignored.
 
+    // Rewrite scalar sub-queries whose single select item is an aggregate
+    // over outer columns. The aggregate belongs to the outer query per SQL
+    // standard, but only if the current conformance allows this non-standard
+    // correlated-aggregate construct. If the conformance does not allow it,
+    // validate that no such construct is present.
+    if (config.conformance().isCorrelatedAggregateAllowed()) {
+      rewriteOuterAggregatesInSelectList(select);
+    } else {
+      checkNoCorrelatedAggregatesInSelectList(select);
+    }
+
     // Validate SELECT list. Expand terms of the form "*" or "TABLE.*".
     final SqlValidatorScope selectScope = getSelectScope(select);
     final List<SqlNode> expandedSelectItems = new ArrayList<>();
@@ -5677,6 +5688,323 @@ protected RelDataType validateSelectList(final 
SqlNodeList selectItems,
     return typeFactory.createStructType(fieldList);
   }
 
+  /**
+   * Rewrites scalar sub-queries in the SELECT list whose single select item is
+   * an aggregate function whose arguments reference only outer columns. Per 
the
+   * SQL standard, such aggregates belong to the outer query.
+   *
+   * <p>The algorithm is:
+   * <ol>
+   * <li>For each item in the SELECT list, check whether it is a scalar
+   * sub-query, optionally wrapped in {@code AS} or {@code WITH}.
+   * <li>Inside the sub-query, require the SELECT list to contain exactly one
+   * item, and that item to be an aggregate function call.
+   * <li>Require every argument of the aggregate to reference only columns from
+   * the outer query (no inner columns), and to contain no nested sub-queries.
+   * <li>If all conditions hold, lift the aggregate out of the sub-query: keep
+   * the aggregate in the outer SELECT list, and guard it with
+   * {@code (SELECT 1 FROM ... LIMIT 1) IS NOT NULL}. The result is equivalent
+   * because the aggregate is evaluated over the outer rows, but yields NULL
+   * whenever the inner query has no rows (as the original scalar sub-query
+   * would).
+   * <li>If the outer query becomes an aggregate query as a result, upgrade its
+   * SELECT clause scope from {@link SelectScope} to
+   * {@link AggregatingSelectScope}.
+   * </ol>
+   *
+   * <p>For example,
+   * <blockquote><pre>
+   * WITH aa(a) AS (VALUES 1, 2, 3),
+   *      t(x) AS (VALUES 10, 20, 30)
+   * SELECT (SELECT sum(a) FROM t) FROM aa
+   * </pre></blockquote>
+   * is rewritten to
+   * <blockquote><pre>
+   * WITH aa(a) AS (VALUES 1, 2, 3),
+   *      t(x) AS (VALUES 10, 20, 30)
+   * SELECT CASE WHEN (SELECT 1 FROM t LIMIT 1) IS NOT NULL
+   *        THEN sum(a) END FROM aa
+   * </pre></blockquote>
+   */
+  private void rewriteOuterAggregatesInSelectList(SqlSelect select) {
+    final SqlNodeList selectItems = select.getSelectList();
+    if (selectItems == null) {
+      return;
+    }
+    final SqlValidatorScope selectScope = getSelectScope(select);
+    final boolean wasAggregate = isAggregate(select);
+    for (int i = 0; i < selectItems.size(); i++) {
+      final SqlNode selectItem = selectItems.get(i);
+      final SqlNode rewrittenItem =
+          rewriteOuterAggregateItem(selectScope, selectItem);
+      if (rewrittenItem != selectItem) {
+        selectItems.set(i, rewrittenItem);
+      }
+    }
+    // The rewrite may have introduced an aggregate into a query that was not
+    // previously aggregate. Update the SELECT clause scope accordingly so that
+    // subsequent validation and conversion see an AggregatingSelectScope.
+    if (!wasAggregate && isAggregate(select)) {
+      SqlValidatorScope scope =
+          clauseScopes.get(IdPair.of(select, Clause.SELECT));
+      if (scope instanceof AggregatingSelectScope) {
+        scope = ((AggregatingSelectScope) scope).getParent();
+      }
+      clauseScopes.put(IdPair.of(select, Clause.SELECT),
+          new AggregatingSelectScope(
+              requireNonNull(scope, "scope"), select, false));
+    }
+  }
+
+  /**
+   * Validates that the SELECT list does not contain a correlated aggregate
+   * when the current conformance does not allow it.
+   */
+  private void checkNoCorrelatedAggregatesInSelectList(SqlSelect select) {
+    final SqlNodeList selectItems = select.getSelectList();
+    if (selectItems == null) {
+      return;
+    }
+    final SqlValidatorScope selectScope = getSelectScope(select);
+    for (SqlNode selectItem : selectItems) {
+      final SqlSelect subQuery = findScalarSubQuerySelect(selectItem);
+      if (subQuery == null) {
+        continue;
+      }
+      final SqlNode aggExpr = findCorrelatedAggregate(subQuery, selectScope);
+      if (aggExpr != null) {
+        throw newValidationError(aggExpr,
+            RESOURCE.correlatedAggregateNotAllowed());
+      }
+    }
+  }
+
+  /**
+   * Rewrites a single SELECT list item if it is a scalar sub-query containing
+   * an aggregate over outer columns.
+   *
+   * @return the rewritten expression, or {@code selectItem} if no rewrite 
applies
+   */
+  private SqlNode rewriteOuterAggregateItem(SqlValidatorScope parentScope,
+      SqlNode selectItem) {
+    final SqlSelect subQuery = findScalarSubQuerySelect(selectItem);
+    if (subQuery == null) {
+      return selectItem;
+    }
+    // Unwrap AS.
+    SqlNode expr = selectItem;
+    @Nullable SqlIdentifier alias = null;
+    if (SqlUtil.isCallTo(selectItem, SqlStdOperatorTable.AS)) {
+      SqlCall asCall = (SqlCall) selectItem;
+      expr = asCall.operand(0);
+      alias = asCall.operand(1);
+    }
+    final SqlBasicCall scalarSubQuery = (SqlBasicCall) expr;
+    final SqlNode query = scalarSubQuery.operand(0);
+    final SqlNode rewrittenSubQuery =
+        rewriteOuterAggregate(query, subQuery, parentScope);
+    if (rewrittenSubQuery == query) {
+      return selectItem;
+    }
+    SqlNode result = rewrittenSubQuery;
+    if (alias != null) {
+      result =
+          SqlStdOperatorTable.AS.createCall(selectItem.getParserPosition(),
+              rewrittenSubQuery, alias);
+    }
+    return result;
+  }
+
+  /**
+   * Extracts the {@link SqlSelect} from a SELECT list item that is a scalar
+   * sub-query, optionally wrapped in {@code AS}.
+   *
+   * @return the sub-query's SELECT, or {@code null} if the item is not a
+   * scalar sub-query
+   */
+  private @Nullable SqlSelect findScalarSubQuerySelect(SqlNode selectItem) {
+    SqlNode expr = selectItem;
+    if (SqlUtil.isCallTo(selectItem, SqlStdOperatorTable.AS)) {
+      expr = ((SqlCall) selectItem).operand(0);
+    }
+    if (!SqlUtil.isCallTo(expr, SqlStdOperatorTable.SCALAR_QUERY)) {
+      return null;
+    }
+    final SqlNode query = ((SqlBasicCall) expr).operand(0);
+    return query instanceof SqlWith
+        ? (SqlSelect) ((SqlWith) query).body
+        : query instanceof SqlSelect
+            ? (SqlSelect) query
+            : null;
+  }
+
+  /**
+   * Finds the single select item of a scalar sub-query that can be lifted to
+   * the outer query. Such an item contains at least one aggregate function and
+   * references only columns from the outer query, so per the SQL standard it
+   * belongs to the outer query.
+   *
+   * <p>The item may be a bare aggregate call (e.g. {@code sum(a)}) or an
+   * expression over aggregates of outer columns (e.g. {@code sum(a) + 
sum(b)}).
+   * It is not lifted if any column reference resolves to an inner column, 
which
+   * covers mixed cases such as {@code sum(a) + sum(x)} where {@code x} is an
+   * inner column.
+   *
+   * @return the liftable select item, or {@code null} if none applies
+   */
+  private @Nullable SqlNode findCorrelatedAggregate(SqlSelect subQuery,
+      SqlValidatorScope parentScope) {
+    final SqlNodeList subSelectItems = 
SqlNonNullableAccessors.getSelectList(subQuery);
+    if (subSelectItems.size() != 1) {
+      return null;
+    }
+    if (subQuery.getGroup() != null && !subQuery.getGroup().isEmpty()) {
+      return null;
+    }
+    SqlNode subSelectItem = subSelectItems.get(0);
+    if (SqlUtil.isCallTo(subSelectItem, SqlStdOperatorTable.AS)) {
+      subSelectItem = ((SqlCall) subSelectItem).operand(0);
+    }
+    if (aggFinder.findAgg(subSelectItem) == null) {
+      return null;
+    }
+    final SqlValidatorScope subScope = getSelectScope(subQuery);
+    final SqlValidatorScope operandScope =
+        subScope instanceof AggregatingSelectScope
+            ? ((AggregatingSelectScope) subScope).parent
+            : subScope;
+    if (!referencesOnlyOuterColumns(subSelectItem, parentScope, operandScope)) 
{
+      return null;
+    }
+    return subSelectItem;
+  }
+
+  /**
+   * Rewrites a scalar sub-query whose single select item is an aggregate
+   * function whose arguments reference only outer columns. The aggregate is
+   * pulled out to the enclosing query, and the sub-query's select item is
+   * replaced with the constant 1.
+   *
+   * @return the rewritten sub-query expression, or {@code subQuery} if no 
rewrite applies
+   */
+  private SqlNode rewriteOuterAggregate(SqlNode originalQuery,
+      SqlSelect subQuery, SqlValidatorScope parentScope) {
+    final SqlNode aggExpr = findCorrelatedAggregate(subQuery, parentScope);
+    if (aggExpr == null) {
+      return originalQuery;
+    }
+    final SqlNodeList subSelectItems = 
SqlNonNullableAccessors.getSelectList(subQuery);
+    // Rewrite: replace aggregate with 1 in a new sub-query, and multiply
+    // by the aggregate in the outer query.
+    final SqlLiteral one = SqlLiteral.createExactNumeric("1", 
SqlParserPos.ZERO);
+    final SqlNodeList newSubSelectItems =
+        new SqlNodeList(ImmutableList.of(one), 
subSelectItems.getParserPosition());
+    final SqlNodeList keywordList = (SqlNodeList) 
subQuery.getOperandList().get(0);
+    final SqlSelect newSubQuery =
+        new SqlSelect(subQuery.getParserPosition(),
+        keywordList,
+        newSubSelectItems,
+        subQuery.getFrom(),
+        subQuery.getWhere(),
+        subQuery.getGroup(),
+        subQuery.getHaving(),
+        subQuery.getWindowList(),
+        subQuery.getQualify(),
+        subQuery.getOrderList(),
+        subQuery.getOffset(),
+        subQuery.getFetch(),
+        subQuery.getHints(),
+        subQuery.getDistinctOn());
+    // Ensure the rewritten sub-query still returns at most one row, because
+    // removing the aggregate may otherwise produce multiple rows.
+    if (newSubQuery.getFetch() == null) {
+      newSubQuery.setFetch(
+          SqlLiteral.createExactNumeric("1", SqlParserPos.ZERO));
+    }
+    final SqlNode newQuery = originalQuery instanceof SqlWith
+        ? new SqlWith(originalQuery.getParserPosition(),
+            ((SqlWith) originalQuery).withList, newSubQuery)
+        : newSubQuery;
+    registerQuery(parentScope, null, newQuery, newQuery, null, false);
+    validateQuery(newQuery, parentScope, unknownType);
+    final SqlNode scalarSubQuery =
+        
SqlStdOperatorTable.SCALAR_QUERY.createCall(newQuery.getParserPosition(), 
newQuery);
+
+    final SqlParserPos pos = aggExpr.getParserPosition();
+    final SqlNode condition =
+        SqlStdOperatorTable.IS_NOT_NULL.createCall(pos, scalarSubQuery);
+    return new SqlCase(pos, null,
+        new SqlNodeList(ImmutableList.of(condition), pos),
+        new SqlNodeList(ImmutableList.of(aggExpr), pos),
+        SqlLiteral.createNull(pos));
+  }
+
+  /**
+   * Returns whether an expression contains only references to columns that are
+   * outside the current select scope, and can be resolved in the parent scope.
+   */
+  private boolean referencesOnlyOuterColumns(SqlNode node,
+      SqlValidatorScope parentScope, SqlValidatorScope currentScope) {
+    // Do not rewrite if the aggregate argument contains a sub-query; the
+    // sub-query may reference inner columns, and pulling the aggregate out
+    // would change the semantics.
+    if (containsSubQuery(node)) {
+      return false;
+    }
+    // ok[0] is cleared if any identifier is not an outer reference;
+    // ok[1] is set once at least one outer column is found.
+    final boolean[] ok = {true, false};
+    node.accept(new SqlBasicVisitor<Void>() {
+      @Override public Void visit(SqlIdentifier id) {
+        if (!isOuterReference(currentScope, id)) {
+          ok[0] = false;
+          return null;
+        }
+        // The identifier must be resolvable in the parent scope; otherwise it
+        // references an intermediate scope, not the immediate enclosing query.
+        try {
+          parentScope.fullyQualify(id);
+          ok[1] = true;
+        } catch (CalciteException e) {
+          ok[0] = false;
+        }
+        return null;
+      }
+    });
+    return ok[0] && ok[1];
+  }
+
+  /**
+   * Returns whether an expression contains a sub-query.
+   */
+  private static boolean containsSubQuery(SqlNode node) {
+    final boolean[] found = {false};
+    node.accept(new SqlBasicVisitor<Void>() {
+      @Override public Void visit(SqlCall call) {
+        if (call.getKind().belongsTo(SqlKind.QUERY)) {
+          found[0] = true;
+          return null;
+        }
+        return super.visit(call);
+      }
+    });
+    return found[0];
+  }
+
+  /**
+   * Returns whether an identifier resolves to a scope which is not the
+   * supplied one.
+   */
+  private boolean isOuterReference(SqlValidatorScope scope, SqlIdentifier id) {
+    final SqlQualified fqId = scope.fullyQualify(id);
+    if (fqId.prefixLength <= 0) {
+      return false;
+    }
+    final SqlValidatorScope.ResolvedImpl resolved = new 
SqlValidatorScope.ResolvedImpl();
+    scope.resolve(fqId.prefix(), catalogReader.nameMatcher(), false, resolved);
+    return resolved.count() == 1 && !resolved.only().scope.isWithin(scope);
+  }
+
   /**
    * Validates an expression.
    *
diff --git 
a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties 
b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
index 27e52cbce9..f4f16d7326 100644
--- 
a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
+++ 
b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
@@ -303,6 +303,7 @@ IntervalMustBeNonNegative=Interval must be non-negative 
''{0}''
 CannotUseWithinWithoutOrderBy=Must contain an ORDER BY clause when WITHIN is 
used
 FirstColumnOfOrderByMustBeTimestamp=First column of ORDER BY must be of type 
TIMESTAMP
 ExtendNotAllowed=Extended columns not allowed under the current SQL 
conformance level
+CorrelatedAggregateNotAllowed=Aggregate function referencing outer column is 
not allowed under the current SQL conformance level
 RolledUpNotAllowed=Rolled up column ''{0}'' is not allowed in {1}
 SchemaExists=Schema ''{0}'' already exists
 SchemaInvalidType=Invalid schema type ''{0}''; valid values: {1}
diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
index efa86f9c0b..228d90026b 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -8469,6 +8469,44 @@ void testGroupExpressionEquivalenceParams() {
         .columnType("INTEGER NOT NULL");
   }
 
+  @Test void testCorrelatedAggregateConformance() {
+    final String sql = "select (select ^sum(sal)^ from dept) from emp";
+    // LENIENT and BABEL allow the non-standard correlated-aggregate construct.
+    sql(sql).withConformance(SqlConformanceEnum.LENIENT).ok();
+    sql(sql).withConformance(SqlConformanceEnum.BABEL).ok();
+    // DEFAULT and STRICT_2003 do not.
+    final String err =
+        "Aggregate function referencing outer column is not allowed under the 
current SQL conformance level";
+    sql(sql).withConformance(SqlConformanceEnum.DEFAULT).fails(err);
+    sql(sql).withConformance(SqlConformanceEnum.STRICT_2003).fails(err);
+
+    // A non-numeric aggregate over an outer column (MAX of a VARCHAR) is also
+    // lifted to the outer query; the CASE-based rewrite is type-agnostic.
+    final String nonNumeric =
+        "select (select max(ename) from dept) from emp";
+    sql(nonNumeric).withConformance(SqlConformanceEnum.LENIENT).ok();
+    sql(nonNumeric).withConformance(SqlConformanceEnum.BABEL).ok();
+
+    // An expression over aggregates of outer columns is lifted to the outer
+    // query as a whole, and is subject to the same conformance rules as a bare
+    // aggregate.
+    final String twoAggs =
+        "select (select ^sum(sal) + sum(comm)^ from dept) from emp";
+    sql(twoAggs).withConformance(SqlConformanceEnum.LENIENT).ok();
+    sql(twoAggs).withConformance(SqlConformanceEnum.BABEL).ok();
+    sql(twoAggs).withConformance(SqlConformanceEnum.DEFAULT).fails(err);
+    sql(twoAggs).withConformance(SqlConformanceEnum.STRICT_2003).fails(err);
+
+    // Mixed case: an expression aggregating both an outer column (sal) and an
+    // inner column (deptno) references an inner column, so it is not lifted 
and
+    // is not rejected under any conformance level.
+    final String mixed =
+        "select (select sum(sal) + sum(deptno) from dept) from emp";
+    sql(mixed).withConformance(SqlConformanceEnum.LENIENT).ok();
+    sql(mixed).withConformance(SqlConformanceEnum.DEFAULT).ok();
+    sql(mixed).withConformance(SqlConformanceEnum.STRICT_2003).ok();
+  }
+
   @Test void testAggregateInOrderByFails() {
     sql("select empno from emp order by ^sum(empno)^")
         .fails(ERR_AGG_IN_ORDER_BY);
diff --git a/core/src/test/resources/sql/agg.iq 
b/core/src/test/resources/sql/agg.iq
index 5478f6bb6e..b637a6cbc1 100644
--- a/core/src/test/resources/sql/agg.iq
+++ b/core/src/test/resources/sql/agg.iq
@@ -4301,4 +4301,231 @@ GROUP BY GROUPING SETS ((deptno), ());
 
 !ok
 
+# [CALCITE-6104] Aggregate function that references outer column should be 
evaluated in outer query
+# Correlated aggregates are a non-standard extension.
+# They are rejected under DEFAULT conformance.
+!use scott
+
+SELECT (SELECT sum(sal) FROM dept) FROM emp;
+Aggregate function referencing outer column is not allowed under the current 
SQL conformance level
+!error
+
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT count(a) FROM xx LIMIT 1) AS ca
+FROM aa;
+Aggregate function referencing outer column is not allowed under the current 
SQL conformance level
+!error
+
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) FROM xx LIMIT 1) AS sa,
+  (SELECT count(a) FROM xx LIMIT 1) AS ca
+FROM aa;
+Aggregate function referencing outer column is not allowed under the current 
SQL conformance level
+!error
+
+# Allowed under LENIENT conformance.
+!use scott-lenient
+
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) FROM xx LIMIT 1) AS sa
+FROM aa;
++----+
+| SA |
++----+
+|  6 |
++----+
+(1 row)
+
+!ok
+
+# A non-numeric aggregate (MAX over a VARCHAR outer column) is also lifted to
+# the outer query. The CASE-based rewrite is type-agnostic, so the aggregate is
+# evaluated over the outer rows and produces a single row.
+SELECT (SELECT max(ename) FROM dept) AS m FROM emp;
++------+
+| M    |
++------+
+| WARD |
++------+
+(1 row)
+
+!ok
+
+# Inner table is empty: the scalar sub-query contributes NULL
+WITH aa (a) AS (VALUES 1, 2, 3),
+  empty_xx (x) AS (SELECT 1 FROM emp WHERE 1 = 0)
+SELECT (SELECT sum(a) FROM empty_xx LIMIT 1) AS sa
+FROM aa;
++----+
+| SA |
++----+
+|    |
++----+
+(1 row)
+
+!ok
+
+# Outer table is empty: the outer aggregate has no input rows
+WITH empty_aa (a) AS (SELECT 1 FROM emp WHERE 1 = 0),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) FROM xx LIMIT 1) AS sa
+FROM empty_aa;
++----+
+| SA |
++----+
+|    |
++----+
+(1 row)
+
+!ok
+
+# Aggregated column contains a mix of values and NULLs
+WITH aa (a) AS (VALUES 1, NULL, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) FROM xx LIMIT 1) AS sa
+FROM aa;
++----+
+| SA |
++----+
+|  4 |
++----+
+(1 row)
+
+!ok
+
+# Aggregate with no column references aggregates at the innermost level
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(1) FROM xx LIMIT 1) AS s
+FROM aa;
++---+
+| S |
++---+
+| 3 |
+| 3 |
+| 3 |
++---+
+(3 rows)
+
+!ok
+
+# Aggregate referencing only inner column aggregates at the inner level
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(x) FROM xx LIMIT 1) AS s
+FROM aa;
++----+
+| S  |
++----+
+| 60 |
+| 60 |
+| 60 |
++----+
+(3 rows)
+
+!ok
+
+# An expression over aggregates of different outer columns is lifted to the
+# outer query as a whole: sum(a) = 1+2+3 = 6, sum(b) = 10+20+30 = 60, so the
+# result is a single row 66.
+WITH aa (a, b) AS (VALUES (1, 10), (2, 20), (3, 30)),
+  xx (x) AS (VALUES 100, 200, 300)
+SELECT (SELECT sum(a) + sum(b) FROM xx LIMIT 1) AS s
+FROM aa;
++----+
+| S  |
++----+
+| 66 |
++----+
+(1 row)
+
+!ok
+
+# A single aggregate over an arithmetic expression of outer columns is also
+# lifted: sum(a + b) = (1+10) + (2+20) + (3+30) = 66.
+WITH aa (a, b) AS (VALUES (1, 10), (2, 20), (3, 30)),
+  xx (x) AS (VALUES 100, 200, 300)
+SELECT (SELECT sum(a + b) FROM xx LIMIT 1) AS s
+FROM aa;
++----+
+| S  |
++----+
+| 66 |
++----+
+(1 row)
+
+!ok
+
+# Lifted expression with an empty inner table: the guard sub-query yields no
+# row, so the whole expression is NULL.
+WITH aa (a, b) AS (VALUES (1, 10), (2, 20), (3, 30)),
+  empty_xx (x) AS (SELECT 1 FROM emp WHERE 1 = 0)
+SELECT (SELECT sum(a) + sum(b) FROM empty_xx LIMIT 1) AS s
+FROM aa;
++---+
+| S |
++---+
+|   |
++---+
+(1 row)
+
+!ok
+
+# Mixed case: the expression aggregates an outer column (a) and an inner
+# column (x). It references an inner column, so it is not lifted and keeps the
+# pre-existing correlated-sub-query behaviour (evaluated once per outer row).
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) + sum(x) FROM xx LIMIT 1) AS s
+FROM aa;
++----+
+| S  |
++----+
+| 63 |
+| 66 |
+| 69 |
++----+
+(3 rows)
+
+!ok
+
+# Two scalar sub-queries with outer-only aggregates are both lifted to the 
outer query
+WITH aa (a) AS (VALUES 1, 2, 3),
+  xx (x) AS (VALUES 10, 20, 30)
+SELECT (SELECT sum(a) FROM xx LIMIT 1) AS sa,
+  (SELECT count(a) FROM xx LIMIT 1) AS ca
+FROM aa;
++----+----+
+| SA | CA |
++----+----+
+|  6 |  3 |
++----+----+
+(1 row)
+
+!ok
+
+EnumerableCalc(expr#0..2=[{inputs}], expr#3=[IS NOT NULL($t2)], 
expr#4=[null:INTEGER], expr#5=[CASE($t3, $t0, $t4)], expr#6=[null:BIGINT], 
expr#7=[CASE($t3, $t1, $t6)], SA=[$t5], CA=[$t7])
+  EnumerableNestedLoopJoin(condition=[true], joinType=[left])
+    EnumerableAggregate(group=[{}], agg#0=[SUM($0)], agg#1=[COUNT()])
+      EnumerableValues(tuples=[[{ 1 }, { 2 }, { 3 }]])
+    EnumerableLimit(fetch=[1])
+      EnumerableValues(tuples=[[{ 1 }, { 1 }, { 1 }]])
+!plan
+
+SELECT (SELECT sum(sal) FROM dept) AS sum_sal
+FROM emp;
++----------+
+| SUM_SAL  |
++----------+
+| 29025.00 |
++----------+
+(1 row)
+
+!ok
+
+!use scott
+
 # End agg.iq

Reply via email to