Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/lower/src/sql/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ pub(super) fn df_expr_to_l2(expr: &Expr) -> Result<L2Expr, LoweringError> {
})
}

// 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
Expand Down
17 changes: 11 additions & 6 deletions crates/lower/src/sql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
121 changes: 121 additions & 0 deletions crates/lower/tests/sql_lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AggIntent> {
let mut out = Vec::new();
fn go(qe: &QueryExpr, out: &mut Vec<AggIntent>) {
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"
);
}
Loading