Skip to content

[CALCITE-6104] Aggregate function that references outer column should be evaluated in outer query#5052

Open
xuzifu666 wants to merge 20 commits into
apache:mainfrom
xuzifu666:calcite-6104
Open

[CALCITE-6104] Aggregate function that references outer column should be evaluated in outer query#5052
xuzifu666 wants to merge 20 commits into
apache:mainfrom
xuzifu666:calcite-6104

Conversation

@xuzifu666

Copy link
Copy Markdown
Member


!ok

# [CALCITE-6104] Aggregate function that references outer column should be evaluated in outer query

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment that these are validated using an independent database.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also have a test with 2 outer aggregates?
Showing the plan could also help.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reminder, I had added comment about it and the test case had been added.
But agg.iq to verify the correctness of the numerical results calculated after the Calcite fix within PostgreSQL, the original SQL must be rewritten into standard SQL supported by PostgreSQL.
Validated these sql in https://onecompiler.com/postgresql/44tgmwq6p and result is as expected.


!ok

SELECT (SELECT sum(sal) FROM dept) AS sum_sal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blog post quoted by @julianhyde has more examples, please add all of the relevant ones

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I review the blog overall and add new case for it. The correspondence is as follows:

SELECT (SELECT sum(1) FROM xx LIMIT 1) FROM aa

Aggregate with no column references aggregates at the innermost level.

SELECT (SELECT sum(a) FROM xx LIMIT 1) FROM aa

Aggregate function that references outer column should be evaluated in outer query.

SELECT (SELECT sum(x) FROM xx LIMIT 1) FROM aa

Aggregate referencing only inner column aggregates at the inner level.

* <p>For example,
* <blockquote><pre>SELECT (SELECT sum(a) FROM t LIMIT 1) FROM aa</pre></blockquote>
* is rewritten to
* <blockquote><pre>SELECT sum(a) * (SELECT 1 FROM t LIMIT 1) FROM aa</pre></blockquote>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From this example it's not obvious to me at all what the general rewrite rule is.
Can you describe the algorithm?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I’ve added the logic for the overall process; I hope that clarifies things.

* </ol>
*
* <p>For example,
* <blockquote><pre>SELECT (SELECT sum(a) FROM t LIMIT 1) FROM aa</pre></blockquote>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot read this example because we don't know where column a is.
Can you also add tests where one or both tables are empty or contain only nulls in the aggregated column?

@xuzifu666 xuzifu666 Jun 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, The Javadoc examples for rewriteOuterAggregatesInSelectList have been improved, explicitly stating that a comes from aa and x comes from t.
Boundary tests have been added to agg.iq: Inner table empty → 1 row NULL
Outer table empty → 1 row NULL (because the outer aggregation has no input rows)
and postgresql test also updated (result is also keep the same with agg.iq, empty results for VALUE are not currently supported, so I am using the SQL statement SELECT 1 FROM emp WHERE 1 = 0 as a substitute.) https://onecompiler.com/postgresql/44tgmwq6p

Comment thread core/src/test/resources/sql/agg.iq Outdated
!ok

# [CALCITE-6104] Aggregate function that references outer column should be evaluated in outer query
# The expected results below have been verified in PostgreSQL by running

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused, I thought these queries are standard SQL, but, indeed, I cannot run them on postgres or other databases I tried. Isn't this PR supposed to implement a standard SQL feature? Why can't these queries use standard SQL then?

@xuzifu666 xuzifu666 Jun 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is indeed a confusing point. These correlated-aggregate queries are not standard SQL, and CALCITE-6104 should not implement a SQL standard feature. Instead, it makes Calcite support a non-standard extension that some databases already implement.

The SQL standard does not allow aggregate functions to reference outer columns
In standard SQL, an aggregate function like sum(a) can only reference columns from the SELECT level where it appears. So a query like:

SELECT (SELECT sum(a) FROM xx LIMIT 1) FROM aa;

will fail on PostgreSQL, Oracle, and MySQL/MariaDB because a belongs to the outer table aa, not the sub-query xx.

But PostgreSQL and others support this extension.
These databases allow an aggregate to "climb up" to the nearest SELECT that contains its free variables. Calcite previously handled this pattern incorrectly (it aggregated inside the sub-query, grouped by the outer rows). The fix makes Calcite behave consistently with those databases.
This test is provided in Jira (julianhyde@3f4cab5) is supported—at least with the current PR applied; these are not standard SQL either.
Additionally, I tested converting these SQL statements to standard SQL; they passed even without this PR, which is why I hold the view mentioned above.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the clarification. Maybe this SQL should not be accepted by default? There are these conformance modes, I think only modes like LENIENT or BABEL should accept this construct.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You say that postgres accepts these queries, then why do you have to modify the queries to run them on postgres? I couldn't run them as written in Postgres.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks for pointing this out. The current comment is incorrect, should state "These SQL queries are non-standard and their successful execution is only guaranteed within Calcite for now" makes perfect sense.

Regarding the limitation where correlated aggregate rewriting is only available under modes like LENIENT or BABEL, could log a separate jira to resolve this later(If ok I would create a new jira)? This way, we can track these changes independently and easily gather any additional feedback. From my perspective, this limitation bears little relevance to the current Jira ticket. I’d like to hear if you agree with my view on this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will require 3 lines of code and should be done as part of this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll implement this restriction directly in this PR.

// Rewrite scalar sub-queries whose single select item is an aggregate
// over outer columns. The aggregate belongs to the outer query per SQL
// standard.
rewriteOuterAggregatesInSelectList(select);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call this handleOuterAggregate, and make the function reject it if not in a suitable conformance mode.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the latest commit implements controls in this area.

Comment thread core/src/test/resources/sql/agg.iq Outdated
!ok

# [CALCITE-6104] Aggregate function that references outer column should be evaluated in outer query
# The expected results below have been verified in PostgreSQL by running

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You say that postgres accepts these queries, then why do you have to modify the queries to run them on postgres? I couldn't run them as written in Postgres.

* <blockquote><pre>
* WITH aa(a) AS (VALUES 1, 2, 3),
* t(x) AS (VALUES 10, 20, 30)
* SELECT (SELECT sum(a) FROM t LIMIT 1) FROM aa

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the LIMIT 1 needed here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not need, I had updated it.

* <blockquote><pre>
* WITH aa(a) AS (VALUES 1, 2, 3),
* t(x) AS (VALUES 10, 20, 30)
* SELECT sum(a) * (SELECT 1 FROM t LIMIT 1) FROM aa

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rewrite will only work for numeric aggregates on types which have a multiplication operation

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this message to the comments first.

@xuzifu666

Copy link
Copy Markdown
Member Author

I have addressed the comments; please take a look when you have a moment to see if the current changes are appropriate~ @mihaibudiu

// 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 (config.conformance().isCorrelatedAggregateAllowed()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is an error reported if the conformance does not allow this?

@xuzifu666 xuzifu666 Jun 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the reminder, I added handling and tests for cases where conformance is not allow, and also implemented custom exception messages for these scenarios. PTAL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please read my review comments carefully. I had already suggested error handling in the previous iteration.
My energy for reviews is limited. This is not the only thing that could have been done in fewer steps.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, and thank you for pointing this out. You are right — I should have caught the error-handling requirement from your earlier comment instead of needing a second reminder.
I will be more careful reading review comments in full going forwar.

@xuzifu666

Copy link
Copy Markdown
Member Author

These days, I’ve reviewed the comments more on this PR and made further simplifications and revisions.

changes:

  1. Added deriveType(subScope, aggCall) + SqlTypeUtil.isNumeric(...) check after operand validation. Non-numeric aggregates (e.g. max(ename) returning VARCHAR) now return null and fall back to old behavior, avoiding the ill-typed max(ename) * (SELECT 1 ...) rewrite that threw "Cannot apply '*' to VARCHAR". Used deriveType (active resolution) instead of getValidatedNodeTypeIfKnown (cache read), since the cache is empty on the DEFAULT check path.

  2. Fixed Javadoc: rewrote to accurately describe the method.

  3. Removed atomic types: replaced AtomicBoolean/AtomicInteger with boolean[] holders in referencesOnlyOuterColumns and containsSubQuery(confirmed that there are no multi-threaded race conditions).

  4. Added assertions in testCorrelatedAggregateConformance verifying max(ename) no longer errors under LENIENT/BABEL.

When you have the time, please take a look and see if it is suitable, very thanks~ @mihaibudiu

@mihaibudiu mihaibudiu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens at runtime for a non-numeric aggregate?
I don't see a quidem test covering this case.

@xuzifu666

xuzifu666 commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

What happens at runtime for a non-numeric aggregate?
I don't see a quidem test covering this case.

Originally the rewrite multiplied the aggregate by the scalar sub-query (agg * (SELECT 1 ... LIMIT 1)), which only type-checks for numeric aggregates — a non-numeric aggregate like MAX() would have thrown "Cannot apply '*' to VARCHAR".
I've changed the rewrite to a type-agnostic form and added a quidem test for it which result is as expected. @mihaibudiu

return SqlStdOperatorTable.MULTIPLY.createCall(
aggCall.getParserPosition(), aggCall, scalarSubQuery);
// Rewrite to CASE WHEN (SELECT 1 FROM ... LIMIT 1) IS NOT NULL THEN <agg> END.
// This is type-agnostic (unlike multiplication, which only works for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why mention here multiplication?
no one cares about prior incorrect implementations

Please, please, review the changes you submit for review before submitting them.

@xuzifu666 xuzifu666 Jul 4, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the unnecessary content in this change. I have removed the whole comment here (this comment doesn't mean much).

continue;
}
// Ensure the sub-query's scope is established before inspecting it.
deriveType(selectScope,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to derive the type?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this deriveType call is only being used for its side effect of establishing the sub-query's scope, and the return value is thrown away which is a misuse. The scope is needed by findCorrelatedAggregate, but the detection of "is this a scalar sub-query wrapping a single aggregate" is purely syntactic and needs no type at all. I had removed this deriveType and move the scope-dependent check to after the sub-query has been validated normally.

/**
* Returns whether an expression contains a sub-query.
*/
private static boolean containsSubQuery(SqlNode node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no other helper function doing this in the Calcite codebase?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's RexUtil.SubQueryFinder, but it operates on the Rex layer (RexSubQuery), whereas here we're still at the SqlNode layer, so it doesn't apply. I didn't find an existing SqlNode-level "contains sub-query" helper. That said, the hand-written switch over every sub-query SqlKind is fragile (easy to miss a new kind) — I had replaced it with the predefined SqlKind.QUERY set (SqlKind.QUERY.contains(call.getKind())) instead of enumerating each case.

// First pass, ensure that aliases are unique. "*" and "TABLE.*" items
// are ignored.

// Rewrite scalar sub-queries whose single select item is an aggregate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be done after validating all arguments?
Then you would not need to call deriveType early.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and this is the root cause behind the first question you asked this time.
The sub-query's SELECT scope is already established during the registration phase (registerSubQueries for the SELECT list), so the early deriveType calls were redundant. I've removed them; getSelectScope(subQuery) works directly.

}

/**
* Returns whether an identifier refers to a scope outside the current select.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure exactly what "outside" means, but given this function definition I think that the javadoc could be "in a scope which is not the supplied one". There's certainly no "current select" in sight.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense; I’ve made the relevant changes to the comment.

* arguments reference only columns from the outer query. Such an aggregate
* belongs to the outer query per the SQL standard.
*
* <p>Returns {@code null} unless the sub-query has exactly one select item,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This raises the question: what happens if the sub-query has two such aggregates, e.g., SUM(a1) + SUM(a2)? Can you write a test case for that so we see what happens?

@xuzifu666 xuzifu666 Jul 8, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this issue, the previous implementation could not correctly handle this scenario.

So I've extended the rewrite to handle this(For the sake of clarity, I have also modified this comment): Instead of requiring the select item to be a single aggregate call, findCorrelatedAggregate now lifts any select item that contains an aggregate and references only outer columns (checked by applying referencesOnlyOuterColumns to the whole item). So sum(a) + sum(b) over outer columns is now lifted as a whole to the outer query.

The mixed case is handled safely: if the expression references any inner column (e.g. sum(a) + sum(x) where x is inner), referencesOnlyOuterColumns returns false and we don't rewrite, keeping the pre-existing behaviour rather than silently producing a wrong result.

I've added test cases for all three: sum(a) + sum(b) (lifted, single row), sum(a) + sum(x) (mixed, not lifted), and an empty-inner-table guard case, plus validator-level conformance tests.

@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also have a test for SUM(a + b)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants