Repository navigation
feat(sql-parser): support two-layer nested aggregation queries - #892
Conversation
Add SQLPatternMatcher::nested_query_to_pattern, which accepts an outer SUM/COUNT/AVG/MIN/MAX/QUANTILE over the inner aggregate's alias, grouped by a subset of the inner GROUP BY, and returns the inner layer's pattern. query_info_to_pattern still rejects nested queries. The parser now rejects clauses on either layer it cannot represent (outer WHERE, HAVING, inner ORDER BY / LIMIT) instead of dropping them. Part of #819. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ime aggregation A nested SQL query is planned through its inner layer, like any single-level query, and its outer layer is emitted as a query-time aggregation stage, mirroring PromQL's nested_aggregation_plan. Part of #819. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation A nested SQL query is executed through its inner layer, using the nested query's config, and the config's query-time aggregations are applied to the result as its outer layer. A nested query whose config has no query-time aggregations is not served, since running the inner layer alone would drop the outer aggregation. The 8 non-topk one-stage nested-aggregations cases and the two nested cases in basic.yaml are now supported. Closes #819. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
milindsrivastava1997
left a comment
There was a problem hiding this comment.
Code review (unverified findings, so please double-check).
Bug outside the diff, sqlpattern_parser.rs:163: a CTE can be bound to the wrong table. cte_to_subquery always uses cte_tables[0] and overwrites the outer FROM whatever name it references. Every CTE query becomes nested, so this was unreachable until this PR started serving nested queries. WITH a AS (... SUM ...), b AS (... MAX ...) SELECT job, SUM(v) FROM b GROUP BY job is now served using a, which silently gives the wrong result.
Inline comments cover the rest. The first three (the CTE planning failure, the dropped outer joins, and this one) should block merge.
| } | ||
|
|
||
| /// The SQL text of the subquery in the outer layer's FROM. | ||
| fn inner_query_sql(stmts: &[Statement]) -> Option<String> { |
There was a problem hiding this comment.
Bug: a CTE-form nested query fails planning for the whole workload. SQLPatternParser rewrites WITH t AS (...) SELECT ... FROM t into a FROM-subquery, so qdata.subquery is Some and the query matches as nested. But inner_query_sql reads the raw, un-rewritten statements, where FROM is a plain table name, so it returns SqlParse("nested query has no subquery in FROM"). generate_sql_plan propagates that with ? (generator.rs:99), so every query in the workload fails planning.
Repro: WITH t AS (SELECT job, instance, SUM(value) AS v FROM data WHERE time BETWEEN ... GROUP BY job, instance) SELECT job, SUM(v) FROM t GROUP BY job
| } | ||
|
|
||
| // Parse subquery | ||
| let subquery = match &select.from[0].relation { |
There was a problem hiding this comment.
Bug: joins on the outer query are silently dropped. The nested branch only looks at select.from[0].relation and never rejects select.from[0].joins or additional FROM entries. SELECT job, SUM(v) FROM (<inner>) t JOIN other o ON t.job = o.job GROUP BY job used to be rejected with NestedQueryUnsupported; now it's served with the join ignored, which gives a wrong but valid-looking answer. This contradicts the PR's claim that unrepresentable clauses are rejected on both layers.
| } | ||
|
|
||
| /// SELECT clauses that SQLQueryData cannot represent. | ||
| fn has_unsupported_clauses(select: &Select) -> bool { |
There was a problem hiding this comment.
Bug: QUALIFY is silently dropped. has_unsupported_clauses doesn't check select.qualify. Now that this helper guards both layers, ... GROUP BY job, instance QUALIFY v > 10 in the inner query is served with the filter ignored, so the outer aggregate includes rows that should have been filtered out.
| ); | ||
| return None; | ||
| } | ||
| let inner = query_data.subquery.as_deref()?; |
There was a problem hiding this comment.
Risk: the planner's inner query and the engine's inner query are never reconciled. The engine rebuilds the inner layer from the incoming query_data and ignores config.planned_subquery. The planner sizes aggregations from the ClickHouse-dialect inner SQL (re-serialized via to_string), while the engine re-parses with GenericDialect. If the two differ in round-tripping or dialect (e.g. parametric quantile(0.95)(v)), the config's aggregation_keys get applied to a differently shaped inner query with no error. Consider checking the two against each other, or using planned_subquery directly.
There was a problem hiding this comment.
Leaving this as is. The engine doesn't look up the config by planned_subquery: find_query_config_sql matches the incoming query against config.query, the original nested text, and matches_sql_pattern compares both layers structurally (metric, aggregation, labels, time range). So the config's aggregation keys only ever serve an inner layer that has the same shape as the one the planner planned. planned_subquery is informational, as it is for flat queries. I also checked the round trip that planned_subquery goes through (ClickHouse dialect, then to_string, then planning) for parametric quantile(0.95)(value), half-open ranges and CAST(... AS DateTime). Each one re-serializes with the same meaning.
| (output_labels, result) | ||
| } else { | ||
| let QueryResult::Vector(InstantVector { values, timestamp }) = result else { | ||
| error!("SQL query-time aggregations need an instant vector"); |
There was a problem hiding this comment.
Bug: a global outer aggregate over an empty inner result returns zero rows. SQL requires exactly one row here (0 for SUM/COUNT, NULL for the others). SELECT SUM(v) FROM (<inner> GROUP BY job, instance) over an empty window: ClickHouse returns 0, but apply_instant_pipeline with All grouping over no elements returns nothing, so the results won't match in the equivalence tests.
There was a problem hiding this comment.
This isn't new behavior. An empty window has no local data, so execute_context returns None and the query falls through to the fallback, which returns the SQL-correct single row. Only a window that exists but holds zero rows comes back empty, and the flat global aggregates from #826 behave the same way in that case. So this would be a shared fix for flat and nested queries, outside this PR.
| } | ||
|
|
||
| /// Aggregations an outer layer can apply to its inner layer's rows. | ||
| const QUERY_TIME_AGGREGATIONS: [&str; 6] = ["SUM", "COUNT", "AVG", "MIN", "MAX", "QUANTILE"]; |
There was a problem hiding this comment.
Cleanup: the allowed outer aggregations are listed twice. They're in QUERY_TIME_AGGREGATIONS here and again in the name-to-operator match in planner/sql.rs (sql_query_time_aggregation). If someone adds an operator to one list but not the other, the matcher accepts queries the planner then rejects, or the reverse. A single name-to-operator mapping in asap_types would remove the duplicate.
| query_evaluation_time: f64, | ||
| ) -> Result<Option<NestedAggregationPlan>, ControllerError> { | ||
| let schema = build_sql_schema(&self.table_definitions); | ||
| let (stmts, qdata) = |
There was a problem hiding this comment.
Cleanup: queries get parsed twice. nested_aggregation_plan builds the schema and fully parses every query, then get_streaming_aggregation_configs builds the schema and parses the same query again for every single-level query. Returning the parsed qdata/stmts from one call, or deciding nested vs. single-level in one place, would avoid the repeated parse and schema clone.
There was a problem hiding this comment.
Leaving this as is. The extra parse only happens in the offline planner, and costs a few microseconds per query. Sharing the parsed qdata would mean changing get_streaming_aggregation_configs to take pre-parsed data, which isn't worth it for that cost.
| ) -> Result<QueryTimeAggregation, ControllerError> { | ||
| let info = &outer.aggregation_info; | ||
| let operator = match info.get_name() { | ||
| "SUM" => QueryTimeAggregationOperator::Sum, |
There was a problem hiding this comment.
Cleanup: this duplicates promql.rs::aggregation_stage. sql_query_time_aggregation re-implements the operator mapping, the All/By grouping construction and the quantile-parameter handling. The two copies can drift apart; for example, PromQL checks the phi range inline while SQL relies on validate(). A shared helper taking (operator name, labels, param) would serve both.
| // Query-level clauses of the inner layer have no place | ||
| // in its SQLQueryData, so they would be dropped. | ||
| if subquery.with.is_some() | ||
| || subquery.order_by.is_some() |
There was a problem hiding this comment.
Nit: misleading error message. A WITH inside the subquery is rejected with "ORDER BY / LIMIT inside a subquery is not supported", which names a clause the user's query doesn't have and makes the rejection hard to debug. Give the WITH case its own message.
cte_to_subquery bound FROM to the first CTE whatever name it referenced, and replaced FROM wholesale, dropping its joins. Every CTE query was nested and so rejected until nested queries became supported; reject WITH outright instead. Joins, extra FROM entries, QUALIFY, CONNECT BY and value-table modes are now rejected on every layer, flat queries included, rather than silently ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add QueryTimeAggregationOperator::from_name and use it in both the PromQL and SQL planners. A test checks that every outer aggregation the SQL matcher accepts maps to an operator, since sql_utilities cannot depend on asap_types to share the list itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Supports two-layer SQL queries: an aggregation over a subquery that aggregates the table.
This uses the same approach as PromQL's nested aggregations. The inner layer is planned and served like any single-level query, and the outer layer runs as a query-time aggregation over its rows.
SQLPatternMatcher::nested_query_to_pattern, accepts an outerSUM/COUNT/AVG/MIN/MAX/QUANTILEover the inner aggregate's alias. The outerGROUP BYmust be a subset of the innerGROUP BY; with no outerGROUP BY, all rows are aggregated.query_info_to_patternstill rejects nested queries. The parser now rejects clauses it can't represent instead of dropping them: an outerWHERE,HAVING, and innerORDER BY/LIMIT.FROMentries,QUALIFY,CONNECT BYand value-table modes are rejected rather than ignored. CTEs (WITH) are now rejected outright. The old CTE-to-subquery rewrite boundFROMto the first CTE whatever name it referenced, and dropped its joins. Every CTE query used to come out nested, and nested queries were rejected, so no query that was supported before is lost.planned_subqueryand planned through the existing single-level path. The outer layer is emitted as onequery_time_aggregationsstage. The newQueryTimeAggregationOperator::from_nameholds the name-to-operator mapping, and the PromQL and SQL planners both use it.apply_instant_pipeline. If the config has no query-time aggregations, the nested query is not served. Serving it would execute only the inner layer and silently drop the outer aggregation.Scope is two layers only, with no top-k. Three or more layers, #818, #820 and #821 are unchanged. Outer
ORDER BY/LIMITis rejected and tracked in #891. The--planner milppath still rejects nested queries.SQL query support suite
cargo test -p query_engine_rust --test sql_query_support: 105 → 115 of 228 cases supported. All 228 cases match their expected outcome.nested-aggregationsbasicThe new supported cases are the 8 non-top-k
one-stage-*cases innested-aggregationsand the 2 nested cases inbasic(nested_sum_of_sum,nested_avg_of_quantile).Tests
sql_utilities: the nested matcher accepts each allowed outer aggregation and rejects each unsupported shape. The parser rejects each clause it can't represent, on both layers and on flat queries, and rejects CTEs.asap_types: every outer aggregation the SQL matcher accepts maps to an operator.planner: stage operator, grouping and quantile parameter per aggregation. The no-GROUP BYcase usesallgrouping. A nested query's streaming plan equals its inner layer's.query-engine: executes against stored sums and checks the result values for outerSUM,QUANTILEand no-GROUP BYSUM. A config without stages is not served.Closes #819.
🤖 Generated with Claude Code