From d55a37791422e3e8e75afcc5eb47b00344a6e3ff Mon Sep 17 00:00:00 2001 From: zz_y Date: Fri, 19 Jun 2026 11:19:13 -0600 Subject: [PATCH] feat(lower): support nested SQL query functions via derived tables (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQL front end already recurses through the DataFusion `LogicalPlan`, so a nested aggregate/filter/projection chain lowered — but a *derived table* / inline view (`FROM (SELECT …) t`) was rejected: `lower_plan`'s `SubqueryAlias` arm only accepted a `TableScan` (or another alias) as its input. That is the SQL counterpart of the PromQL function nesting added in the sibling change — an aggregate over an aggregate, a filter over a derived aggregate, etc. The `SubqueryAlias` arm now lowers an arbitrary inner plan recursively (`lower_plan` already handles every node a sub-`SELECT` produces). The alias is dropped; a qualified outer reference (`t.col`) resolves by bare name against the derived output schema — the same `Qualified → bare-name` fallback the converter's column resolution already applies for joins. Subquery-*valued* expressions in a predicate (`x > (SELECT …)`, `IN (SELECT …)`, `EXISTS (…)`) still need a subquery node in the L2 expression IR, so they are rejected cleanly with an explicit message rather than the generic catch-all. Tests: derived-table aggregate-over-aggregate, outer-avg-over-inner-percentile, filter-over-derived-aggregate (qualified alias resolution), and a pinned scalar-subquery rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/lower/src/sql/expr.rs | 10 +++ crates/lower/src/sql/mod.rs | 17 ++-- crates/lower/tests/sql_lowering.rs | 121 +++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/lower/src/sql/expr.rs b/crates/lower/src/sql/expr.rs index d11d9a6a..abe5f732 100644 --- a/crates/lower/src/sql/expr.rs +++ b/crates/lower/src/sql/expr.rs @@ -181,6 +181,16 @@ pub(super) fn df_expr_to_l2(expr: &Expr) -> Result { }) } + // Subquery-valued expressions in a predicate/projection — `x > (SELECT + // …)`, `x IN (SELECT …)`, `EXISTS (SELECT …)`. These need a subquery + // node in the L2 expression IR (and a correlated-vs-uncorrelated + // decision); rejected cleanly until that lands rather than mislowered. + // Derived tables in `FROM` (the common nesting shape) ARE supported — + // see `lower_plan`'s `SubqueryAlias` arm. + Expr::ScalarSubquery(_) | Expr::InSubquery(_) | Expr::Exists(_) => Err( + LoweringError::UnsupportedFeature("subquery-valued expression in predicate".into()), + ), + other => Err(LoweringError::UnsupportedFeature(format!( "expression: {}", other diff --git a/crates/lower/src/sql/mod.rs b/crates/lower/src/sql/mod.rs index 328b30e7..aa996099 100644 --- a/crates/lower/src/sql/mod.rs +++ b/crates/lower/src/sql/mod.rs @@ -106,16 +106,21 @@ impl<'a> SqlLowerer<'a> { LogicalPlan::Subquery(_) => Err(LoweringError::UnsupportedFeature("subquery".into())), LogicalPlan::SubqueryAlias(alias) => { // An alias over a table re-qualifies the scan's columns with the - // alias (so `a.col` / `b.col` in a self-join disambiguate). A - // derived table (inline view) is unsupported in v1. + // alias (so `a.col` / `b.col` in a self-join disambiguate). match alias.input.as_ref() { LogicalPlan::TableScan(scan) => { self.scan_source(&scan.table_name.to_string(), &alias.alias.to_string()) } - LogicalPlan::SubqueryAlias(_) => self.lower_plan(&alias.input), - _ => Err(LoweringError::UnsupportedFeature( - "subquery (inline view / derived table)".into(), - )), + // A *derived table* / inline view — `FROM (SELECT …) t`, the + // SQL counterpart of PromQL function nesting (an aggregate + // over an aggregate, a filter over a derived aggregate, …). + // Lower the inner plan recursively; `lower_plan` already + // handles every node a sub-`SELECT` can produce. The alias is + // dropped — a qualified outer reference (`t.col`) resolves by + // bare name against the derived output schema, the same + // `Qualified → bare-name` fallback the converter's column + // resolution already applies for joins (issue #27). + other => self.lower_plan(other), } } other => Err(LoweringError::UnsupportedFeature(format!( diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index 1f1e1e93..d2416af4 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -505,3 +505,124 @@ async fn window_aggregate_lowers_to_windowfunc() { assert_eq!(*func, WindowFuncKind::Sum); assert_eq!(args, &vec![L3Expr::Column(3)], "SUM(bytes) → arg col 3"); } + +// ── Nested query functions: derived tables / inline views (issue #27) ─────────── + +/// Collect every `AggIntent` in the tree, root-to-leaf. +fn all_intents(qe: &QueryExpr) -> Vec { + let mut out = Vec::new(); + fn go(qe: &QueryExpr, out: &mut Vec) { + match qe { + QueryExpr::Aggregate { aggs, child, .. } => { + out.extend(aggs.iter().cloned()); + go(child, out); + } + QueryExpr::Project { child, .. } + | QueryExpr::Filter { child, .. } + | QueryExpr::Window { child, .. } + | QueryExpr::Distinct { child, .. } + | QueryExpr::Sort { child, .. } + | QueryExpr::Limit { child, .. } + | QueryExpr::WindowFunc { child, .. } + | QueryExpr::Subquery { child, .. } => go(child, out), + QueryExpr::BinaryOp { lhs, rhs, .. } + | QueryExpr::Join { + left: lhs, + right: rhs, + .. + } + | QueryExpr::SetOp { + left: lhs, + right: rhs, + .. + } => { + go(lhs, out); + go(rhs, out); + } + _ => {} + } + } + go(qe, &mut out); + out +} + +#[tokio::test] +async fn derived_table_aggregate_over_aggregate_nests() { + // `MAX(s)` over a derived table `(SELECT service, SUM(bytes) AS s … GROUP BY + // service)` — the SQL counterpart of PromQL function nesting (issue #27). + // Both reductions survive into L3: an outer `Max` over the inner `Sum`. + let qe = lower( + "SELECT MAX(s) FROM \ + (SELECT service, SUM(bytes) AS s FROM metrics GROUP BY service) t", + ) + .await; + let intents = all_intents(&qe); + assert!( + intents.iter().any(|i| matches!(i, AggIntent::Max { .. })), + "outer MAX survives, got {intents:?}" + ); + assert!( + intents.iter().any(|i| matches!(i, AggIntent::Sum { .. })), + "inner SUM survives, got {intents:?}" + ); + // The whole nested tree's output schema derives without error (positional + // resolution is total across the derived-table boundary). + assert_eq!(qe.output_schema().unwrap().columns.len(), 1); +} + +#[tokio::test] +async fn derived_table_outer_avg_over_inner_percentile() { + // Outer exact `AVG` over an inner approximate `Quantile` — each layer keeps + // its own intent (the per-node sketch-vs-exact choice is an L4 decision). + let qe = lower( + "SELECT AVG(p) FROM \ + (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ + FROM metrics GROUP BY service) t", + ) + .await; + let intents = all_intents(&qe); + assert!(intents.iter().any(|i| matches!(i, AggIntent::Avg { .. }))); + assert!(intents + .iter() + .any(|i| matches!(i, AggIntent::Quantile { q, .. } if (*q - 0.9).abs() < 1e-9))); +} + +#[tokio::test] +async fn filter_over_derived_aggregate_resolves_alias_column() { + // `WHERE t.s > 100` over a derived aggregate — the qualified ref `t.s` + // resolves by bare name against the derived output schema, and the Filter + // sits above the inner Aggregate. + let qe = lower( + "SELECT t.service, t.s FROM \ + (SELECT service, SUM(bytes) AS s FROM metrics GROUP BY service) t \ + WHERE t.s > 100", + ) + .await; + assert!( + find_filter(&qe).is_some(), + "the outer WHERE lowers to a Filter, got {qe:?}" + ); + assert!(all_intents(&qe) + .iter() + .any(|i| matches!(i, AggIntent::Sum { .. }))); + // Schema derivation is total across the boundary. + let _ = qe.output_schema().expect("nested schema derivation"); +} + +#[tokio::test] +async fn scalar_subquery_in_predicate_is_rejected() { + // A subquery-*valued* expression (`x > (SELECT …)`) needs a subquery node in + // the L2 expression IR (and a correlated/uncorrelated decision); rejected + // cleanly until that lands. Derived tables in FROM (the common nesting + // shape) ARE supported — see the tests above. + let res = lower_sql( + "SELECT service FROM metrics WHERE bytes > (SELECT AVG(bytes) FROM metrics)", + &catalog(), + AccuracyTarget::Exact, + ) + .await; + assert!( + res.is_err(), + "scalar subquery in predicate should be rejected" + ); +}