diff --git a/Cargo.lock b/Cargo.lock index 4c93b748..b9605b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,7 +328,6 @@ name = "asap-frontend-promql" version = "0.1.0" dependencies = [ "asap-aware-mapping", - "asap-l2", "asap-types", "promql-parser", ] @@ -337,7 +336,6 @@ dependencies = [ name = "asap-frontend-sql" version = "0.1.0" dependencies = [ - "asap-l2", "asap-types", "datafusion", "tokio", @@ -352,14 +350,6 @@ dependencies = [ "asap-types", ] -[[package]] -name = "asap-l2" -version = "0.1.0" -dependencies = [ - "asap-types", - "thiserror", -] - [[package]] name = "asap-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c64365c7..50fbf85d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/types", - "crates/l2", "crates/asap-aware-mapping", "crates/frontend-promql", "crates/frontend-sql", diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 11188a9a..5f89e03b 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -10,8 +10,9 @@ //! invariant (arrows point up) holds here too. //! //! Post-lowering **canonicalization** is *not* here: it landed in -//! `asap_l2::canonicalize`, run inside the shared `convert_root` so every -//! front end normalizes before L3 leaves the converter (issue #34, closed). +//! `asap_types::pre_asap::canonicalize`, run inside the shared `resolve_root` +//! so every front end normalizes before L3 leaves resolution (issue #34, +//! closed). //! //! ## Status //! @@ -45,7 +46,7 @@ //! | Term | Layer | Meaning | Lives in | //! |---|---|---|---| //! | **Parse** | L1 | text (PromQL/SQL) → AST | `asap-frontend-promql` / `asap-frontend-sql` | -//! | **Bind #1** | L2 | *name resolution*: `ColumnRef` (a name) → `ColumnId` (a concrete schema column) — the classic RDBMS "Parse → **Bind** → Optimize" pipeline sense (e.g. SQL Server's query-processor terminology) | [`asap_l2::binder::Binder`](https://docs.rs/asap-l2) | +//! | **Bind #1** | L2 | *name resolution*: `ColumnRef` (a name) → `ColumnId` (a concrete schema column) — the classic RDBMS "Parse → **Bind** → Optimize" pipeline sense (e.g. SQL Server's query-processor terminology) | [`asap_types::pre_asap::binder::Binder`](https://docs.rs/asap-types) | //! | **Implementation** — [`boundary::implementation_for`] | L3→L4, *one node* | choosing a concrete physical realization (a sketch family, an exact accumulator, or pass-through) for one [`AggIntent`](asap_types::pre_asap::agg_intent::AggIntent) | [`boundary`] | //! | **`implement_tree`** — [`bind::implement_tree`] | L3→L4, *whole tree* | walk a whole `QueryExpr` tree, calling [`boundary::implementation_for`] per node, and emit the complete L4 [`SummaryExpr`](asap_types::post_asap::SummaryExpr)/`L4Node` DAG — named after "implementation" too rather than reusing "bind" a second time | [`bind`] | //! | **Bind #2** (downstream, not in this crate) | L4→L5 | a *deployment's* own physical binder, additionally deciding **placement** (edge vs. backend, wire format, …) — a genuinely different, deployment-specific decision this crate doesn't model at all | e.g. `control_plane::sketch_algebra::rules::bind_*` (as of this writing; expected to fold into that deployment's cost-model layer rather than stay a separate "bind" concept) | diff --git a/crates/devtools/tests/cross_language.rs b/crates/devtools/tests/cross_language.rs index bada6d39..a6a502ac 100644 --- a/crates/devtools/tests/cross_language.rs +++ b/crates/devtools/tests/cross_language.rs @@ -3,7 +3,7 @@ //! Semantically equivalent SQL and PromQL queries must lower to the **same //! canonical intent algebra**, so an L4 rule matching on `AggIntent` sees one //! spelling regardless of source language. These tests are the executable spec -//! for the shared [`canonicalize`](asap_l2::canonicalize) pass: they pin the +//! for the shared [`canonicalize`](asap_types::pre_asap::canonicalize) pass: they pin the //! canonical heavy-hitter shape and assert both front ends reach it. //! //! A literal `lower_sql(S) == lower_promql(P)` cannot hold — the two count diff --git a/crates/frontend-promql/Cargo.toml b/crates/frontend-promql/Cargo.toml index 30fc5a7a..8104f082 100644 --- a/crates/frontend-promql/Cargo.toml +++ b/crates/frontend-promql/Cargo.toml @@ -4,10 +4,9 @@ version = "0.1.0" edition = "2021" # PromQL front end: L1 (parse) → L2 relational, then the shared L2→L3 converter -# in asap-types. Pulls the PromQL parser only — never DataFusion. +# — both in asap-types. Pulls the PromQL parser only — never DataFusion. [dependencies] asap-types = { path = "../types" } -asap-l2 = { path = "../l2" } # Public GreptimeTeam/promql-parser (Apache-2.0) from crates.io. 0.10 upstreamed # the experimental functions we used to carry as local patches (mad_over_time, diff --git a/crates/frontend-promql/src/error.rs b/crates/frontend-promql/src/error.rs index aaea0897..06a6270a 100644 --- a/crates/frontend-promql/src/error.rs +++ b/crates/frontend-promql/src/error.rs @@ -1,8 +1,10 @@ use std::fmt; -use asap_l2::ConvertError; +use asap_types::pre_asap::ResolveTreeError; -/// Errors from lowering a PromQL query (L1 parse → L2 → shared L2→L3 convert). +/// Errors from lowering a PromQL query (L1 parse → canonical L2, built +/// directly → [`resolve_root`](asap_types::pre_asap::resolve_root) binds it +/// to L3, issue #179). /// /// Carries no DataFusion type — the PromQL front end never depends on the SQL /// stack. The language-neutral variants (`UnsupportedFeature` / `WrongLanguage` @@ -25,8 +27,9 @@ pub enum PromqlError { InvalidParameter(String), /// The workload's query language is not PromQL. WrongLanguage(String), - /// The L2→L3 converter failed (name resolution against the bound schema). - Convert(ConvertError), + /// Resolving the canonical L2 tree to L3 failed (name resolution against + /// the bound schema). + Convert(ResolveTreeError), } impl fmt::Display for PromqlError { @@ -39,15 +42,15 @@ impl fmt::Display for PromqlError { Self::MissingArgument(m) => write!(f, "missing argument: {m}"), Self::InvalidParameter(m) => write!(f, "invalid parameter: {m}"), Self::WrongLanguage(l) => write!(f, "unsupported query language: {l}"), - Self::Convert(e) => write!(f, "L2→L3 conversion failed: {e}"), + Self::Convert(e) => write!(f, "L2→L3 resolution failed: {e}"), } } } impl std::error::Error for PromqlError {} -impl From for PromqlError { - fn from(e: ConvertError) -> Self { +impl From for PromqlError { + fn from(e: ResolveTreeError) -> Self { Self::Convert(e) } } diff --git a/crates/frontend-promql/src/lib.rs b/crates/frontend-promql/src/lib.rs index 15f27197..6c2c8870 100644 --- a/crates/frontend-promql/src/lib.rs +++ b/crates/frontend-promql/src/lib.rs @@ -1,17 +1,18 @@ -//! PromQL front end: L1 (parse via `promql-parser`) → L2 relational, then the -//! shared L2→L3 [`convert_root`](asap_l2::convert_root). +//! PromQL front end: L1 (parse via `promql-parser`) → the canonical L2 shape, +//! built directly (issue #179) → [`resolve_root`]. //! -//! Emits the per-language -//! [`relational::QueryExpr`](asap_l2::relational); the shared -//! converter runs the [`Binder`](asap_l2::Binder) for -//! positional name resolution. Depends on the PromQL parser only — never on the -//! SQL / DataFusion stack. +//! Emits [`L2QueryExpr`](asap_types::pre_asap::L2QueryExpr) itself — the +//! canonical `QueryExpr`, generic over an unresolved +//! [`ColumnRef`](asap_types::pre_asap::ColumnRef) — directly, rather than a +//! separate per-language relational tree; `resolve_root` runs the +//! [`Binder`](asap_types::pre_asap::Binder) for positional name resolution. +//! Depends on the PromQL parser only — never on the SQL / DataFusion stack. pub mod error; pub mod histogram; pub mod promql; -use asap_l2::convert_root; +use asap_types::pre_asap::resolve_root; use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::{QueryLanguage, QueryWorkload}; @@ -29,8 +30,8 @@ pub use promql::PromqlLowerer; /// `histogram_quantile` discrimination uses the structural heuristic; to drive /// it from declared sample types instead, use [`lower_promql_with_histograms`]. pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Result { - let l2 = PromqlLowerer::lower(query)?; - let l3 = convert_root(&l2, &accuracy)?; + let l2 = PromqlLowerer::lower(query, &accuracy)?; + let l3 = resolve_root(&l2)?; Ok(l3) } diff --git a/crates/frontend-promql/src/promql.rs b/crates/frontend-promql/src/promql.rs index 7ad8e870..92f69a29 100644 --- a/crates/frontend-promql/src/promql.rs +++ b/crates/frontend-promql/src/promql.rs @@ -1,19 +1,26 @@ -//! Layers 1→2 lowering: PromQL string → Layer-2 `relational::QueryExpr`. +//! Layers 1→2 lowering: PromQL string → the canonical, unresolved +//! [`L2QueryExpr`](asap_types::pre_asap::query_expr::L2QueryExpr) +//! (`QueryExpr`). //! //! - **L1 (parse)** is delegated to `promql-parser` 0.8. -//! - **L2 (per-language tree)** is built here: the walk interprets PromQL -//! semantics (range vectors, aggregate operators, label matchers) and emits -//! the language-flavored [`relational::QueryExpr`] the planner's L2→L3 -//! converter ([`convert_root`](asap_l2::convert_root)) -//! consumes. Canonicalisation (window-over-aggregate fold, GROUP-BY → -//! positional `Aggregate.by`, positional name binding) happens in that -//! converter, not here. +//! - **L2** is built *directly in canonical shape* here (issue #179): the walk +//! interprets PromQL semantics (range vectors, aggregate operators, label +//! matchers) and emits `L2QueryExpr` nodes with unresolved `ColumnRef`s — +//! the same tree shape [`resolve_root`](asap_types::pre_asap::resolve_root) +//! later binds to canonical, positional `QueryExpr`. The +//! structural decisions a two-layer design would otherwise defer to a +//! separate converter (heavy-hitter `topk` recognition, the +//! `PerEntity`/`Reduce` reduction choice, `without(...)` grouping) are made +//! right here, since a front end building this shape already knows the +//! answer at parse time — see `reduction_for` and `mark_without`. +//! `resolve_root` is left with exactly the schema-*dependent* work: binding +//! every `ColumnRef` to its positional `ColumnId`. //! -//! # PromQL → L2 mapping (summary) +//! # PromQL → canonical L2 mapping (summary) //! -//! | PromQL | L2 shape (→ canonical via `convert_root`) | +//! | PromQL | Canonical shape | //! |---|---| -//! | `quantile_over_time(φ, m{f}[w])` | `Aggregate{[Quantile(φ)], Window{w, Filter(Source)}}` | +//! | `quantile_over_time(φ, m{f}[w])` | `Aggregate{[Quantile(φ)], TimeRange{w, Scan{predicates}}}` | //! | `histogram_quantile(φ, )` | `Aggregate{[HistogramQuantile(φ)]}` — cumulative-bucket interpolation (classic form recognised by `by (le)` / a `_bucket` metric / an `le` matcher) | //! | `histogram_quantile(φ, )` | `Aggregate{[Quantile(φ)]}` over the fully-lowered arg (generic, sketch-able with an accuracy target) | //! | `histogram_quantiles(v, "l", φ…)` | `Merge{Relabel{l=φᵢ, }…}` — one branch per φ (issue #109) | @@ -21,14 +28,14 @@ //! | `OUTER_op(inner_func(m[w]))` (e.g. `sum(rate(m[w]))`) | `Aggregate{[OUTER_op]}` over `Aggregate{[inner_func]}` — two levels | //! | `OUTER_op()` (e.g. `max(sum by (job) (rate(m[w])))`, `sum(rate(a[w]) + rate(b[w]))`) | `Aggregate{[OUTER_op]}` over the fully-lowered `` — arbitrary function nesting (issue #27) | //! | `topk(k, )` / `bottomk(k, )` | `Sort{value} → Limit{k}` over the fully-lowered argument | -//! | `avg/min/max/sum_over_time(m[w])` | `Aggregate{[Avg/Min/Max/Sum], Window{w}}` | -//! | `stddev/stdvar_over_time(m[w])` | `Aggregate{[StdDev/Variance], Window{w}}` | -//! | `count_over_time(m[w])` | `Aggregate{[Count], Window{w}}` | -//! | `last/first/mad/ts_of_min/ts_of_max/ts_of_first/ts_of_last_over_time(m[w])` | `Aggregate{[Last/First/Mad/TsOf…OverTime], Window{w}}` — per-series range reducers (issue #51) | +//! | `avg/min/max/sum_over_time(m[w])` | `Aggregate{[Avg/Min/Max/Sum], TimeRange{w}}` | +//! | `stddev/stdvar_over_time(m[w])` | `Aggregate{[StdDev/Variance], TimeRange{w}}` | +//! | `count_over_time(m[w])` | `Aggregate{[Count], TimeRange{w}}` | +//! | `last/first/mad/ts_of_min/ts_of_max/ts_of_first/ts_of_last_over_time(m[w])` | `Aggregate{[Last/First/Mad/TsOf…OverTime], TimeRange{w}}` — per-series range reducers (issue #51) | //! | `sort`/`sort_desc(v)`, `sort_by_label[_desc](v,"l"…)` | `Sort{value \| label…}` (no `Limit`) — row-preserving reorder (issue #51); `min_of`/`max_of` scalar reducers → #89 | -//! | `rate/irate(m[w])` | `Aggregate{[Rate{w}]}` (no Window) — `irate` shares the `rate` *intent*; the avg-vs-last-two-samples difference is an L4 estimation method | -//! | `increase(m[w])` | `Aggregate{[Increase{w}]}` (no Window) | -//! | `changes`/`delta`/`idelta`/`deriv`/`resets`/`predict_linear`/`double_exponential_smoothing`(`m[w]`, …) | `Aggregate{[Changes/Delta/…], Window{w}}` — per-series counter-derivative intents (issue #44) | +//! | `rate/irate(m[w])` | `Aggregate{[Rate], TimeRange{w}}` — `irate` shares the `rate` *intent*; the avg-vs-last-two-samples difference is an L4 estimation method | +//! | `increase(m[w])` | `Aggregate{[Increase], TimeRange{w}}` | +//! | `changes`/`delta`/`idelta`/`deriv`/`resets`/`predict_linear`/`double_exponential_smoothing`(`m[w]`, …) | `Aggregate{[Changes/Delta/…], TimeRange{w}}` — per-series counter-derivative intents (issue #44) | //! | `absent(v)` / `absent_over_time(m[w])` / `present_over_time(m[w])` | `Aggregate{[Absent/AbsentOverTime/PresentOverTime]}` — presence intents; the empty→synthesized-sample logic is L4 (issue #47) | //! | `abs`/`ceil`/`sqrt`/`ln`/`clamp*`/`round`/trig(`v`), `pi()` | `Aggregate{[Math(f)]}` element-wise transform (issue #45); `pi()` → a `Scalar` leaf | //! | `time()` / `timestamp`/`hour`/`day_of_week`/… (`v`) | `EvalTime` leaf / `Aggregate{[TimeFn(f)]}` (issue #46) | @@ -36,15 +43,15 @@ //! | `label_replace(v,…)` / `label_join(v,…)` | `Relabel{dst, value}` — per-series label rewrite; value unchanged (issue #50) | //! | `info(v, [selector])` | `InfoJoin{selector}` — label-enrichment join against the info metric(s); join keys resolved at L4 (issue #84) | //! | `group` / `offset` / `@` / `info` | **rejected** — distinct semantics with no intent-algebra representation yet (`info` label-join → #84) | -//! | `OUTER by (dims) (…)` | `Aggregate.keys = dims` (→ positional `Aggregate.by` in L3; generic `topk by`/`bottomk` grouping → `Sort.partition_by`) | -//! | `count by (d) (…)` | `Aggregate{[CountDistinct], …}` (→ `Cardinality`) | +//! | `OUTER by (dims) (…)` | `Aggregate.reduction = Reduce(by = dims)` (generic `topk by`/`bottomk` grouping → `Sort.partition_by`) | +//! | `count by (d) (…)` | `Aggregate{[Cardinality], …}` | //! | `group(v)` / `count_values("l", v)` | `Aggregate{[Group]}` (constant 1) / `Aggregate{[CountValues{l}]}` (group-by-value + count, new label `l`) — issue #49 | //! | `limitk(k, v)` / `limit_ratio(r, v)` | `Sample{LimitK(k) \| LimitRatio(r)}` — series-sampling selection, whole series kept unchanged (issue #86) | -//! | `topk(k, count_over_time(…))` | `TopK{k, by}` (heavy-hitter intent) | +//! | `topk(k, count_over_time(…))` | `Aggregate{[TopK{k}]}` (heavy-hitter intent) over the explicit inner `Aggregate{[Count]}` | //! | `topk(k, )` / `bottomk(k, …)` | `Sort{value} → Limit{k}` | -//! | `m{f}` | `Filter(Source)` | +//! | `m{f}` | `Scan{predicates}` | //! | `a OP b` | `BinaryOp{vector_match}` | -//! | `expr[r:res]` | `PromQLSubquery{r, res}` | +//! | `expr[r:res]` | `Subquery{r, res}` | //! | ` offset ` / ` @ `/`start()`/`end()` | `TimeShift{shift}` over the selector's `Scan` — pass-through schema; a ranged selector shifts under its `TimeRange` (issue #40) | use std::time::{Duration, SystemTime}; @@ -55,17 +62,17 @@ use promql_parser::parser::{ VectorMatchCardinality, VectorSelector, }; -use asap_l2::relational::{AggFunc, AggItem, L2SortKey, QueryExpr as L2, SourceSpec}; use asap_types::pre_asap::agg_intent::{ - is_frequency_heavy_hitter, MathFunc, RankingMeasure, TimeFunc, + is_frequency_heavy_hitter, AggIntent, MathFunc, RankingMeasure, TimeFunc, }; use asap_types::pre_asap::query_expr::{ - AtModifier as L3AtModifier, BinaryOpKind, GroupSide, TimeShift, VectorGrouping, VectorMatch, - VectorMatchKind, + AtModifier as L3AtModifier, BinaryOpKind, GroupKeys, GroupSide, L2QueryExpr as L2, Predicate, + Reduction, SortKey, Source, TimeShift, VectorGrouping, VectorMatch, VectorMatchKind, }; use asap_types::pre_asap::{ ArithOp, ColumnRef, CompareOp, InfoMatcher, L2Expr, L3Scalar, SampleKind, }; +use asap_types::types::AccuracyTarget; use crate::error::PromqlError as LoweringError; @@ -117,10 +124,14 @@ enum InnerFunc { StdDev, Variance, Count, - Rate(Duration), - Increase(Duration), + // `Rate`/`Increase` carry no window of their own — unlike the old L2 + // `AggFunc::Rate{window}`, canonical `AggIntent::Rate`/`Increase` have no + // window field either; `windowed_aggregate` reads `Inner.window` + // uniformly for every intent, so it would be a redundant duplicate here. + Rate, + Increase, // Counter-derivative range functions (issue #44). The window rides on the - // enclosing L2 `Window` node (like `*_over_time`), so these carry only + // enclosing `TimeRange` node (like `*_over_time`), so these carry only // their non-window scalar params. Changes, Delta, @@ -156,7 +167,18 @@ struct Inner { const MAX_DEPTH: usize = 256; impl PromqlLowerer { - pub fn lower(query: &str) -> Result { + /// Lower `query` to the canonical L2 tree, threading `accuracy` onto every + /// approximate intent (`Count`, `Quantile`, `Cardinality`, `TopK`) as it is + /// built — this front end constructs the canonical shape directly (issue + /// #179), so accuracy is baked in here rather than threaded through a + /// later, separate converter pass. `accuracy` rides the same ambient, + /// thread-local mechanism as `histogram::CatalogGuard` + /// — synchronous, one-query-at-a-time lowering, injected into the deep + /// `walk` recursion without a parameter on every one of its ~30 mutually + /// recursive signatures; consulted only at the handful of sites that build + /// an accuracy-bearing `AggIntent`. + pub fn lower(query: &str, accuracy: &AccuracyTarget) -> Result { + let _guard = AccuracyGuard::install(accuracy.clone()); let ast = parser::parse(query).map_err(LoweringError::Parse)?; // Reject over-deep nesting up front, so the (mutually-recursive) walk // below cannot blow the stack. The check itself recurses at most @@ -166,6 +188,34 @@ impl PromqlLowerer { } } +std::thread_local! { + static ACCURACY: std::cell::RefCell = + const { std::cell::RefCell::new(AccuracyTarget::Exact) }; +} + +/// RAII guard installing `accuracy` as the ambient accuracy target for the +/// current thread's lowering, restoring the prior value on drop — same shape +/// as `histogram::CatalogGuard`. +struct AccuracyGuard(AccuracyTarget); + +impl AccuracyGuard { + fn install(accuracy: AccuracyTarget) -> Self { + let prev = ACCURACY.with(|a| a.replace(accuracy)); + AccuracyGuard(prev) + } +} + +impl Drop for AccuracyGuard { + fn drop(&mut self) { + ACCURACY.with(|a| *a.borrow_mut() = std::mem::replace(&mut self.0, AccuracyTarget::Exact)); + } +} + +/// The ambient accuracy target installed by the current [`PromqlLowerer::lower`] call. +fn current_accuracy() -> AccuracyTarget { + ACCURACY.with(|a| a.borrow().clone()) +} + /// Bounded depth check over the parser AST: errors once nesting would exceed /// `budget` frames, descending into every child expression. fn check_depth(expr: &Expr, budget: usize) -> Result<()> { @@ -235,10 +285,10 @@ fn walk(expr: &Expr) -> Result { vector_match: None, }), }, - Expr::Subquery(sq) => Ok(L2::PromQLSubquery { + Expr::Subquery(sq) => Ok(L2::Subquery { range: sq.range, resolution: sq.step, - input: Box::new(walk(&sq.expr)?), + child: Box::new(walk(&sq.expr)?), }), Expr::VectorSelector(vs) => { let (metric, matchers, shift) = vs_parts(vs)?; @@ -246,10 +296,9 @@ fn walk(expr: &Expr) -> Result { } Expr::MatrixSelector(ms) => { let (metric, matchers, shift) = vs_parts(&ms.vs)?; - Ok(L2::Window { - duration: ms.range, - slide: None, - input: Box::new(filtered_source(metric, matchers, shift)), + Ok(L2::TimeRange { + range: ms.range, + child: Box::new(filtered_source(metric, matchers, shift)), }) } // A number literal is a scalar leaf (`v > 5`, or a bare scalar query @@ -296,17 +345,17 @@ fn range_fn_over_subquery(call: &Call) -> Result> { // sub-query that window is the sub-query's own range. if let "rate" | "irate" | "increase" = call.func.name { let arg_expr = arg(call, 0)?; - let Some(range) = subquery_range(arg_expr) else { + if subquery_range(arg_expr).is_none() { return Ok(None); - }; + } let inner = if call.func.name == "increase" { - InnerFunc::Increase(range) + InnerFunc::Increase } else { - InnerFunc::Rate(range) + InnerFunc::Rate }; return Ok(Some(outer_aggregate( vec![], - inner_func(&inner), + inner_intent(&inner), walk(arg_expr)?, ))); } @@ -351,7 +400,7 @@ fn range_fn_over_subquery(call: &Call) -> Result> { } Ok(Some(outer_aggregate( vec![], - inner_func(&inner), + inner_intent(&inner), walk(arg_expr)?, ))) } @@ -482,21 +531,44 @@ fn outer_kind(agg: &AggregateExpr) -> Result { /// at the root; `walk_aggregate` has already rejected the non-aggregate outers /// (topk/limitk), so a `without` grouping always has an `Aggregate` here (issue /// #39). A no-op when the modifier was `by`. +/// Flip the outer `Aggregate` produced for a `without(...)` grouping into the +/// exclusion form. A no-op when the modifier was `by`. +/// +/// `reduction_for` (used by [`windowed_aggregate`]/[`outer_aggregate`] to +/// build this node) decides `PerEntity` vs `Reduce(by)` *without* knowing +/// about `without` yet — it only ever sees `by`-mode keys, since `without`'s +/// excluded-labels list is applied here, after the fact, exactly like the old +/// L2 `relational` tree's `mark_without` did (the L2→L3 converter read +/// `without` only after this front-end step had already set it). Whether +/// `reduction_for` picked `PerEntity` (only possible when `keys` was empty) +/// or `Reduce(by)`, the correct answer under `without(...)` is always +/// `Reduce(without(keys))`: a `without` grouping is never label-preserving — +/// per-entity requires `!by.is_without()` — so this both re-tags an existing +/// `Reduce` and upgrades a wrongly-early `PerEntity` guess, uniformly. fn mark_without(l2: L2, without: bool) -> L2 { + if !without { + return l2; + } match l2 { L2::Aggregate { - keys, - measures, - having, - input, - .. - } if without => L2::Aggregate { - keys, - without: true, + reduction, measures, + output_names, having, - input, - }, + child, + } => { + let keys = match reduction { + Reduction::Reduce(by) => by.keys().to_vec(), + Reduction::PerEntity => vec![], + }; + L2::Aggregate { + reduction: Reduction::Reduce(GroupKeys::without(keys)), + measures, + output_names, + having, + child, + } + } other => other, } } @@ -505,30 +577,37 @@ fn build_over_subtree(outer: Outer, keys: Vec, child: L2) -> Result child, - Outer::Plain(intent) => outer_aggregate(keys, outer_func(&intent), child), - Outer::Count => outer_aggregate(keys, AggFunc::CountDistinct, child), + Outer::Plain(intent) => outer_aggregate(keys, outer_intent(&intent), child), + Outer::Count => outer_aggregate( + keys, + AggIntent::Cardinality { + col: None, + accuracy: current_accuracy(), + }, + child, + ), Outer::CountValues { label } => { - outer_aggregate(keys, AggFunc::CountValues { label }, child) + outer_aggregate(keys, AggIntent::CountValues { label }, child) } Outer::Sample { kind } => L2::Sample { - keys, + by: keys.into(), kind, - input: Box::new(child), + child: Box::new(child), }, Outer::TopK { k, descending } => { let sorted = L2::Sort { - keys: vec![L2SortKey { + keys: vec![SortKey { expr: L2Expr::Column(ColumnRef::SampleValue), ascending: !descending, nulls_first: false, }], - partition_by: keys, - input: Box::new(child), + partition_by: keys.into(), + child: Box::new(child), }; L2::Limit { - n: k, + n: k as usize, offset: 0, - input: Box::new(sorted), + child: Box::new(sorted), } } }) @@ -567,21 +646,25 @@ fn walk_histogram(call: &Call) -> Result { // `HistogramKind` (issue #79) drives the choice when available, else we // fall back to the structural `by (le)`/`_bucket` heuristic (issue #43). let func = if histogram_arg_is_sketchable(arg_expr) { - AggFunc::Quantile(phi) + AggIntent::Quantile { + col: None, + q: phi, + accuracy: current_accuracy(), + } } else { - AggFunc::HistogramQuantile(phi) + AggIntent::HistogramQuantile { q: phi } }; return Ok(outer_aggregate(vec![], func, walk(arg_expr)?)); } // (histogram_quantile handled above; accessors below) let (func, vec_idx) = match call.func.name { - "histogram_count" => (AggFunc::HistogramCount, 0), - "histogram_sum" => (AggFunc::HistogramSum, 0), - "histogram_avg" => (AggFunc::HistogramAvg, 0), - "histogram_stddev" => (AggFunc::HistogramStdDev, 0), - "histogram_stdvar" => (AggFunc::HistogramStdVar, 0), + "histogram_count" => (AggIntent::HistogramCount, 0), + "histogram_sum" => (AggIntent::HistogramSum, 0), + "histogram_avg" => (AggIntent::HistogramAvg, 0), + "histogram_stddev" => (AggIntent::HistogramStdDev, 0), + "histogram_stdvar" => (AggIntent::HistogramStdVar, 0), "histogram_fraction" => ( - AggFunc::HistogramFraction { + AggIntent::HistogramFraction { lower: num_arg(call, 0)?, upper: num_arg(call, 1)?, }, @@ -624,30 +707,35 @@ fn walk_histogram_quantiles(call: &Call) -> Result { let branches = (2..call.args.args.len()) .map(|i| { let phi = quantile_param(num_arg(call, i)?)?; - let func = if sketchable { - AggFunc::Quantile(phi) + let intent = if sketchable { + AggIntent::Quantile { + col: None, + q: phi, + accuracy: current_accuracy(), + } } else { - AggFunc::HistogramQuantile(phi) + AggIntent::HistogramQuantile { q: phi } }; + let child = walk(vec_expr)?; + let reduction = reduction_for(&[], &intent, &child); let quantile = L2::Aggregate { - keys: vec![], - without: false, - measures: vec![AggItem { - alias: Some("value".into()), - func, - col: ColumnRef::SampleValue, - }], + reduction, + measures: vec![intent], + // Each branch aliases its value column to "value" (not the + // intent-keyed default) so `Merge` — which derives its schema + // from the first branch — doesn't silently misdescribe the rest. + output_names: vec!["value".into()], having: None, - input: Box::new(walk(vec_expr)?), + child: Box::new(child), }; Ok(L2::Relabel { dst: label.clone(), value: L2Expr::Literal(L3Scalar::Utf8(open_metrics_float(phi))), - input: Box::new(quantile), + child: Box::new(quantile), }) }) .collect::>>()?; - Ok(L2::Merge { inputs: branches }) + Ok(L2::Merge { children: branches }) } /// Prometheus's `labels.FormatOpenMetricsFloat` — how `histogram_quantiles` @@ -733,7 +821,7 @@ fn walk_time(call: &Call) -> Result { } else { walk(arg(call, 0)?)? }; - Ok(outer_aggregate(vec![], AggFunc::TimeFn(func), inner)) + Ok(outer_aggregate(vec![], AggIntent::TimeFn(func), inner)) } /// The presence functions (issue #47). @@ -747,9 +835,9 @@ fn is_presence_fn(name: &str) -> bool { /// marks the operation (issue #47). fn walk_presence(call: &Call) -> Result { let func = match call.func.name { - "absent" => AggFunc::Absent, - "absent_over_time" => AggFunc::AbsentOverTime, - "present_over_time" => AggFunc::PresentOverTime, + "absent" => AggIntent::Absent, + "absent_over_time" => AggIntent::AbsentOverTime, + "present_over_time" => AggIntent::PresentOverTime, other => return Err(LoweringError::UnsupportedFunction(other.to_string())), }; // arg 0 is the instant vector (`absent`) or range vector (`*_over_time`); @@ -790,7 +878,7 @@ fn is_sort_fn(name: &str) -> bool { /// lower to a bare `Sort` (no `Limit`) over the vector argument — a faithful, /// row-preserving reordering (issue #51). fn walk_sort(call: &Call) -> Result { - let input = Box::new(walk(arg(call, 0)?)?); + let child = Box::new(walk(arg(call, 0)?)?); let (by_value, ascending) = match call.func.name { "sort" => (true, true), "sort_desc" => (true, false), @@ -798,7 +886,7 @@ fn walk_sort(call: &Call) -> Result { "sort_by_label_desc" => (false, false), other => return Err(LoweringError::UnsupportedFunction(other.to_string())), }; - let sort_key = |expr| L2SortKey { + let sort_key = |expr| SortKey { expr, ascending, nulls_first: false, @@ -822,8 +910,8 @@ fn walk_sort(call: &Call) -> Result { }; Ok(L2::Sort { keys, - partition_by: vec![], - input, + partition_by: GroupKeys::none(), + child, }) } @@ -832,12 +920,12 @@ fn walk_sort(call: &Call) -> Result { /// matchers; the actual join against the info metric — on shared identifying /// labels — is resolved at L4 (issue #84). fn walk_info(call: &Call) -> Result { - let input = Box::new(walk(arg(call, 0)?)?); + let child = Box::new(walk(arg(call, 0)?)?); let selector = match call.args.args.get(1) { Some(sel) => info_selector(sel)?, None => Vec::new(), // default: enrich from `target_info` }; - Ok(L2::InfoJoin { selector, input }) + Ok(L2::InfoJoin { selector, child }) } /// Extract the `info` data-label selector's matchers. Unlike an ordinary @@ -882,7 +970,7 @@ fn is_label_fn(name: &str) -> bool { /// values are untouched; the regex-match-or-passthrough and capture-expansion /// are L4/runtime concerns (issue #50). fn walk_label(call: &Call) -> Result { - let input = Box::new(walk(arg(call, 0)?)?); + let child = Box::new(walk(arg(call, 0)?)?); match call.func.name { "label_replace" => { let dst = str_arg(call, 1)?; @@ -897,7 +985,7 @@ fn walk_label(call: &Call) -> Result { L2Expr::Literal(L3Scalar::Utf8(replacement)), ], }; - Ok(L2::Relabel { dst, value, input }) + Ok(L2::Relabel { dst, value, child }) } "label_join" => { // label_join(v, dst, sep, src_1, …, src_n) — needs ≥1 source label. @@ -916,7 +1004,7 @@ fn walk_label(call: &Call) -> Result { name: "label_join".into(), args, }; - Ok(L2::Relabel { dst, value, input }) + Ok(L2::Relabel { dst, value, child }) } other => Err(LoweringError::UnsupportedFunction(other.to_string())), } @@ -1010,7 +1098,7 @@ fn walk_math(call: &Call) -> Result { }; // The value being transformed is always arg 0 (a vector). let inner = walk(arg(call, 0)?)?; - Ok(outer_aggregate(vec![], AggFunc::Math(func), inner)) + Ok(outer_aggregate(vec![], AggIntent::Math(func), inner)) } /// Whether `expr` is a **classic cumulative-bucket** `histogram_quantile` @@ -1205,7 +1293,7 @@ fn lower_inner_call(call: &Call) -> Result { metric, matchers, window: Some(window), - func: Some(InnerFunc::Rate(window)), + func: Some(InnerFunc::Rate), shift, }) } @@ -1215,7 +1303,7 @@ fn lower_inner_call(call: &Call) -> Result { metric, matchers, window: Some(window), - func: Some(InnerFunc::Increase(window)), + func: Some(InnerFunc::Increase), shift, }) } @@ -1289,8 +1377,8 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result { Outer::None => match &inner.func { None => Ok(filtered_source(inner.metric, inner.matchers, inner.shift)), Some(f) => { - let func = inner_func(f); - Ok(windowed_aggregate(inner, keys, func)) + let intent = inner_intent(f); + Ok(windowed_aggregate(inner, keys, intent)) } }, // An OUTER aggregation operator (`sum`/`avg`/…/`count`) over an inner @@ -1298,46 +1386,43 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result { // two-level reduction: the inner func runs per series, the outer op // then aggregates across series. Collapsing them into one aggregate // silently drops a level — e.g. `sum(rate(m[w]))` must keep the `sum`. - Outer::Plain(outer_intent) => Ok(match &inner.func { - None => windowed_aggregate(inner, keys, outer_func(&outer_intent)), + Outer::Plain(intent) => Ok(match &inner.func { + None => windowed_aggregate(inner, keys, outer_intent(&intent)), Some(f) => { - let inner_f = inner_func(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_f); - outer_aggregate(keys, outer_func(&outer_intent), inner_agg) + let inner_i = inner_intent(f); + let inner_agg = windowed_aggregate(inner, vec![], inner_i); + outer_aggregate(keys, outer_intent(&intent), inner_agg) } }), Outer::Count => Ok(match &inner.func { - None => windowed_aggregate(inner, keys, AggFunc::CountDistinct), + None => windowed_aggregate(inner, keys, cardinality()), Some(f) => { - let inner_f = inner_func(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_f); - outer_aggregate(keys, AggFunc::CountDistinct, inner_agg) + let inner_i = inner_intent(f); + let inner_agg = windowed_aggregate(inner, vec![], inner_i); + outer_aggregate(keys, cardinality(), inner_agg) + } + }), + Outer::CountValues { label } => Ok(match &inner.func { + None => windowed_aggregate(inner, keys, AggIntent::CountValues { label }), + Some(f) => { + let inner_i = inner_intent(f); + let inner_agg = windowed_aggregate(inner, vec![], inner_i); + outer_aggregate(keys, AggIntent::CountValues { label }, inner_agg) } }), - Outer::CountValues { label } => { - let func = AggFunc::CountValues { label }; - Ok(match &inner.func { - None => windowed_aggregate(inner, keys, func), - Some(f) => { - let inner_f = inner_func(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_f); - outer_aggregate(keys, func, inner_agg) - } - }) - } Outer::Sample { kind } => { // Series sampling selects whole series unchanged — like generic // `topk`, a range-vector argument reduces per series first (label- // preserving), a bare selector is sampled directly; neither is // wrapped in a reducing aggregate (issue #86). - let base = match inner.func.as_ref().map(inner_func) { - Some(func) => windowed_aggregate(inner, vec![], func), + let base = match inner.func.as_ref().map(inner_intent) { + Some(intent) => windowed_aggregate(inner, vec![], intent), None => filtered_source(inner.metric, inner.matchers, inner.shift), }; Ok(L2::Sample { - keys, + by: keys.into(), kind, - input: Box::new(base), + child: Box::new(base), }) } Outer::TopK { k, descending } => { @@ -1358,11 +1443,18 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result { // and TopK into a single-pass heavy-hitter sketch (SpaceSaving / // CMS-with-heap), but that is a cost-model decision, not an L3 // concern. - let count_agg = windowed_aggregate(inner, vec![], inner_func(&InnerFunc::Count)); - Ok(L2::TopK { - k, - by: keys, - input: Box::new(count_agg), + let count_agg = windowed_aggregate(inner, vec![], inner_intent(&InnerFunc::Count)); + Ok(L2::Aggregate { + // A ranking always reduces (a `by`-empty TopK ranks the + // whole input into one ordering, never per-entity). + reduction: Reduction::Reduce(keys.into()), + measures: vec![AggIntent::TopK { + k: k as usize, + accuracy: current_accuracy(), + }], + output_names: vec![], + having: None, + child: Box::new(count_agg), }) } else { // The base over which we rank. A range-vector-function argument @@ -1376,136 +1468,182 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result { // including the `by (…)` partition keys, so they no longer // resolve at L3 (issue #30). Keep the selector label-preserving so // `Sort.partition_by` can rank within each group (issue #12). - let base = match inner.func.as_ref().map(inner_func) { - Some(func) => windowed_aggregate(inner, vec![], func), + let base = match inner.func.as_ref().map(inner_intent) { + Some(intent) => windowed_aggregate(inner, vec![], intent), None => filtered_source(inner.metric, inner.matchers, inner.shift), }; let sorted = L2::Sort { - keys: vec![L2SortKey { + keys: vec![SortKey { expr: L2Expr::Column(ColumnRef::SampleValue), ascending: !descending, nulls_first: false, }], - partition_by: keys, - input: Box::new(base), + partition_by: keys.into(), + child: Box::new(base), }; Ok(L2::Limit { - n: k, + n: k as usize, offset: 0, - input: Box::new(sorted), + child: Box::new(sorted), }) } } } } -/// `Aggregate{keys, [func]}` over `[Window{w}] → Filter(Source)`. Rate/Increase -/// carry their own window in the func, so no `Window` node is emitted. -fn windowed_aggregate(inner: Inner, keys: Vec, func: AggFunc) -> L2 { - let skip_window = matches!(func, AggFunc::Rate { .. } | AggFunc::Increase { .. }); - let window = inner.window; +/// Decide `PerEntity` vs `Reduce(by)` for a canonical `Aggregate`, entirely +/// from local, already-in-scope information (issue #179's "local, +/// context-free structural rewriting"): the keys list, the intent's own +/// `is_per_series` flag, and whether the child being wrapped is already a +/// range/subquery marker — no schema needed. `without()` is applied +/// separately, post-hoc, by `mark_without` — see its doc for why that's still +/// correct here. +fn reduction_for( + keys: &[ColumnRef], + intent: &AggIntent, + child: &L2, +) -> Reduction { + let is_range_child = matches!(child, L2::TimeRange { .. } | L2::Subquery { .. }); + if keys.is_empty() && (intent.is_per_series() || is_range_child) { + Reduction::PerEntity + } else { + Reduction::Reduce(GroupKeys::by(keys.to_vec())) + } +} + +/// `Aggregate{reduction, [intent]}` over `[TimeRange{w}] → Scan`. Always wraps +/// in `TimeRange` when there's a window — including for `Rate`/`Increase`, +/// whose window rides on `inner.window` too (set redundantly alongside the +/// intent itself): canonical `AggIntent::Rate`/`Increase` carry no window +/// field of their own, unlike the old L2 `AggFunc::Rate{window}` — "the range +/// is on the enclosing `TimeRange` node" is now true unconditionally, so +/// there's no more `skip_window` special case. +fn windowed_aggregate(inner: Inner, keys: Vec, intent: AggIntent) -> L2 { let base = filtered_source(inner.metric, inner.matchers, inner.shift); - let input = match window { - Some(w) if !skip_window => L2::Window { - duration: w, - slide: None, - input: Box::new(base), + let child = match inner.window { + Some(w) => L2::TimeRange { + range: w, + child: Box::new(base), }, - _ => base, + None => base, }; + let reduction = reduction_for(&keys, &intent, &child); L2::Aggregate { - keys, - without: false, - measures: vec![AggItem { - // None alias → the converter keeps PromQL's intent-keyed output - // names ("sum", "quantile_0_99", …) instead of overriding them. - alias: None, - func, - col: ColumnRef::SampleValue, - }], + reduction, + measures: vec![intent], + // A single empty entry — never an override — so the resolver keeps + // PromQL's intent-keyed output names ("sum", "quantile_0_99", …) + // instead. + output_names: vec![String::new()], having: None, - input: Box::new(input), + child: Box::new(child), } } -/// `Aggregate{keys, [func]}` directly over an existing L2 subtree — the OUTER -/// level of a two-level aggregation such as `sum(rate(…))` or the +/// `Aggregate{reduction, [intent]}` directly over an existing L2 subtree — the +/// OUTER level of a two-level aggregation such as `sum(rate(…))` or the /// `Aggregate{[Quantile]}` that wraps a `histogram_quantile` argument. -fn outer_aggregate(keys: Vec, func: AggFunc, input: L2) -> L2 { +fn outer_aggregate(keys: Vec, intent: AggIntent, child: L2) -> L2 { + let reduction = reduction_for(&keys, &intent, &child); L2::Aggregate { - keys, - without: false, - measures: vec![AggItem { - // None alias → the converter keeps PromQL's intent-keyed output - // names ("sum", "quantile_0_99", …) instead of overriding them. - alias: None, - func, - col: ColumnRef::SampleValue, - }], + reduction, + measures: vec![intent], + output_names: vec![String::new()], having: None, - input: Box::new(input), + child: Box::new(child), } } fn filtered_source(metric: String, matchers: Vec, shift: TimeShift) -> L2 { - let source = L2::Source(SourceSpec::new(metric).with_shift(shift)); - if matchers.is_empty() { - source + let scan = L2::Scan { + source: Source::TimeSeries { metric }, + predicates: matchers.into_iter().map(Predicate).collect(), + // Usage-derived (PromQL is schemaless) — the Binder fills this in. + schema: None, + }; + if shift.is_identity() { + scan } else { - let pred = if matchers.len() == 1 { - matchers.into_iter().next().unwrap() - } else { - L2Expr::BoolAnd(matchers) - }; - L2::Filter { - pred, - input: Box::new(source), + L2::TimeShift { + shift, + child: Box::new(scan), } } } -fn inner_func(f: &InnerFunc) -> AggFunc { +/// `count(v)` / `count by (…) (v)` — SQL `COUNT(DISTINCT col)`'s PromQL +/// counterpart, over the (always implicit) sample value. +fn cardinality() -> AggIntent { + AggIntent::Cardinality { + col: None, + accuracy: current_accuracy(), + } +} + +fn inner_intent(f: &InnerFunc) -> AggIntent { match f { - InnerFunc::Quantile(q) => AggFunc::Quantile(*q), - InnerFunc::Avg => AggFunc::Avg, - InnerFunc::Min => AggFunc::Min, - InnerFunc::Max => AggFunc::Max, - InnerFunc::Sum => AggFunc::Sum, - InnerFunc::StdDev => AggFunc::StdDev { population: true }, - InnerFunc::Variance => AggFunc::Variance { population: true }, - InnerFunc::Count => AggFunc::Count, - InnerFunc::Rate(w) => AggFunc::Rate { window: *w }, - InnerFunc::Increase(w) => AggFunc::Increase { window: *w }, - InnerFunc::Changes => AggFunc::Changes, - InnerFunc::Delta => AggFunc::Delta, - InnerFunc::IDelta => AggFunc::IDelta, - InnerFunc::Deriv => AggFunc::Deriv, - InnerFunc::Resets => AggFunc::Resets, - InnerFunc::PredictLinear(s) => AggFunc::PredictLinear { seconds: *s }, - InnerFunc::DoubleExp { smoothing, trend } => AggFunc::DoubleExpSmoothing { + InnerFunc::Quantile(q) => AggIntent::Quantile { + col: None, + q: *q, + accuracy: current_accuracy(), + }, + InnerFunc::Avg => AggIntent::Avg { col: None }, + InnerFunc::Min => AggIntent::Min { col: None }, + InnerFunc::Max => AggIntent::Max { col: None }, + InnerFunc::Sum => AggIntent::Sum { col: None }, + InnerFunc::StdDev => AggIntent::StdDev { + col: None, + population: true, + }, + InnerFunc::Variance => AggIntent::Variance { + col: None, + population: true, + }, + InnerFunc::Count => AggIntent::Count { + accuracy: current_accuracy(), + }, + InnerFunc::Rate => AggIntent::Rate, + InnerFunc::Increase => AggIntent::Increase, + InnerFunc::Changes => AggIntent::Changes, + InnerFunc::Delta => AggIntent::Delta, + InnerFunc::IDelta => AggIntent::IDelta, + InnerFunc::Deriv => AggIntent::Deriv, + InnerFunc::Resets => AggIntent::Resets, + InnerFunc::PredictLinear(s) => AggIntent::PredictLinear { seconds: *s }, + InnerFunc::DoubleExp { smoothing, trend } => AggIntent::DoubleExpSmoothing { smoothing: *smoothing, trend: *trend, }, - InnerFunc::LastOverTime => AggFunc::LastOverTime, - InnerFunc::FirstOverTime => AggFunc::FirstOverTime, - InnerFunc::MadOverTime => AggFunc::MadOverTime, - InnerFunc::TsOfMinOverTime => AggFunc::TsOfMinOverTime, - InnerFunc::TsOfMaxOverTime => AggFunc::TsOfMaxOverTime, - InnerFunc::TsOfFirstOverTime => AggFunc::TsOfFirstOverTime, - InnerFunc::TsOfLastOverTime => AggFunc::TsOfLastOverTime, + InnerFunc::LastOverTime => AggIntent::LastOverTime, + InnerFunc::FirstOverTime => AggIntent::FirstOverTime, + InnerFunc::MadOverTime => AggIntent::MadOverTime, + InnerFunc::TsOfMinOverTime => AggIntent::TsOfMinOverTime, + InnerFunc::TsOfMaxOverTime => AggIntent::TsOfMaxOverTime, + InnerFunc::TsOfFirstOverTime => AggIntent::TsOfFirstOverTime, + InnerFunc::TsOfLastOverTime => AggIntent::TsOfLastOverTime, } } -fn outer_func(o: &OuterIntent) -> AggFunc { +fn outer_intent(o: &OuterIntent) -> AggIntent { match o { - OuterIntent::Sum => AggFunc::Sum, - OuterIntent::Avg => AggFunc::Avg, - OuterIntent::Min => AggFunc::Min, - OuterIntent::Max => AggFunc::Max, - OuterIntent::StdDev => AggFunc::StdDev { population: true }, - OuterIntent::Variance => AggFunc::Variance { population: true }, - OuterIntent::Quantile(q) => AggFunc::Quantile(*q), - OuterIntent::Group => AggFunc::Group, + OuterIntent::Sum => AggIntent::Sum { col: None }, + OuterIntent::Avg => AggIntent::Avg { col: None }, + OuterIntent::Min => AggIntent::Min { col: None }, + OuterIntent::Max => AggIntent::Max { col: None }, + OuterIntent::StdDev => AggIntent::StdDev { + col: None, + population: true, + }, + OuterIntent::Variance => AggIntent::Variance { + col: None, + population: true, + }, + OuterIntent::Quantile(q) => AggIntent::Quantile { + col: None, + q: *q, + accuracy: current_accuracy(), + }, + OuterIntent::Group => AggIntent::Group, } } diff --git a/crates/frontend-sql/Cargo.toml b/crates/frontend-sql/Cargo.toml index 8792fd75..540a358f 100644 --- a/crates/frontend-sql/Cargo.toml +++ b/crates/frontend-sql/Cargo.toml @@ -4,10 +4,9 @@ version = "0.1.0" edition = "2021" # SQL front end: L1 (parse + plan via DataFusion) → L2 relational, then the -# shared L2→L3 converter in asap-types. Pulls DataFusion only — never promql-parser. +# shared L2→L3 converter — both in asap-types. Pulls DataFusion only — never promql-parser. [dependencies] asap-types = { path = "../types" } -asap-l2 = { path = "../l2" } datafusion = "43" [dev-dependencies] diff --git a/crates/frontend-sql/src/error.rs b/crates/frontend-sql/src/error.rs index 012c84d4..48c51e38 100644 --- a/crates/frontend-sql/src/error.rs +++ b/crates/frontend-sql/src/error.rs @@ -1,9 +1,11 @@ use std::fmt; -use asap_l2::ConvertError; +use asap_types::pre_asap::ResolveTreeError; -/// Errors from lowering a SQL query (L1 parse + plan via DataFusion → L2 → -/// shared L2→L3 convert). +/// Errors from lowering a SQL query (L1 parse + plan via DataFusion → +/// canonical L2, built directly → +/// [`resolve_root`](asap_types::pre_asap::resolve_root) binds it to L3, issue +/// #179). /// /// Carries no PromQL type — the SQL front end never depends on the PromQL /// parser. The language-neutral variants (`UnsupportedFeature` / `WrongLanguage` @@ -26,8 +28,9 @@ pub enum SqlError { UnsupportedFeature(String), /// The workload's query language is not SQL. WrongLanguage(String), - /// The L2→L3 converter failed (name resolution against the bound schema). - Convert(ConvertError), + /// Resolving the canonical L2 tree to L3 failed (name resolution against + /// the bound schema). + Convert(ResolveTreeError), } impl fmt::Display for SqlError { @@ -40,15 +43,15 @@ impl fmt::Display for SqlError { Self::UnsupportedDialect(d) => write!(f, "unsupported SQL dialect: {d}"), Self::UnsupportedFeature(m) => write!(f, "unsupported feature: {m}"), Self::WrongLanguage(l) => write!(f, "unsupported query language: {l}"), - Self::Convert(e) => write!(f, "L2→L3 conversion failed: {e}"), + Self::Convert(e) => write!(f, "L2→L3 resolution failed: {e}"), } } } impl std::error::Error for SqlError {} -impl From for SqlError { - fn from(e: ConvertError) -> Self { +impl From for SqlError { + fn from(e: ResolveTreeError) -> Self { Self::Convert(e) } } diff --git a/crates/frontend-sql/src/lib.rs b/crates/frontend-sql/src/lib.rs index 31e39dbe..188e0cf1 100644 --- a/crates/frontend-sql/src/lib.rs +++ b/crates/frontend-sql/src/lib.rs @@ -1,16 +1,17 @@ -//! SQL front end: L1 (parse + plan via DataFusion) → L2 relational, then the -//! shared L2→L3 [`convert_root`](asap_l2::convert_root). +//! SQL front end: L1 (parse + plan via DataFusion) → the canonical L2 shape, +//! built directly (issue #179) → [`resolve_root`]. //! -//! Emits the per-language -//! [`relational::QueryExpr`](asap_l2::relational); the shared -//! converter runs the [`Binder`](asap_l2::Binder) for -//! positional name resolution. Depends on DataFusion only — never on the PromQL -//! parser. +//! Emits [`L2QueryExpr`](asap_types::pre_asap::L2QueryExpr) itself — the +//! canonical `QueryExpr`, generic over an unresolved +//! [`ColumnRef`](asap_types::pre_asap::ColumnRef) — directly, rather than a +//! separate per-language relational tree; `resolve_root` runs the +//! [`Binder`](asap_types::pre_asap::Binder) for positional name resolution. +//! Depends on DataFusion only — never on the PromQL parser. pub mod error; pub mod sql; -use asap_l2::convert_root; +use asap_types::pre_asap::resolve_root; use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::{QueryLanguage, QueryWorkload, SqlDialect}; @@ -45,9 +46,9 @@ pub async fn lower_sql_dialect( accuracy: AccuracyTarget, ) -> Result { let l2 = SqlLowerer::with_dialect(catalog, dialect) - .lower(query) + .lower(query, &accuracy) .await?; - let l3 = convert_root(&l2, &accuracy)?; + let l3 = resolve_root(&l2)?; Ok(l3) } diff --git a/crates/frontend-sql/src/sql/mod.rs b/crates/frontend-sql/src/sql/mod.rs index f88352f4..b40b814e 100644 --- a/crates/frontend-sql/src/sql/mod.rs +++ b/crates/frontend-sql/src/sql/mod.rs @@ -1,12 +1,27 @@ -//! SQL → Layer-2 relational lowering. +//! SQL → the canonical, unresolved +//! [`L2QueryExpr`](asap_types::pre_asap::query_expr::L2QueryExpr) +//! (`QueryExpr`). //! -//! Parses SQL via DataFusion (over the catalog's registered tables), then walks -//! the unoptimized `LogicalPlan` and emits the language-independent -//! [`relational::QueryExpr`](asap_l2::relational) that -//! [`convert_root`](asap_l2::convert_root) lowers to -//! canonical L3. Positional column identity, accuracy threading, and the -//! window-over-aggregate fold all happen in that converter — this front end -//! only interprets SQL semantics into the shared L2 algebra. +//! Parses SQL via DataFusion (over the catalog's registered tables), then +//! walks the unoptimized `LogicalPlan` and emits `L2QueryExpr` nodes with +//! unresolved `ColumnRef`s directly (issue #179) — the same tree shape +//! [`resolve_root`](asap_types::pre_asap::resolve_root) binds to canonical, +//! positional `QueryExpr`. Unlike PromQL's front end, SQL's +//! `Aggregate` nodes need no reduction-shape decision at construction time — +//! DataFusion's `Aggregate` plan node is always `Reduction::Reduce`, never +//! PromQL's per-series `PerEntity` (there is no windowed/subquery child +//! concept in SQL) — so this front end always builds `Reduce` directly. It +//! does still have to fold a `WHERE` directly over a bare table scan onto +//! `Scan.predicates` itself (`filter_or_fold`) — canonical's invariant that a +//! `Filter` never sits directly over a `Scan` — since front ends producing +//! this shape are responsible for it now, not a converter. +//! +//! Heavy-hitter `topk` recognition (`ORDER BY count(...) DESC LIMIT k`) is +//! *not* done here: SQL emits a plain `Sort`/`Limit`, and the shared L3 +//! `canonicalize` pass (issue #34, run by `resolve_root`) recognises the +//! count-ranked shape positionally, so a SQL `ORDER BY`/`LIMIT` and a PromQL +//! `topk(...)` converge without either front end special-casing the other's +//! syntax. use std::sync::Arc; @@ -19,13 +34,15 @@ use datafusion::logical_expr::{ }; use datafusion::prelude::{SessionConfig, SessionContext}; -use asap_l2::relational::{ - AggFunc, AggItem, L2ProjectItem, L2SortKey, QueryExpr as L2, SourceSpec, +use asap_types::pre_asap::agg_intent::AggIntent; +use asap_types::pre_asap::query_expr::{ + GroupKeys, L2QueryExpr as L2, Predicate, ProjectItem, Reduction, SortKey, Source, }; use asap_types::pre_asap::schema::{DataType, Schema}; use asap_types::pre_asap::{ ColumnRef, CompareOp, JoinKind, L2Expr, L3Scalar, SetOpKind, WindowFuncKind, }; +use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; use crate::error::SqlError as LoweringError; @@ -38,9 +55,41 @@ pub use types::SqlCatalog; use self::expr::df_expr_to_l2; use self::types::{arrow_to_l3, schema_to_arrow}; -/// Lowers SQL strings to the Layer-2 [`relational::QueryExpr`] over a table -/// [`SqlCatalog`]. Call [`convert_root`](asap_l2::convert_root) -/// on the result for canonical L3. +std::thread_local! { + static ACCURACY: std::cell::RefCell = + const { std::cell::RefCell::new(AccuracyTarget::Exact) }; +} + +/// RAII guard installing `accuracy` as the ambient accuracy target for the +/// current thread's lowering, restoring the prior value on drop — same +/// ambient-thread-local shape as `asap_frontend_promql::promql`'s +/// `AccuracyGuard`, for the same reason: it injects `accuracy` into the deep +/// `lower_plan` recursion without a parameter on every one of its +/// signatures, consulted only at the couple of sites that build an +/// accuracy-bearing `AggIntent`. +struct AccuracyGuard(AccuracyTarget); + +impl AccuracyGuard { + fn install(accuracy: AccuracyTarget) -> Self { + let prev = ACCURACY.with(|a| a.replace(accuracy)); + AccuracyGuard(prev) + } +} + +impl Drop for AccuracyGuard { + fn drop(&mut self) { + ACCURACY.with(|a| *a.borrow_mut() = std::mem::replace(&mut self.0, AccuracyTarget::Exact)); + } +} + +fn current_accuracy() -> AccuracyTarget { + ACCURACY.with(|a| a.borrow().clone()) +} + +/// Lowers SQL strings to the canonical [`L2QueryExpr`](asap_types::pre_asap::L2QueryExpr) +/// over a table [`SqlCatalog`]. Call +/// [`resolve_root`](asap_types::pre_asap::resolve_root) on the result for +/// canonical L3. pub struct SqlLowerer<'a> { catalog: &'a SqlCatalog, dialect: SqlDialect, @@ -64,11 +113,19 @@ impl<'a> SqlLowerer<'a> { Self { catalog, dialect } } - /// Parse + lower a SQL query to Layer-2 relational form. - pub async fn lower(&self, sql: &str) -> Result { + /// Parse + lower a SQL query to the canonical L2 shape, threading + /// `accuracy` onto every approximate intent (`Count`, `Quantile`, + /// `Cardinality`) as it is built. + /// + /// The `AccuracyGuard` installs *after* the only `.await` point + /// (`ctx.sql`) — `lower_plan` itself is synchronous, so once it starts + /// there is no further suspension point that could move this task to a + /// different OS thread out from under a thread-local set beforehand. + pub async fn lower(&self, sql: &str, accuracy: &AccuracyTarget) -> Result { let ctx = self.build_context()?; let df = ctx.sql(sql).await?; let plan = df.into_unoptimized_plan(); + let _guard = AccuracyGuard::install(accuracy.clone()); self.lower_plan(&plan) } @@ -117,7 +174,7 @@ impl<'a> SqlLowerer<'a> { Distinct::On(_) => Err(LoweringError::UnsupportedFeature("DISTINCT ON".into())), Distinct::All(input) => Ok(L2::Distinct { cols: vec![], - input: Box::new(self.lower_plan(input)?), + child: Box::new(self.lower_plan(input)?), }), }, LogicalPlan::Union(u) => { @@ -160,10 +217,10 @@ impl<'a> SqlLowerer<'a> { match self.lower_plan(other)? { // The derived SELECT list already lowered to a // Projection — stamp the alias onto it, no extra node. - L2::Project { cols, input, .. } => Ok(L2::Project { + L2::Project { cols, child, .. } => Ok(L2::Project { cols, qualifier: Some(alias_name), - input, + child, }), // Otherwise (e.g. `SELECT *` unwrapped to a scan) wrap // in an identity projection that re-qualifies each @@ -174,7 +231,7 @@ impl<'a> SqlLowerer<'a> { .schema() .fields() .iter() - .map(|f| L2ProjectItem { + .map(|f| ProjectItem { alias: Some(f.name().clone()), expr: L2Expr::Column(ColumnRef::Named(f.name().clone())), }) @@ -182,7 +239,7 @@ impl<'a> SqlLowerer<'a> { Ok(L2::Project { cols, qualifier: Some(alias_name), - input: Box::new(inner), + child: Box::new(inner), }) } } @@ -205,8 +262,8 @@ impl<'a> SqlLowerer<'a> { /// /// The residual filter is applied **below** the joins, which is where it sat /// before: a semi-join only ever drops left rows, so the two orders agree — - /// and keeping `Filter` directly over the `Source` preserves the converter's - /// fold of predicates onto the `Scan`. + /// and keeping the fold-onto-`Scan` (`filter_or_fold`) below the joins + /// matches where the old converter folded it too. fn lower_filter(&self, filter: &logical_expr::Filter) -> Result { let mut conjuncts = Vec::new(); split_conjunction(&filter.predicate, &mut conjuncts); @@ -216,10 +273,7 @@ impl<'a> SqlLowerer<'a> { let input = self.lower_plan(&filter.input)?; let mut node = match rebuild_conjunction(&residual) { - Some(pred) => L2::Filter { - pred: df_expr_to_l2(&pred)?, - input: Box::new(input), - }, + Some(pred) => filter_or_fold(df_expr_to_l2(&pred)?, input), None => input, }; for sq in subqueries { @@ -271,25 +325,25 @@ impl<'a> SqlLowerer<'a> { // computed key (`SELECT bytes + 1 …`) is named rather than becoming // the anonymous `col_0` that nothing can reference. LogicalPlan::Projection(p) if p.expr.len() == 1 => L2::Project { - cols: vec![L2ProjectItem { + cols: vec![ProjectItem { alias: Some(IN_SUBQUERY_KEY.to_string()), expr: df_expr_to_l2(unalias(&p.expr[0]))?, }], qualifier: None, - input: Box::new(self.lower_plan(&p.input)?), + child: Box::new(self.lower_plan(&p.input)?), }, other => L2::Project { - cols: vec![L2ProjectItem { + cols: vec![ProjectItem { alias: Some(IN_SUBQUERY_KEY.to_string()), expr: L2Expr::Column(ColumnRef::Named(key.name().clone())), }], qualifier: None, - input: Box::new(self.lower_plan(other)?), + child: Box::new(self.lower_plan(other)?), }, }; Ok(L2::Join { kind: JoinKind::Semi, - pred: Some(L2Expr::Compare { + pred: Predicate(L2Expr::Compare { left: Box::new(df_expr_to_l2(&is.expr)?), op: CompareOp::Eq, right: Box::new(L2Expr::Column(ColumnRef::Named( @@ -320,7 +374,13 @@ impl<'a> SqlLowerer<'a> { // the join predicate. Whatever is left stays an ordinary inner filter. let (inner, correlation) = split_correlation(inner)?; let right = self.lower_plan(&inner)?; - let pred = correlation.map(|e| df_expr_to_l2(&e)).transpose()?; + // No correlation conjunct (a genuinely uncorrelated `EXISTS`) means + // the join condition is unconditionally true — same convention as an + // unconditional `JOIN` (`lower_join`, below). + let pred = match correlation { + Some(e) => Predicate(df_expr_to_l2(&e)?), + None => Predicate(L2Expr::Literal(L3Scalar::Boolean(true))), + }; Ok(L2::Join { kind, pred, @@ -329,15 +389,17 @@ impl<'a> SqlLowerer<'a> { }) } - /// Table leaf — carries the catalog's resolved schema so the L2→L3 Binder - /// has positional column identity. Projection pushdown is left to the - /// enclosing `Project` (DataFusion's unoptimized plan sets no projection). + /// Table leaf — carries the catalog's resolved schema directly on `Scan` + /// (`schema: Some(_)`), so `resolve_root`'s Binder doesn't need to + /// usage-derive it (SQL is never schemaless). Projection pushdown is left + /// to the enclosing `Project` (DataFusion's unoptimized plan sets no + /// projection). fn lower_table_scan(&self, scan: &logical_expr::TableScan) -> Result { let table = scan.table_name.to_string(); self.scan_source(&table, &table) } - /// A `Source` over catalog table `table`, with its columns qualified by + /// A `Scan` over catalog table `table`, with its columns qualified by /// `qualifier` (the table name, or an alias from a `SubqueryAlias`) so /// `Qualified` column refs resolve to the right side across a join. fn scan_source(&self, table: &str, qualifier: &str) -> Result { @@ -358,10 +420,13 @@ impl<'a> SqlLowerer<'a> { // Catalog-backed: the table's columns are fully declared → closed. closed: true, }; - Ok(L2::Source(SourceSpec::with_schema( - table.to_string(), - qualified, - ))) + Ok(L2::Scan { + source: Source::Table { + table_ref: table.to_string(), + }, + predicates: vec![], + schema: Some(qualified), + }) } /// ⋈ — equijoin. The `on` key pairs become `left = right` comparisons, @@ -395,11 +460,12 @@ impl<'a> SqlLowerer<'a> { if let Some(filter) = &join.filter { conjuncts.push(df_expr_to_l2(filter)?); } - let pred = match conjuncts.len() { - 0 => None, - 1 => Some(conjuncts.pop().unwrap()), - _ => Some(L2Expr::BoolAnd(conjuncts)), - }; + let pred = Predicate(match conjuncts.len() { + // No condition (a CROSS JOIN) is unconditionally true. + 0 => L2Expr::Literal(L3Scalar::Boolean(true)), + 1 => conjuncts.pop().unwrap(), + _ => L2Expr::BoolAnd(conjuncts), + }); Ok(L2::Join { kind, pred, @@ -417,7 +483,7 @@ impl<'a> SqlLowerer<'a> { window.window_expr.len() ))); } - let input = Box::new(self.lower_plan(&window.input)?); + let child = Box::new(self.lower_plan(&window.input)?); let first = window .window_expr .first() @@ -457,7 +523,7 @@ impl<'a> SqlLowerer<'a> { .order_by .iter() .map(|s| { - df_expr_to_l2(&s.expr).map(|expr| L2SortKey { + df_expr_to_l2(&s.expr).map(|expr| SortKey { expr, ascending: s.asc, nulls_first: s.nulls_first, @@ -475,10 +541,10 @@ impl<'a> SqlLowerer<'a> { Ok(L2::WindowFunc { func, args, - partition_by, + partition_by: partition_by.into(), order_by, output_name, - input, + child, }) } @@ -487,22 +553,22 @@ impl<'a> SqlLowerer<'a> { if proj.expr.iter().any(|e| matches!(e, Expr::Wildcard { .. })) { return self.lower_plan(&proj.input); } - let input = Box::new(self.lower_plan(&proj.input)?); + let child = Box::new(self.lower_plan(&proj.input)?); let cols = proj .expr .iter() .map(|e| match e { - Expr::Alias(a) => df_expr_to_l2(&a.expr).map(|expr| L2ProjectItem { + Expr::Alias(a) => df_expr_to_l2(&a.expr).map(|expr| ProjectItem { expr, alias: Some(a.name.clone()), }), - _ => df_expr_to_l2(e).map(|expr| L2ProjectItem { expr, alias: None }), + _ => df_expr_to_l2(e).map(|expr| ProjectItem { expr, alias: None }), }) .collect::, _>>()?; Ok(L2::Project { cols, qualifier: None, - input, + child, }) } @@ -559,13 +625,13 @@ impl<'a> SqlLowerer<'a> { .map(|e| derived.rewrite_agg(e)) .collect::, LoweringError>>()?; - let input = Box::new(derived.wrap(input)?); + let child = Box::new(derived.wrap(input)?); // DataFusion names the aggregate outputs in its own schema (e.g. // "sum(metrics.bytes)") — the same names the enclosing Projection // references. The schema is [group fields …, aggregate fields …], so - // skip the group fields and thread the rest as L2 aliases → L3 + // skip the group fields and thread the rest straight through as // `Aggregate.output_names`, letting that Projection resolve them. - let out_names: Vec = agg + let output_names: Vec = agg .schema .fields() .iter() @@ -574,22 +640,18 @@ impl<'a> SqlLowerer<'a> { .collect(); let measures = aggr_expr .iter() - .enumerate() - .map(|(i, e)| { - let mut item = lower_agg_item(e)?; - if let Some(name) = out_names.get(i) { - item.alias = Some(name.clone()); - } - Ok(item) - }) + .map(lower_agg_intent) .collect::, LoweringError>>()?; Ok(L2::Aggregate { - keys, - // SQL `GROUP BY` is always an inclusion list — there is no `without`. - without: false, + // SQL `GROUP BY` is always an inclusion list, never PromQL's + // `without(...)` exclusion form — and always a genuine reduction, + // never `PerEntity` (there's no windowed/subquery-child concept + // in SQL for that to apply to). + reduction: Reduction::Reduce(GroupKeys::by(keys)), measures, + output_names, having: None, - input, + child, }) } @@ -643,7 +705,7 @@ impl<'a> SqlLowerer<'a> { .map(|f| Ok((f.name().to_string(), arrow_to_l3(f.data_type())?))) .collect::>()?; - let out_names: Vec = agg + let output_names: Vec = agg .schema .fields() .iter() @@ -664,14 +726,7 @@ impl<'a> SqlLowerer<'a> { .collect::, LoweringError>>()?; let measures = aggr_expr .iter() - .enumerate() - .map(|(i, e)| { - let mut item = lower_agg_item(e)?; - if let Some(name) = out_names.get(i) { - item.alias = Some(name.clone()); - } - Ok(item) - }) + .map(lower_agg_intent) .collect::, LoweringError>>()?; let input = derived.wrap(input)?; @@ -684,17 +739,17 @@ impl<'a> SqlLowerer<'a> { .map(|e| expr_to_group_ref(e)) .collect::, LoweringError>>()?; let aggregate = L2::Aggregate { - keys: level_keys, - without: false, + reduction: Reduction::Reduce(GroupKeys::by(level_keys)), measures: measures.clone(), + output_names: output_names.clone(), having: None, - input: Box::new(input.clone()), + child: Box::new(input.clone()), }; // Reinstate omitted keys as typed nulls, in canonical order. let cols = keys .iter() .zip(&distinct) - .map(|((name, dtype), e)| L2ProjectItem { + .map(|((name, dtype), e)| ProjectItem { alias: Some(name.clone()), expr: if level.contains(e) { L2Expr::Column(ColumnRef::Named(name.clone())) @@ -706,7 +761,7 @@ impl<'a> SqlLowerer<'a> { } }, }) - .chain(out_names.iter().map(|n| L2ProjectItem { + .chain(output_names.iter().map(|n| ProjectItem { alias: Some(n.clone()), expr: L2Expr::Column(ColumnRef::Named(n.clone())), })) @@ -714,12 +769,12 @@ impl<'a> SqlLowerer<'a> { Ok(L2::Project { cols, qualifier: None, - input: Box::new(aggregate), + child: Box::new(aggregate), }) }) .collect::, LoweringError>>()?; - Ok(L2::Merge { inputs: branches }) + Ok(L2::Merge { children: branches }) } fn lower_sort(&self, sort: &logical_expr::Sort) -> Result { @@ -733,7 +788,7 @@ impl<'a> SqlLowerer<'a> { .expr .iter() .map(|s| { - df_expr_to_l2(&s.expr).map(|expr| L2SortKey { + df_expr_to_l2(&s.expr).map(|expr| SortKey { expr, ascending: s.asc, nulls_first: s.nulls_first, @@ -744,8 +799,8 @@ impl<'a> SqlLowerer<'a> { keys, // SQL `ORDER BY` is a global sort; per-group ranking would come from a // window function (`WindowFunc`), not a bare Sort. - partition_by: vec![], - input: Box::new(self.lower_plan(&sort.input)?), + partition_by: GroupKeys::none(), + child: Box::new(self.lower_plan(&sort.input)?), }) } @@ -753,21 +808,24 @@ impl<'a> SqlLowerer<'a> { // Count-ranked `LIMIT k` over a `Sort` is promoted to the heavy-hitter // `TopK` by the shared L3 `canonicalize` pass (issue #34), not here. Ok(L2::Limit { - n: eval_fetch(&limit.fetch).unwrap_or(usize::MAX) as u64, - offset: eval_fetch(&limit.skip).unwrap_or(0) as u64, - input: Box::new(self.lower_plan(&limit.input)?), + n: eval_fetch(&limit.fetch).unwrap_or(usize::MAX), + offset: eval_fetch(&limit.skip).unwrap_or(0), + child: Box::new(self.lower_plan(&limit.input)?), }) } } // ── Aggregate / group-key helpers ─────────────────────────────────────────────── -/// Map a DataFusion aggregate expression to a relational [`AggItem`]. The -/// L2→L3 converter resolves the input column to a positional id and applies the -/// workload accuracy target — so this only picks the `AggFunc` + input column. -fn lower_agg_item(expr: &Expr) -> Result { +/// Map a DataFusion aggregate expression directly to the canonical +/// [`AggIntent`] — issue #179's "dedicated function → canonical +/// intent directly" front-end construction, no `AggFunc` intermediate. +/// `resolve_root` resolves `col` to a positional `ColumnId`; the output name +/// (DataFusion's own, e.g. `"sum(metrics.bytes)"`) is threaded separately as +/// `Aggregate.output_names`, not carried here. +fn lower_agg_intent(expr: &Expr) -> Result, LoweringError> { match expr { - Expr::Alias(a) => lower_agg_item(&a.expr), + Expr::Alias(a) => lower_agg_intent(&a.expr), Expr::AggregateFunction(agg_fn) => { let name = agg_fn.func.name().to_lowercase(); // L3 has no DISTINCT modifier for the value reducers; only @@ -781,52 +839,67 @@ fn lower_agg_item(expr: &Expr) -> Result { // Value reducers (`reducer_col`) require a real column — `SUM(a*b)` // is rejected, not silently reduced over a probe column. Quantile // and CountDistinct reduce a column too, so they take the same path: - // at L3 their `col` is `Option` where `None` means "the + // `col` is `Option` once resolved, where `None` means "the // PromQL sample value", which a SQL query never has. Taking an // expression here would set `col: None` and silently drop it (#115). - let (func, col) = match name.as_str() { - "count" if agg_fn.distinct => { - (AggFunc::CountDistinct, reducer_col(&name, &agg_fn.args)?) - } - "count" => (AggFunc::Count, ColumnRef::Wildcard), - "sum" => (AggFunc::Sum, reducer_col(&name, &agg_fn.args)?), - "min" => (AggFunc::Min, reducer_col(&name, &agg_fn.args)?), - "max" => (AggFunc::Max, reducer_col(&name, &agg_fn.args)?), - "avg" | "mean" => (AggFunc::Avg, reducer_col(&name, &agg_fn.args)?), - "stddev" | "stddev_samp" => ( - AggFunc::StdDev { population: false }, - reducer_col(&name, &agg_fn.args)?, - ), - "stddev_pop" => ( - AggFunc::StdDev { population: true }, - reducer_col(&name, &agg_fn.args)?, - ), - "var" | "variance" | "var_samp" => ( - AggFunc::Variance { population: false }, - reducer_col(&name, &agg_fn.args)?, - ), - "var_pop" => ( - AggFunc::Variance { population: true }, - reducer_col(&name, &agg_fn.args)?, - ), - "approx_percentile_cont" | "percentile_cont" => ( - AggFunc::Quantile(extract_percentile_q(&agg_fn.args)?), - reducer_col(&name, &agg_fn.args)?, - ), + let col = |args: &[Expr]| -> Result, LoweringError> { + reducer_col(&name, args).map(Some) + }; + Ok(match name.as_str() { + "count" if agg_fn.distinct => AggIntent::Cardinality { + col: col(&agg_fn.args)?, + accuracy: current_accuracy(), + }, + "count" => AggIntent::Count { + accuracy: current_accuracy(), + }, + "sum" => AggIntent::Sum { + col: col(&agg_fn.args)?, + }, + "min" => AggIntent::Min { + col: col(&agg_fn.args)?, + }, + "max" => AggIntent::Max { + col: col(&agg_fn.args)?, + }, + "avg" | "mean" => AggIntent::Avg { + col: col(&agg_fn.args)?, + }, + "stddev" | "stddev_samp" => AggIntent::StdDev { + col: col(&agg_fn.args)?, + population: false, + }, + "stddev_pop" => AggIntent::StdDev { + col: col(&agg_fn.args)?, + population: true, + }, + "var" | "variance" | "var_samp" => AggIntent::Variance { + col: col(&agg_fn.args)?, + population: false, + }, + "var_pop" => AggIntent::Variance { + col: col(&agg_fn.args)?, + population: true, + }, + "approx_percentile_cont" | "percentile_cont" => AggIntent::Quantile { + col: col(&agg_fn.args)?, + q: extract_percentile_q(&agg_fn.args)?, + accuracy: current_accuracy(), + }, // `median(c)` is the φ=0.5 quantile. As with `approx_distinct` and // `approx_percentile_cont`, the `approx_` prefix does not force an // approximation: the sketch-vs-exact choice is the AccuracyTarget's // (see `plan::boundary`), so both spellings share one intent (#111). - "median" | "approx_median" => { - (AggFunc::Quantile(0.5), reducer_col(&name, &agg_fn.args)?) - } - "approx_distinct" => (AggFunc::CountDistinct, reducer_col(&name, &agg_fn.args)?), + "median" | "approx_median" => AggIntent::Quantile { + col: col(&agg_fn.args)?, + q: 0.5, + accuracy: current_accuracy(), + }, + "approx_distinct" => AggIntent::Cardinality { + col: col(&agg_fn.args)?, + accuracy: current_accuracy(), + }, _ => return Err(LoweringError::UnsupportedAggregate(name)), - }; - Ok(AggItem { - alias: Some(name), - func, - col, }) } _ => Err(LoweringError::UnsupportedAggregate(format!("{expr:?}"))), @@ -837,6 +910,32 @@ fn lower_agg_item(expr: &Expr) -> Result { /// predicate cannot bind it to a same-named column of the outer relation. const IN_SUBQUERY_KEY: &str = "__asap_in_key"; +/// Fold `pred` directly onto `child.predicates` when `child` is a bare `Scan` +/// (a `WHERE` directly over a table), otherwise wrap it in an ordinary +/// `Filter` — canonical's invariant that a `Filter` never sits directly over a +/// `Scan`. A front end emitting the canonical shape directly is responsible +/// for maintaining that invariant itself (issue #179). +fn filter_or_fold(pred: L2Expr, child: L2) -> L2 { + match child { + L2::Scan { + source, + mut predicates, + schema, + } => { + predicates.push(Predicate(pred)); + L2::Scan { + source, + predicates, + schema, + } + } + other => L2::Filter { + pred: Predicate(pred), + child: Box::new(other), + }, + } +} + /// Flatten a top-level `AND` chain into its conjuncts. fn split_conjunction<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) { match expr { @@ -998,7 +1097,7 @@ fn expand_grouping_set(gs: &logical_expr::GroupingSet) -> Vec> { /// extending it. #[derive(Default)] struct DerivedCols { - cols: Vec, + cols: Vec>, /// Whether any column is genuinely derived. Without one the aggregate keeps /// its original child, so trees that lower today keep their exact shape. any: bool, @@ -1023,7 +1122,7 @@ impl DerivedCols { Some(_) => { self.collision.get_or_insert(alias); } - None => self.cols.push(L2ProjectItem { + None => self.cols.push(ProjectItem { alias: Some(alias), expr, }), @@ -1093,7 +1192,7 @@ impl DerivedCols { Ok(L2::Project { cols: self.cols, qualifier: None, - input: Box::new(input), + child: Box::new(input), }) } } diff --git a/crates/frontend-sql/src/sql/types.rs b/crates/frontend-sql/src/sql/types.rs index 594048ee..6eaacb6e 100644 --- a/crates/frontend-sql/src/sql/types.rs +++ b/crates/frontend-sql/src/sql/types.rs @@ -17,8 +17,9 @@ use crate::error::SqlError as LoweringError; /// Table catalog for SQL lowering: table name → resolved L3 [`Schema`]. /// /// Used twice: to register Arrow-backed `MemTable`s so DataFusion can resolve -/// `SELECT … FROM t`, and to attach each table's schema to the relational -/// `SourceSpec` so the L2→L3 Binder/converter has positional column identity. +/// `SELECT … FROM t`, and to attach each table's schema directly onto the +/// canonical `Scan` (`schema: Some(_)`) so the Binder doesn't need to +/// usage-derive it. #[derive(Debug, Clone, Default)] pub struct SqlCatalog { pub tables: HashMap, diff --git a/crates/frontend-sql/tests/sql_lowering.rs b/crates/frontend-sql/tests/sql_lowering.rs index b930bbe8..26d29b7b 100644 --- a/crates/frontend-sql/tests/sql_lowering.rs +++ b/crates/frontend-sql/tests/sql_lowering.rs @@ -1,8 +1,9 @@ //! End-to-end SQL → L2 → canonical L3 lowering tests (positional IR). //! -//! Validates the re-targeted DataFusion front end: SQL parses + plans, lowers to -//! the relational L2 algebra, and the shared `convert_root` produces positional -//! canonical L3 (the same converter the PromQL path uses). +//! Validates the DataFusion front end: SQL parses + plans, lowers directly to +//! the canonical L2 shape (`QueryExpr`, issue #179), and the shared +//! `resolve_root` produces positional canonical L3 (the same resolver the +//! PromQL path uses). use asap_frontend_sql::{lower_sql, SqlCatalog}; use asap_types::pre_asap::schema::{Column, DataType, Schema}; diff --git a/crates/l2/Cargo.toml b/crates/l2/Cargo.toml deleted file mode 100644 index 7979b1a1..00000000 --- a/crates/l2/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "asap-l2" -version = "0.1.0" -edition = "2021" - -# The L2 per-language relational algebra + the L2→L3 converter (convert_root, -# binder, column resolution). Front ends emit L2 and call convert_root; this -# crate owns both. Depends only on the shared types crate (asap-types). -[dependencies] -asap-types = { path = "../types" } -thiserror = "2" diff --git a/crates/l2/src/lib.rs b/crates/l2/src/lib.rs deleted file mode 100644 index 5102519b..00000000 --- a/crates/l2/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! `asap-l2` — the Layer-2 relational algebra + the L2→L3 converter. -//! -//! The parser front ends emit the per-language [`relational::QueryExpr`] tree; -//! [`convert_root`] lowers it to the canonical L3 [`QueryExpr`] in -//! [`asap_ir`], running the [`Binder`] for positional name resolution and -//! folding single-statistic sketchable aggregates into canonical shapes. -//! -//! This crate owns L2 *and* the converter because the converter needs both L2 -//! and L3; it depends only on the L3 IR crate. Downstream consumers that reason -//! about L3 only (optimizer, sketch) depend on `asap-ir` directly and never -//! pull this lowering machinery. - -pub mod binder; -pub mod canonicalize; -pub mod column_resolution; -pub mod lower; -pub mod relational; - -pub use binder::{Binder, SchemaCatalog, UsageDerivedCatalog}; -pub use canonicalize::canonicalize; -pub use column_resolution::{ - infer_schema_for_root, infer_source_schema, output_schema_for_aggregate, resolve_column_ref, - resolve_column_refs, resolve_expr, ResolveError, -}; -pub use lower::{convert, convert_root, ConvertError}; diff --git a/crates/l2/src/lower.rs b/crates/l2/src/lower.rs deleted file mode 100644 index 89b73386..00000000 --- a/crates/l2/src/lower.rs +++ /dev/null @@ -1,999 +0,0 @@ -//! Layer-2 → canonical L3 IR converter. -//! -//! Recursively converts a whole [`relational::QueryExpr`] tree into a whole -//! [`query_expr::QueryExpr`] tree. The single-statistic sketchable `Aggregate` -//! fuses directly in canonical terms (window-swap, positional `Aggregate.by`); -//! see the `Aggregate` arm. -//! -//! Name resolution is an explicit pass: [`convert_root`] runs the -//! [`Binder`](crate::binder) first to build the complete, self-contained -//! schema every `ColumnId` indexes into, so positional resolution downstream -//! is total. - -use std::time::Duration; - -use thiserror::Error; - -use crate::binder::{collect_referenced_columns, Binder}; -use crate::column_resolution::{ - output_schema_for_aggregate, resolve_column_refs, resolve_expr, resolve_group_keys_promql, - ResolveError, -}; -use crate::relational::{AggFunc, QueryExpr as LQueryExpr, SourceSpec}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::expr_ir::{ColumnRef, L2Expr, L3Expr, L3Scalar}; -use asap_types::pre_asap::query_expr::{ - GroupKeys, Predicate, ProjectItem, QueryExpr as CQueryExpr, Reduction, SortKey, Source, -}; -use asap_types::pre_asap::schema::{ColumnId, Schema}; -use asap_types::types::AccuracyTarget; - -/// Errors produced while converting a Layer-2 tree to canonical. -#[derive(Debug, Error)] -pub enum ConvertError { - /// A column reference (`Aggregate` key, `TopK` key, aggregate input column) - /// did not resolve against the child's derived schema. - #[error("column resolution failed: {0}")] - Resolve(#[from] ResolveError), - /// Deriving the schema of an already-converted child failed (needed to - /// resolve positional column references against it). - #[error("schema derivation failed: {0}")] - Schema(#[from] asap_types::pre_asap::query_expr::QueryExprError), - /// Group keys landed on a per-series windowed/range reduction, which must - /// stay label-preserving (`Aggregate.by` empty). The only PromQL shape that - /// would do this is a generic `topk by (…)`, whose grouping is routed to - /// `Sort.partition_by` instead (issue #12) — so this indicates an - /// unsupported query shape rather than a path that should silently group. - #[error( - "group keys on a per-series windowed reduction are unsupported \ - (per-group ranking must use Sort.partition_by — see issue #12)" - )] - WindowedReductionKeys, - /// A counter-derivative range function (`changes`/`delta`/`deriv`/…) reached - /// the converter without an enclosing `Window`, so no range could be - /// recovered. Emitting it range-less would silently drop its window, so this - /// signals a malformed L2 tree rather than a valid instant aggregate (#71). - #[error( - "counter-derivative range function has no window \ - (its range would be silently dropped)" - )] - RangelessRangeReduction, -} - -/// Lower a Layer-2 tree to canonical L3, threading `accuracy` onto every -/// approximate intent (`Count`, `Quantile`, `Cardinality`, `TopK`). -pub fn convert_root( - legacy: &LQueryExpr, - accuracy: &AccuracyTarget, -) -> Result { - convert_root_with_inherited(legacy, accuracy, &[]) -} - -/// [`convert_root`] with label names inherited from an enclosing scope seeded -/// into the leaf schema. Used when re-binding a `BinaryOp` side, so an outer -/// aggregate's group keys (`sum by (__name__)(a or b)`) resolve even though they -/// appear in neither side's own sub-tree (issue #52). -fn convert_root_with_inherited( - legacy: &LQueryExpr, - accuracy: &AccuracyTarget, - inherited: &[String], -) -> Result { - let fallback = Binder::new().bind_with_inherited(legacy, inherited); - let l3 = convert(legacy, &fallback, accuracy)?; - // Both language front ends end here, so this is the one place to normalize - // structural differences between equivalent queries (issue #34). - Ok(crate::canonicalize::canonicalize(l3)) -} - -/// Convert a Layer-2 tree to canonical L3. -/// -/// `fallback` is the leaf schema used for schema-less (PromQL) `Source`s — -/// the Binder's usage-derived `(ts, value)` floor + referenced labels. SQL -/// leaves carry their own resolved schema on [`SourceSpec::schema`], so the -/// fallback is unused for them. Positional column references (`Aggregate` -/// keys + input columns, `TopK` keys) resolve against the **converted child's -/// derived output schema**, so a `JOIN`'s concatenated schema and a table's -/// real columns bind to the right positions. -pub fn convert( - legacy: &LQueryExpr, - fallback: &Schema, - acc: &AccuracyTarget, -) -> Result { - Ok(match legacy { - LQueryExpr::Source(spec) => scan(spec, fallback, &[])?, - - LQueryExpr::Scalar(v) => CQueryExpr::Scalar(*v), - - LQueryExpr::EvalTime => CQueryExpr::EvalTime, - - // `vector(s)` / `scalar(v)` — the type-conversion bridges. Convert the - // child through the same fallback schema and wrap it (issue #48). - LQueryExpr::VectorFromScalar(inner) => { - CQueryExpr::VectorFromScalar(Box::new(convert(inner, fallback, acc)?)) - } - LQueryExpr::ScalarFromVector(inner) => { - CQueryExpr::ScalarFromVector(Box::new(convert(inner, fallback, acc)?)) - } - - // ρ — relabel. Resolve the label-value expression positionally against - // the child's schema (source labels seeded by the binder), then rewrite - // `dst` over the converted child (issue #50). - LQueryExpr::Relabel { dst, value, input } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - CQueryExpr::Relabel { - dst: dst.clone(), - value: resolve_expr(value, &child_schema)?, - child: Box::new(child), - } - } - - // Series sampling (`limitk`/`limit_ratio`) — resolve the grouping keys - // positionally, pass the sampled child through unchanged (issue #86). - LQueryExpr::Sample { keys, kind, input } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - CQueryExpr::Sample { - by: resolve_column_refs(keys, &child_schema)?.into(), - kind: *kind, - child: Box::new(child), - } - } - - // `info(v, [selector])` — label enrichment. The selector matchers are - // info-metric-side and symbolic (not resolved against the child), so - // they copy straight through; L4 resolves the join keys (issue #84). - LQueryExpr::InfoJoin { selector, input } => CQueryExpr::InfoJoin { - selector: selector.clone(), - child: Box::new(convert(input, fallback, acc)?), - }, - - // Fold label matchers / pushed-down predicates directly onto the Scan - // when the immediate child is a `Source`; otherwise emit a `Filter`. - // Predicate column refs resolve positionally against the input schema. - LQueryExpr::Filter { pred, input } => match input.as_ref() { - LQueryExpr::Source(spec) => scan(spec, fallback, pred.conjuncts())?, - other => { - let child = convert(other, fallback, acc)?; - let child_schema = child.output_schema()?; - CQueryExpr::Filter { - pred: Predicate(resolve_expr(pred, &child_schema)?), - child: Box::new(child), - } - } - }, - - LQueryExpr::Aggregate { - keys, - without, - measures, - having, - input, - } => { - // Single-statistic aggregate (no HAVING) over a *time-series* leaf - // fuses into the canonical shape: a `Window` input becomes - // `Window { Aggregate { by: [] } }` (a per-series, label-preserving - // reduction — see `output_schema`'s `Window` arm). GROUP BY keys - // resolve *positionally* into `Aggregate.by` — the same shape SQL - // produces — whenever they're in scope: an instant selector, or a - // label-preserving per-series `rate`/`increase`/`*_over_time`. A - // windowed reduction must stay label-preserving (no group keys); the - // one PromQL shape that would group here, generic `topk by (…)`, - // routes its grouping to `Sort.partition_by` instead (see below). - // The reducer's input column resolves against the aggregate's - // *direct* input (the scan under any window). (SQL GROUP BY is - // tabular, so it skips this branch for the plain `Aggregate.by` path - // below.) - if measures.len() == 1 && having.is_none() && !input.leaf_is_tabular() { - // Extract the temporal range. Two sources: - // 1. An L2 `Window` child (emitted for `*_over_time` functions). - // 2. `Rate`/`Increase` AggFunc (carry their own range; no L2 Window). - // The range becomes a `TimeRange` node wrapping the inner scan - // rather than a `Window` node above the aggregate — `Window` is - // reserved for streaming query-repetition semantics. - let (agg_input_l2, time_range): (&LQueryExpr, Option) = - match input.as_ref() { - LQueryExpr::Window { - duration, - input: win_input, - .. - } => (win_input, Some(*duration)), - // A `PromQLSubquery` is itself the range context (a range - // function over a sub-query, `f([range:res])` — - // issues #42/#55). No separate `TimeRange`, and - // `Rate`/`Increase`'s carried window is subsumed by the - // sub-query's range. - other @ LQueryExpr::PromQLSubquery { .. } => (other, None), - other => { - let range = match &measures[0].func { - AggFunc::Rate { window } | AggFunc::Increase { window } => { - Some(*window) - } - _ => None, - }; - (other, range) - } - }; - // A counter-derivative range function is per-series over a range - // and always arrives under an L2 `Window` (or, for a sub-query - // argument, over a `PromQLSubquery` — handled above). If one - // reaches here range-less over anything else, emitting it without - // a `TimeRange` would silently drop its window — reject the - // malformed tree instead (issue #71). - if time_range.is_none() - && !matches!(agg_input_l2, LQueryExpr::PromQLSubquery { .. }) - && matches!( - &measures[0].func, - AggFunc::Changes - | AggFunc::Delta - | AggFunc::IDelta - | AggFunc::Deriv - | AggFunc::Resets - | AggFunc::PredictLinear { .. } - | AggFunc::DoubleExpSmoothing { .. } - | AggFunc::LastOverTime - | AggFunc::FirstOverTime - | AggFunc::MadOverTime - | AggFunc::TsOfMinOverTime - | AggFunc::TsOfMaxOverTime - | AggFunc::TsOfFirstOverTime - | AggFunc::TsOfLastOverTime - ) - { - return Err(ConvertError::RangelessRangeReduction); - } - let agg_child_raw = convert(agg_input_l2, fallback, acc)?; - let agg_in_schema = agg_child_raw.output_schema()?; - let intent = agg_func_to_intent( - &measures[0].func, - acc, - resolve_agg_col(&measures[0].col, &agg_in_schema)?, - ); - // Wrap the child in TimeRange when there is a range. - let agg_child = match time_range { - Some(range) => CQueryExpr::TimeRange { - range, - child: Box::new(agg_child_raw), - }, - None => agg_child_raw, - }; - // Resolve the group keys positionally against the aggregate's - // input so the grouping lives in `Aggregate.reduction` — the - // *same* shape SQL produces. Only for instant (non-range) - // aggregates: an instant aggregate (`sum by (job) (m)`) or a - // cross-series reduction over a label-preserving `rate`/`increase`. - // - // A range reduction's keys can't go into `by`: this node must stay - // a per-entity (label-preserving) reduction. So a windowed - // reduction must carry no group keys: the only PromQL shape that - // would put keys here is a generic `topk by (…)`, and that routes - // its grouping to `Sort.partition_by` instead (issue #12). Any keys - // still reaching a windowed reduction are an unsupported shape — - // surface it rather than silently dropping the grouping. - if time_range.is_some() && !keys.is_empty() { - return Err(ConvertError::WindowedReductionKeys); - } - // PromQL absent-label grouping semantics (issue #53): a key - // provably absent from a *closed* input schema (e.g. the output - // of a nested cross-series aggregate that collapsed the label) - // groups every series into one partition and is omitted from - // the output — drop it instead of rejecting the query. - let by = group_keys(resolve_group_keys_promql(keys, &agg_in_schema)?, *without); - // Decide the reduction kind once, right here, rather than - // leaving a downstream consumer to re-derive it (issue #165): - // a per-entity reduction is a single intent with no grouping - // keys at all, whose intent is inherently per-series - // (`rate`/`increase`/…) or whose child is a range-window - // wrapper (`TimeRange`/`Subquery` — the `*_over_time` family). - // `without ()` is still a genuine reduction (groups by every - // label), never per-entity, even with an empty exclusion list. - let is_range_child = matches!( - agg_child, - CQueryExpr::TimeRange { .. } | CQueryExpr::Subquery { .. } - ); - let per_entity = - by.is_empty() && !by.is_without() && (intent.is_per_series() || is_range_child); - let reduction = if per_entity { - Reduction::PerEntity - } else { - Reduction::Reduce(by) - }; - return Ok(CQueryExpr::Aggregate { - reduction, - measures: vec![intent], - output_names: vec![measures[0].alias.clone().unwrap_or_default()], - having: None, - child: Box::new(agg_child), - }); - } - - // Plain canonical `Aggregate`: multi-agg or HAVING-bearing. Keys + - // per-reducer input columns resolve against the child's (input) - // schema; HAVING references the aggregate's *output* columns, so it - // resolves against the derived output schema instead. - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - let by = group_keys(resolve_column_refs(keys, &child_schema)?, *without); - let intents: Vec = measures - .iter() - .map(|item| -> Result { - let col = resolve_agg_col(&item.col, &child_schema)?; - Ok(agg_func_to_intent(&item.func, acc, col)) - }) - .collect::, _>>()?; - let output_names: Vec = measures - .iter() - .map(|item| item.alias.clone().unwrap_or_default()) - .collect(); - let having = having - .as_ref() - .map(|h| -> Result { - let out_schema = - output_schema_for_aggregate(&child_schema, &by, &intents, &output_names)?; - Ok(Predicate(resolve_expr(h, &out_schema)?)) - }) - .transpose()?; - CQueryExpr::Aggregate { - // Always a genuine reduction: this branch is multi-intent or - // HAVING-bearing, and per-entity reductions are always a - // single, HAVING-less intent (handled above). - reduction: Reduction::Reduce(by), - measures: intents, - output_names, - having, - child: Box::new(child), - } - } - - // Standalone L2 Window (bare range vector `m[5m]` not inside a - // recognized single-stat Aggregate): map to TimeRange. - LQueryExpr::Window { - duration, input, .. - } => CQueryExpr::TimeRange { - range: *duration, - child: Box::new(convert(input, fallback, acc)?), - }, - - // π — resolve each project item's expression to positional against the - // child's schema. - LQueryExpr::Project { - cols, - qualifier, - input, - } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - let cols = cols - .iter() - .map(|item| -> Result { - Ok(ProjectItem { - alias: item.alias.clone(), - expr: resolve_expr(&item.expr, &child_schema)?, - }) - }) - .collect::, _>>()?; - CQueryExpr::Project { - cols, - qualifier: qualifier.clone(), - child: Box::new(child), - } - } - - LQueryExpr::Distinct { cols, input } => { - // Resolve the L2 (name-based) dedup keys to positional ids against - // the converted child's schema, like every other L3 column ref. - let child = convert(input, fallback, acc)?; - let cols = resolve_column_refs(cols, &child.output_schema()?)?; - CQueryExpr::Distinct { - cols, - child: Box::new(child), - } - } - - LQueryExpr::TopK { k, by, input } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - let by: GroupKeys = resolve_column_refs(by, &child_schema)?.into(); - CQueryExpr::Aggregate { - // A ranking always reduces (a `by`-empty TopK ranks the whole - // input into one ordering, never per-entity). - reduction: Reduction::Reduce(by), - measures: vec![AggIntent::TopK { - k: *k as usize, - accuracy: acc.clone(), - }], - output_names: vec![], - having: None, - child: Box::new(child), - } - } - - LQueryExpr::Merge { inputs } => CQueryExpr::Merge { - children: inputs - .iter() - .map(|i| convert(i, fallback, acc)) - .collect::, _>>()?, - }, - - LQueryExpr::Join { - kind, - pred, - left, - right, - } => { - // Each branch is bound independently (different leaves / label sets). - let left = convert_root(left, acc)?; - let right = convert_root(right, acc)?; - // The join predicate resolves against the concatenated left++right - // schema (the Join's own output shape), so left refs land at - // 0..left_len and right refs at left_len.. . - let mut concat = left.output_schema()?; - concat.columns.extend(right.output_schema()?.columns); - let pred = match pred { - Some(p) => Predicate(resolve_expr(p, &concat)?), - None => Predicate(L3Expr::Literal(L3Scalar::Boolean(true))), - }; - CQueryExpr::Join { - kind: kind.clone(), - pred, - left: Box::new(left), - right: Box::new(right), - } - } - - LQueryExpr::SetOp { - kind, - all, - left, - right, - } => CQueryExpr::SetOp { - kind: kind.clone(), - all: *all, - left: Box::new(convert_root(left, acc)?), - right: Box::new(convert_root(right, acc)?), - }, - - LQueryExpr::Sort { - keys, - partition_by, - input, - } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - let keys = keys - .iter() - .map(|k| -> Result { - Ok(SortKey { - expr: resolve_expr(&k.expr, &child_schema)?, - ascending: k.ascending, - nulls_first: k.nulls_first, - }) - }) - .collect::, _>>()?; - // Per-group ordering keys (`topk by (…)`) resolve positionally - // against the child schema — the per-series reduction below - // preserves its label columns, so the grouping label is present. - let partition_by: GroupKeys = resolve_column_refs(partition_by, &child_schema)?.into(); - CQueryExpr::Sort { - keys, - partition_by, - child: Box::new(child), - } - } - - LQueryExpr::Limit { n, offset, input } => CQueryExpr::Limit { - n: *n as usize, - offset: *offset as usize, - child: Box::new(convert(input, fallback, acc)?), - }, - - LQueryExpr::PromQLSubquery { - range, - resolution, - input, - } => CQueryExpr::Subquery { - range: *range, - resolution: *resolution, - child: Box::new(convert(input, fallback, acc)?), - }, - - // Analytic window: args / partition-by / order-by resolve positionally - // against the child's output schema. - LQueryExpr::WindowFunc { - func, - args, - partition_by, - order_by, - output_name, - input, - } => { - let child = convert(input, fallback, acc)?; - let child_schema = child.output_schema()?; - let args = args - .iter() - .map(|a| resolve_expr(a, &child_schema)) - .collect::, _>>()?; - let partition_by: GroupKeys = resolve_column_refs(partition_by, &child_schema)?.into(); - let order_by = order_by - .iter() - .map(|k| -> Result { - Ok(SortKey { - expr: resolve_expr(&k.expr, &child_schema)?, - ascending: k.ascending, - nulls_first: k.nulls_first, - }) - }) - .collect::, _>>()?; - CQueryExpr::WindowFunc { - func: func.clone(), - args, - partition_by, - order_by, - output_name: output_name.clone(), - child: Box::new(child), - } - } - - LQueryExpr::BinaryOp { - op, - lhs, - rhs, - vector_match, - } => { - // A binary op's two sides may scan different metrics with different - // label sets, so each branch must resolve against its OWN bound - // schema — `convert_root` re-runs the Binder per sub-tree; threading - // the parent `schema` (a superset over both leaves) would bind the - // right side's columns to the wrong positions. - // - // But an independently-bound side still has to see label names an - // *enclosing* node references — e.g. an outer `sum by (__name__)` / - // `sum by (job)` over `(a or b)` whose key is in neither side's own - // matchers (issue #52). `fallback` carries every name referenced in - // this scope; subtract the names referenced *within* the binary op - // itself and what remains is exactly the ancestor-referenced set to - // inherit — seeding a sibling side's own labels would wrongly leak - // them across the two branches. - let own = collect_referenced_columns(legacy); - let inherited: Vec = inherited_names(fallback) - .into_iter() - .filter(|n| !own.contains(n)) - .collect(); - CQueryExpr::BinaryOp { - op: op.clone(), - lhs: Box::new(convert_root_with_inherited(lhs, acc, &inherited)?), - rhs: Box::new(convert_root_with_inherited(rhs, acc, &inherited)?), - vector_match: vector_match.clone(), - } - } - }) -} - -/// Wrap resolved group-key ids in the `by`/`without` form (issue #39). PromQL -/// `without(labels)` stores the resolved *excluded* positions; every other -/// grouping is `by`. -fn group_keys(ids: Vec, without: bool) -> GroupKeys { - if without { - GroupKeys::without(ids) - } else { - GroupKeys::by(ids) - } -} - -/// The label names an enclosing scope's schema carries beyond the `(ts, value)` -/// floor — the set an independently-bound `BinaryOp` side must inherit so an -/// outer aggregate's group keys still resolve (issue #52). -fn inherited_names(schema: &Schema) -> Vec { - schema - .columns - .iter() - .filter(|c| c.name != "ts" && c.name != "value") - .map(|c| c.name.clone()) - .collect() -} - -/// Build a canonical `Scan`. A schema-bearing [`SourceSpec`] (SQL table) emits -/// a `Source::Table` carrying that resolved schema; a schema-less one (PromQL) -/// emits a `Source::TimeSeries` carrying the Binder's usage-derived `fallback`. -/// The L2 predicate conjuncts are resolved positionally against the leaf schema. -fn scan( - spec: &SourceSpec, - fallback: &Schema, - pred_conjuncts: &[L2Expr], -) -> Result { - let (source, schema) = match &spec.schema { - Some(s) => ( - Source::Table { - table_ref: spec.name.clone(), - }, - s.clone(), - ), - None => ( - Source::TimeSeries { - metric: spec.name.clone(), - }, - fallback.clone(), - ), - }; - let predicates = pred_conjuncts - .iter() - .map(|e| -> Result { Ok(Predicate(resolve_expr(e, &schema)?)) }) - .collect::, _>>()?; - let scan = CQueryExpr::Scan { - source, - predicates, - schema, - }; - // A non-identity `offset`/`@` lifts into a `TimeShift` wrapper over the scan; - // an unshifted selector stays a bare `Scan` (issue #40). - Ok(if spec.shift.is_identity() { - scan - } else { - CQueryExpr::TimeShift { - shift: spec.shift, - child: Box::new(scan), - } - }) -} - -/// Resolve a Layer-2 aggregate-input [`ColumnRef`] to a positional input -/// column. `SampleValue` / `Wildcard` carry no specific column → `Ok(None)` -/// (the PromQL sample-value / `COUNT(*)` convention); a `Named` column -/// (`SUM(bytes)`) must resolve to its position, else it is an error — silently -/// dropping it to `None` would reduce the wrong column (the schema probe). -fn resolve_agg_col(col: &ColumnRef, schema: &Schema) -> Result, ResolveError> { - match col { - ColumnRef::Named(name) => { - schema - .column_id(name) - .map(Some) - .ok_or_else(|| ResolveError::NotFound { - name: name.clone(), - available: schema.columns.iter().map(|c| c.name.clone()).collect(), - }) - } - ColumnRef::Qualified { table, name } => schema - .column_id_qualified(table, name) - .or_else(|| schema.column_id(name)) - .map(Some) - .ok_or_else(|| ResolveError::NotFound { - name: format!("{table}.{name}"), - available: schema.columns.iter().map(|c| c.name.clone()).collect(), - }), - ColumnRef::SampleValue | ColumnRef::Wildcard => Ok(None), - } -} - -/// Map a Layer-2 [`AggFunc`] to its canonical [`AggIntent`], threading the -/// workload's accuracy target onto the approximate intents and the resolved -/// input column (`col`) onto the single-column reducers. `col = None` is the -/// PromQL sample-value convention. -fn agg_func_to_intent(func: &AggFunc, acc: &AccuracyTarget, col: Option) -> AggIntent { - match func { - AggFunc::Count => AggIntent::Count { - accuracy: acc.clone(), - }, - AggFunc::Sum => AggIntent::Sum { col }, - AggFunc::Avg => AggIntent::Avg { col }, - AggFunc::Min => AggIntent::Min { col }, - AggFunc::Max => AggIntent::Max { col }, - AggFunc::StdDev { population } => AggIntent::StdDev { - col, - population: *population, - }, - AggFunc::Variance { population } => AggIntent::Variance { - col, - population: *population, - }, - AggFunc::Quantile(q) => AggIntent::Quantile { - col, - q: *q, - accuracy: acc.clone(), - }, - AggFunc::CountDistinct => AggIntent::Cardinality { - col, - accuracy: acc.clone(), - }, - AggFunc::HeavyHitters { k } => AggIntent::TopK { - k: *k as usize, - accuracy: acc.clone(), - }, - // Range is on the enclosing TimeRange node; intent carries no window. - AggFunc::Rate { .. } => AggIntent::Rate, - AggFunc::Increase { .. } => AggIntent::Increase, - // Counter-derivative range functions (issue #44) — the window rides on - // the enclosing TimeRange node; scalar params (predict horizon, - // smoothing factors) are carried in the intent. - AggFunc::Changes => AggIntent::Changes, - AggFunc::Delta => AggIntent::Delta, - AggFunc::IDelta => AggIntent::IDelta, - AggFunc::Deriv => AggIntent::Deriv, - AggFunc::Resets => AggIntent::Resets, - AggFunc::PredictLinear { seconds } => AggIntent::PredictLinear { seconds: *seconds }, - AggFunc::DoubleExpSmoothing { smoothing, trend } => AggIntent::DoubleExpSmoothing { - smoothing: *smoothing, - trend: *trend, - }, - AggFunc::HistogramCount => AggIntent::HistogramCount, - AggFunc::HistogramSum => AggIntent::HistogramSum, - AggFunc::HistogramAvg => AggIntent::HistogramAvg, - AggFunc::HistogramStdDev => AggIntent::HistogramStdDev, - AggFunc::HistogramStdVar => AggIntent::HistogramStdVar, - AggFunc::HistogramFraction { lower, upper } => AggIntent::HistogramFraction { - lower: *lower, - upper: *upper, - }, - AggFunc::HistogramQuantile(q) => AggIntent::HistogramQuantile { q: *q }, - AggFunc::Math(m) => AggIntent::Math(m.clone()), - AggFunc::Absent => AggIntent::Absent, - AggFunc::AbsentOverTime => AggIntent::AbsentOverTime, - AggFunc::PresentOverTime => AggIntent::PresentOverTime, - AggFunc::TimeFn(f) => AggIntent::TimeFn(*f), - AggFunc::Group => AggIntent::Group, - AggFunc::CountValues { label } => AggIntent::CountValues { - label: label.clone(), - }, - AggFunc::LastOverTime => AggIntent::LastOverTime, - AggFunc::FirstOverTime => AggIntent::FirstOverTime, - AggFunc::MadOverTime => AggIntent::MadOverTime, - AggFunc::TsOfMinOverTime => AggIntent::TsOfMinOverTime, - AggFunc::TsOfMaxOverTime => AggIntent::TsOfMaxOverTime, - AggFunc::TsOfFirstOverTime => AggIntent::TsOfFirstOverTime, - AggFunc::TsOfLastOverTime => AggIntent::TsOfLastOverTime, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::relational::{AggFunc, AggItem, QueryExpr as LQueryExpr, SourceSpec}; - use asap_types::pre_asap::agg_intent::AggIntent; - use asap_types::pre_asap::expr_ir::{CompareOp, L2Expr, L3Expr, L3Scalar}; - use asap_types::pre_asap::query_expr::{JoinKind, QueryExpr as CQueryExpr}; - use asap_types::pre_asap::schema::{Column, DataType, Schema}; - - fn col(name: &str, dtype: DataType) -> Column { - Column::new(name, dtype, false) - } - - /// A SQL-shaped `SELECT SUM(bytes), AVG(latency) FROM t` lowers each - /// reducer onto its own input column (positional), and the derived output - /// schema types each result off that column (`SUM(bytes:Int64)→Int64`). - #[test] - fn counter_derivative_without_window_is_rejected() { - // A counter-derivative (`Changes`) is per-series over a range and must - // arrive under an L2 `Window`. If it reaches the converter range-less - // (no `Window`, and unlike `Rate`/`Increase` it carries no window in its - // `AggFunc`), emitting it without a `TimeRange` would silently drop its - // window — the malformed tree is rejected instead (#71). - let schema = Schema::with_time_index( - vec![ - col("ts", DataType::Timestamp), - col("value", DataType::Float64), - ], - 0, - vec![], - ); - let tree = LQueryExpr::Aggregate { - keys: vec![], - without: false, - measures: vec![AggItem { - alias: None, - func: AggFunc::Changes, - col: ColumnRef::SampleValue, - }], - having: None, - input: Box::new(LQueryExpr::Source(SourceSpec::new("m"))), // NO Window - }; - assert!(matches!( - convert(&tree, &schema, &AccuracyTarget::Exact), - Err(ConvertError::RangelessRangeReduction) - )); - } - - #[test] - fn multi_column_aggregate_threads_per_agg_col() { - let schema = Schema::with_time_index( - vec![ - col("ts", DataType::Timestamp), - col("bytes", DataType::Int64), - col("latency", DataType::Float64), - col("value", DataType::Float64), - ], - 0, - vec![], - ); - let tree = LQueryExpr::Aggregate { - keys: vec![], - without: false, - measures: vec![ - AggItem { - alias: Some("total_bytes".into()), - func: AggFunc::Sum, - col: ColumnRef::Named("bytes".into()), - }, - AggItem { - alias: Some("avg_latency".into()), - func: AggFunc::Avg, - col: ColumnRef::Named("latency".into()), - }, - ], - having: None, - input: Box::new(LQueryExpr::Source(SourceSpec::new("t"))), - }; - - let l3 = convert(&tree, &schema, &AccuracyTarget::Exact).unwrap(); - let CQueryExpr::Aggregate { - reduction, - measures, - .. - } = &l3 - else { - panic!("expected Aggregate, got {l3:?}"); - }; - let Reduction::Reduce(by) = reduction else { - panic!("expected a Reduce grouping, got {reduction:?}"); - }; - assert!(by.is_empty()); - // bytes is column 1, latency is column 2 in the input schema. - assert_eq!( - measures, - &vec![ - AggIntent::Sum { col: Some(1) }, - AggIntent::Avg { col: Some(2) }, - ] - ); - - // Output schema types each reducer off its own input column, and names - // it from the AggItem alias (threaded via Aggregate.output_names). - let out = l3.output_schema().unwrap(); - assert_eq!(out.columns[0], col("total_bytes", DataType::Int64)); // SUM(bytes:Int64) - assert_eq!(out.columns[1], col("avg_latency", DataType::Float64)); // AVG(latency)→Float64 - } - - /// PromQL's single sample-value reducer stays `col: None`. - #[test] - fn promql_sample_value_agg_stays_col_none() { - let schema = Schema::with_time_index( - vec![ - col("ts", DataType::Timestamp), - col("value", DataType::Float64), - ], - 0, - vec![], - ); - let tree = LQueryExpr::Aggregate { - keys: vec![], - without: false, - measures: vec![AggItem { - alias: Some("value".into()), - func: AggFunc::Sum, - col: ColumnRef::SampleValue, - }], - having: None, - input: Box::new(LQueryExpr::Source(SourceSpec::new("m"))), - }; - let l3 = convert(&tree, &schema, &AccuracyTarget::Exact).unwrap(); - // single-agg fused path → bare Aggregate (no keys → no Partition) - let CQueryExpr::Aggregate { measures, .. } = &l3 else { - panic!("expected Aggregate, got {l3:?}"); - }; - assert_eq!(measures, &[AggIntent::Sum { col: None }]); - } - - /// `SELECT region, SUM(bytes), COUNT(*) FROM logs JOIN meta … GROUP BY region` - /// — keys and per-agg input columns resolve against the JOIN's *concatenated* - /// schema, not either leaf's. logs=[id,bytes] meta=[id,region] → - /// concat [id(0), bytes(1), id(2), region(3)]. - #[test] - fn aggregate_over_join_resolves_against_concatenated_schema() { - let logs = LQueryExpr::Source(SourceSpec::with_schema( - "logs", - Schema::new(vec![ - col("id", DataType::Int64), - col("bytes", DataType::Int64), - ]), - )); - let meta = LQueryExpr::Source(SourceSpec::with_schema( - "meta", - Schema::new(vec![ - col("id", DataType::Int64), - col("region", DataType::Utf8), - ]), - )); - let join = LQueryExpr::Join { - kind: JoinKind::Inner, - pred: None, - left: Box::new(logs), - right: Box::new(meta), - }; - let tree = LQueryExpr::Aggregate { - keys: vec![ColumnRef::Named("region".into())], - without: false, - measures: vec![ - AggItem { - alias: Some("tot".into()), - func: AggFunc::Sum, - col: ColumnRef::Named("bytes".into()), - }, - AggItem { - alias: Some("n".into()), - func: AggFunc::Count, - col: ColumnRef::Wildcard, - }, - ], - having: None, - input: Box::new(join), - }; - let l3 = convert_root(&tree, &AccuracyTarget::Exact).unwrap(); - let CQueryExpr::Aggregate { - reduction, - measures, - child, - .. - } = &l3 - else { - panic!("expected multi-agg Aggregate, got {l3:?}"); - }; - let Reduction::Reduce(by) = reduction else { - panic!("expected a Reduce grouping, got {reduction:?}"); - }; - assert_eq!(by, &vec![3], "region is column 3 of the joined schema"); - assert_eq!( - measures[0], - AggIntent::Sum { col: Some(1) }, - "bytes is column 1" - ); - assert!(matches!(measures[1], AggIntent::Count { .. })); - assert!(matches!(child.as_ref(), CQueryExpr::Join { .. })); - } - - /// `GROUP BY region HAVING > 5` — HAVING references the aggregate - /// OUTPUT column (`n`), absent from the input schema, so it must resolve - /// against the derived output schema `[region(0), tot(1), n(2)]`. - #[test] - fn having_resolves_against_aggregate_output_schema() { - let schema = Schema::new(vec![ - col("region", DataType::Utf8), - col("bytes", DataType::Int64), - ]); - let tree = LQueryExpr::Aggregate { - keys: vec![ColumnRef::Named("region".into())], - without: false, - measures: vec![ - AggItem { - alias: Some("tot".into()), - func: AggFunc::Sum, - col: ColumnRef::Named("bytes".into()), - }, - AggItem { - alias: Some("n".into()), - func: AggFunc::Count, - col: ColumnRef::Wildcard, - }, - ], - having: Some(L2Expr::Compare { - left: Box::new(L2Expr::Column(ColumnRef::Named("n".into()))), - op: CompareOp::Gt, - right: Box::new(L2Expr::Literal(L3Scalar::Int64(5))), - }), - input: Box::new(LQueryExpr::Source(SourceSpec::with_schema("t", schema))), - }; - let l3 = convert(&tree, &Schema::default(), &AccuracyTarget::Exact).unwrap(); - let CQueryExpr::Aggregate { - having: Some(having), - .. - } = &l3 - else { - panic!("expected Aggregate with HAVING, got {l3:?}"); - }; - let L3Expr::Compare { left, .. } = &having.0 else { - panic!("expected Compare HAVING, got {:?}", having.0); - }; - assert_eq!( - **left, - L3Expr::Column(2), - "HAVING `n` resolves to the count output column (index 2), not the input schema" - ); - } -} diff --git a/crates/l2/src/relational.rs b/crates/l2/src/relational.rs deleted file mode 100644 index 3278550d..00000000 --- a/crates/l2/src/relational.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! The Layer-2 relational IR — the per-language query algebra the parser -//! front ends emit, before [`convert_root`](crate::lower::convert_root) lowers -//! it to the canonical L3 [`query_expr::QueryExpr`](asap_types::pre_asap::query_expr::QueryExpr). -//! -//! Leaf / scalar types (`ColumnRef`, `SortKey`, -//! `BinaryOpKind`, `VectorMatch`) are owned by `query_expr` and re-used here so -//! there is one canonical spelling. Filter / having / project expressions use -//! the shared language-independent [`L3Expr`](asap_types::pre_asap::expr_ir::L3Expr). - -use std::time::Duration; - -use asap_types::pre_asap::agg_intent::{MathFunc, TimeFunc}; -pub use asap_types::pre_asap::expr_ir::{ColumnRef, L2Expr}; -pub use asap_types::pre_asap::query_expr::{BinaryOpKind, VectorMatch, WindowFuncKind}; -use asap_types::pre_asap::query_expr::{InfoMatcher, SampleKind, TimeShift}; -use asap_types::pre_asap::schema::Schema; - -/// SELECT-list item at Layer 2 — a name-based [`L2Expr`] + optional alias. -/// (`query_expr::ProjectItem` is the positional L3 sibling.) -#[derive(Debug, Clone, PartialEq)] -pub struct L2ProjectItem { - pub alias: Option, - pub expr: L2Expr, -} - -/// ORDER BY key at Layer 2 — a name-based [`L2Expr`] + direction. -#[derive(Debug, Clone, PartialEq)] -pub struct L2SortKey { - pub expr: L2Expr, - pub ascending: bool, - pub nulls_first: bool, -} - -/// Base relation / metric stream source. -#[derive(Debug, Clone, PartialEq)] -pub struct SourceSpec { - /// Metric name (PromQL) or table name (SQL). - pub name: String, - /// Front-end-resolved leaf schema. `Some` for SQL tables (DataFusion knows - /// the columns); `None` for PromQL, where the [`Binder`](crate::binder) - /// synthesises a usage-derived schema (the `(ts, value)` floor + referenced - /// labels). The presence of a schema also selects the L3 `Source` variant: - /// `Some` → `Source::Table`, `None` → `Source::TimeSeries`. - pub schema: Option, - /// PromQL `offset` / `@` time shift on this selector (issue #40). The - /// converter lifts a non-identity shift into an L3 [`TimeShift`] wrapper over - /// the `Scan`. `TimeShift::default()` (the identity) for every unshifted - /// selector and every SQL table. - pub shift: TimeShift, -} - -impl SourceSpec { - /// A PromQL-style leaf whose schema the Binder synthesises. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - schema: None, - shift: TimeShift::default(), - } - } - - /// A SQL-style leaf carrying its front-end-resolved schema. - pub fn with_schema(name: impl Into, schema: Schema) -> Self { - Self { - name: name.into(), - schema: Some(schema), - shift: TimeShift::default(), - } - } - - /// This PromQL leaf with a time-shift modifier attached (issue #40). - pub fn with_shift(mut self, shift: TimeShift) -> Self { - self.shift = shift; - self - } -} - -/// One aggregate function in a GROUP BY / AGGREGATE node. -#[derive(Debug, Clone, PartialEq)] -pub struct AggItem { - /// Output alias (`None` = use the intent's conventional name). Matches - /// `L2ProjectItem.alias`'s convention — no `""` sentinel. - pub alias: Option, - pub func: AggFunc, - pub col: ColumnRef, -} - -/// Layer-2 aggregate functions. Mapped to canonical [`AggIntent`] by -/// [`crate::lower::convert`]. -#[derive(Debug, Clone, PartialEq)] -pub enum AggFunc { - Count, - Sum, - Avg, - Min, - Max, - StdDev { - population: bool, - }, - Variance { - population: bool, - }, - Quantile(f64), - /// COUNT DISTINCT — maps to `Cardinality`. - CountDistinct, - /// Heavy-hitter top-k — maps to `AggIntent::TopK`. - HeavyHitters { - k: u64, - }, - /// PromQL `rate()` / `irate()` — carries the range-vector window so the - /// canonical `Rate` intent owns it (no separate `Window` node). - Rate { - window: Duration, - }, - /// PromQL `increase()` — see `Rate`. - Increase { - window: Duration, - }, - - // ── Counter-derivative range functions (issue #44) ─────────────────── - // Per-series range reductions; the window rides on the enclosing L2 - // `Window` node (like `*_over_time`), so — unlike `Rate`/`Increase` — - // these variants do NOT carry it. - /// PromQL `changes(v[w])` → `AggIntent::Changes`. - Changes, - /// PromQL `delta(v[w])` → `AggIntent::Delta`. - Delta, - /// PromQL `idelta(v[w])` → `AggIntent::IDelta`. - IDelta, - /// PromQL `deriv(v[w])` → `AggIntent::Deriv`. - Deriv, - /// PromQL `resets(v[w])` → `AggIntent::Resets`. - Resets, - /// PromQL `predict_linear(v[w], t)` → `AggIntent::PredictLinear`. - PredictLinear { - seconds: f64, - }, - /// PromQL `double_exponential_smoothing(v[w], sf, tf)` → `AggIntent::DoubleExpSmoothing`. - DoubleExpSmoothing { - smoothing: f64, - trend: f64, - }, - - // ── Native-histogram accessors (issue #43) ─────────────────────────── - /// PromQL `histogram_count(v)` → `AggIntent::HistogramCount`. - HistogramCount, - /// PromQL `histogram_sum(v)` → `AggIntent::HistogramSum`. - HistogramSum, - /// PromQL `histogram_avg(v)` → `AggIntent::HistogramAvg`. - HistogramAvg, - /// PromQL `histogram_stddev(v)` → `AggIntent::HistogramStdDev`. - HistogramStdDev, - /// PromQL `histogram_stdvar(v)` → `AggIntent::HistogramStdVar`. - HistogramStdVar, - /// PromQL `histogram_fraction(lower, upper, v)` → `AggIntent::HistogramFraction`. - HistogramFraction { - lower: f64, - upper: f64, - }, - /// PromQL classic `histogram_quantile(φ, )` → - /// `AggIntent::HistogramQuantile`. - HistogramQuantile(f64), - /// PromQL element-wise math / trig transform (`abs`/`sqrt`/`clamp_max`/…) → - /// `AggIntent::Math` (issue #45). - Math(MathFunc), - /// PromQL `absent` → `AggIntent::Absent` (issue #47). - Absent, - /// PromQL `absent_over_time` → `AggIntent::AbsentOverTime`. - AbsentOverTime, - /// PromQL `present_over_time` → `AggIntent::PresentOverTime`. - PresentOverTime, - /// PromQL time / calendar accessor (`timestamp`/`hour`/`day_of_week`/…) → - /// `AggIntent::TimeFn` (issue #46). - TimeFn(TimeFunc), - /// PromQL `group(v)` → `AggIntent::Group` (issue #49). - Group, - /// PromQL `count_values("l", v)` → `AggIntent::CountValues` (issue #49). - CountValues { - label: String, - }, - // ── Additional range-vector reducers (issue #51) ───────────────────── - /// PromQL `last_over_time(v[w])` → `AggIntent::LastOverTime`. - LastOverTime, - /// PromQL `first_over_time(v[w])` → `AggIntent::FirstOverTime`. - FirstOverTime, - /// PromQL `mad_over_time(v[w])` → `AggIntent::MadOverTime`. - MadOverTime, - /// PromQL `ts_of_min_over_time(v[w])` → `AggIntent::TsOfMinOverTime`. - TsOfMinOverTime, - /// PromQL `ts_of_max_over_time(v[w])` → `AggIntent::TsOfMaxOverTime`. - TsOfMaxOverTime, - /// PromQL `ts_of_first_over_time(v[w])` → `AggIntent::TsOfFirstOverTime`. - TsOfFirstOverTime, - /// PromQL `ts_of_last_over_time(v[w])` → `AggIntent::TsOfLastOverTime`. - TsOfLastOverTime, -} - -/// The Layer-2 relational query IR. -#[derive(Debug, Clone, PartialEq)] -pub enum QueryExpr { - /// A named metric stream or table — the outermost leaf. - Source(SourceSpec), - /// A scalar constant leaf — a PromQL number literal (or a folded constant - /// scalar expression like `10*1024*1024`). Appears as a `BinaryOp` operand - /// for ` op ` thresholds / unit conversions (issue #35). - Scalar(f64), - /// The query evaluation time (`time()`), and the implicit input of the - /// no-arg calendar functions (issue #46). - EvalTime, - /// `vector(s)` — scalar→instant-vector bridge (issue #48). - VectorFromScalar(Box), - /// `scalar(v)` — instant-vector→scalar bridge (issue #48). - ScalarFromVector(Box), - /// ρ — per-series label rewrite (`label_replace` / `label_join`). Writes the - /// `dst` label from `value` (a scalar expr over source labels); every other - /// column passes through unchanged (issue #50). - Relabel { - dst: String, - value: L2Expr, - input: Box, - }, - /// Series-sampling selection (`limitk` / `limit_ratio`, issue #86). Keeps a - /// subset of whole series per `keys` group; passes each through unchanged. - Sample { - keys: Vec, - kind: SampleKind, - input: Box, - }, - /// `info(v, [selector])` — label-enrichment join against the info metric(s) - /// selected by `selector` (issue #84). Passes `input`'s series through, - /// enriched at L4 with the info labels. - InfoJoin { - selector: Vec, - input: Box, - }, - /// σ — row-level filter (WHERE / PromQL label matchers). - Filter { pred: L2Expr, input: Box }, - - /// π — projection / SELECT list (SQL). Column refs in `cols` resolve by - /// name against the child schema during conversion. - /// - /// `qualifier` re-qualifies every output column with a table alias — set for - /// a derived table / inline view (`FROM (SELECT …) t`), so an outer `t.col` - /// reference resolves to *this* relation and a join over two derived tables - /// disambiguates its keys. `None` for an ordinary SELECT list. - Project { - cols: Vec, - qualifier: Option, - input: Box, - }, - - /// γ + α — GROUP BY (`keys`) followed by aggregate functions. Keys are - /// `ColumnRef` (not bare strings) so a table-qualified key (`b.k`) resolves - /// to the correct join side, matching the scalar-predicate path. - /// - /// `without` distinguishes PromQL `without(labels)` (group by every label - /// *except* `keys`) from `by(labels)` (group by exactly `keys`). SQL GROUP - /// BY and every non-PromQL producer set it `false` (issue #39). - Aggregate { - keys: Vec, - without: bool, - measures: Vec, - having: Option, - input: Box, - }, - - /// ψ — time window (PromQL `[5m]`). - Window { - duration: Duration, - slide: Option, - input: Box, - }, - - /// δ — deduplicate on `cols`. - Distinct { - cols: Vec, - input: Box, - }, - /// τ — heavy-hitter top-k. `by` are the grouping keys (qualified-capable). - TopK { - k: u64, - by: Vec, - input: Box, - }, - /// ⊕ — n-ary `UNION ALL` of independent branches; rows concatenate, never - /// deduplicate. SQL's `UNION` lowers to `SetOp`, not this. - /// - /// Emitted for the branches of one query that a single `Aggregate` cannot - /// express: PromQL `histogram_quantiles` (one branch per φ, issue #109) and - /// SQL `ROLLUP`/`CUBE`/`GROUPING SETS` (one branch per grouping level, - /// issue #118). Also the shape a sharded / fan-in plan would take. - /// - /// The branches must be union-compatible — the merged schema is the first - /// child's — and the producer is responsible for making them so. - Merge { inputs: Vec }, - - Join { - kind: asap_types::pre_asap::query_expr::JoinKind, - pred: Option, - left: Box, - right: Box, - }, - SetOp { - kind: asap_types::pre_asap::query_expr::SetOpKind, - all: bool, - left: Box, - right: Box, - }, - - Sort { - keys: Vec, - /// Per-group ordering keys (`PARTITION BY`): a non-empty set means - /// "rank within each group", the home for a generic (non-heavy-hitter) - /// `topk by (…)` / `bottomk` grouping. Empty = a global order-by. - /// Resolved to positional `Sort.partition_by` ColumnIds at L3. - partition_by: Vec, - input: Box, - }, - Limit { - n: u64, - offset: u64, - input: Box, - }, - - /// PromQL sub-query syntax: `[range:resolution]`. - PromQLSubquery { - range: Duration, - resolution: Option, - input: Box, - }, - - /// SQL analytic window function `func(args) OVER (PARTITION BY … ORDER BY …)`. - WindowFunc { - func: WindowFuncKind, - args: Vec, - partition_by: Vec, - order_by: Vec, - output_name: String, - input: Box, - }, - - /// Binary op between two instant-vector expressions (PromQL `+`, `/`, …). - BinaryOp { - op: BinaryOpKind, - lhs: Box, - rhs: Box, - vector_match: Option, - }, -} - -impl QueryExpr { - /// Walk the tree depth-first, calling `f` on every node. - pub fn walk(&self, f: &mut F) { - f(self); - match self { - QueryExpr::Source(_) | QueryExpr::Scalar(_) | QueryExpr::EvalTime => {} - QueryExpr::Filter { input, .. } - | QueryExpr::Project { input, .. } - | QueryExpr::Aggregate { input, .. } - | QueryExpr::Window { input, .. } - | QueryExpr::Distinct { input, .. } - | QueryExpr::TopK { input, .. } - | QueryExpr::Sort { input, .. } - | QueryExpr::Limit { input, .. } - | QueryExpr::WindowFunc { input, .. } - | QueryExpr::VectorFromScalar(input) - | QueryExpr::ScalarFromVector(input) - | QueryExpr::Relabel { input, .. } - | QueryExpr::Sample { input, .. } - | QueryExpr::InfoJoin { input, .. } - | QueryExpr::PromQLSubquery { input, .. } => input.walk(f), - QueryExpr::Merge { inputs } => { - for i in inputs { - i.walk(f); - } - } - QueryExpr::Join { left, right, .. } - | QueryExpr::SetOp { left, right, .. } - | QueryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } => { - left.walk(f); - right.walk(f); - } - } - } - - /// The leftmost `Source` leaf. - pub fn leaf_source(&self) -> Option<&SourceSpec> { - match self { - QueryExpr::Source(s) => Some(s), - QueryExpr::Filter { input, .. } - | QueryExpr::Project { input, .. } - | QueryExpr::Aggregate { input, .. } - | QueryExpr::Window { input, .. } - | QueryExpr::Distinct { input, .. } - | QueryExpr::TopK { input, .. } - | QueryExpr::Sort { input, .. } - | QueryExpr::Limit { input, .. } - | QueryExpr::WindowFunc { input, .. } - | QueryExpr::VectorFromScalar(input) - | QueryExpr::ScalarFromVector(input) - | QueryExpr::Relabel { input, .. } - | QueryExpr::Sample { input, .. } - | QueryExpr::InfoJoin { input, .. } - | QueryExpr::PromQLSubquery { input, .. } => input.leaf_source(), - QueryExpr::Merge { inputs } => inputs.first()?.leaf_source(), - QueryExpr::Join { left, .. } - | QueryExpr::SetOp { left, .. } - | QueryExpr::BinaryOp { lhs: left, .. } => left.leaf_source(), - QueryExpr::Scalar(_) | QueryExpr::EvalTime => None, - } - } - - /// Outermost metric/table name from the first `Source` leaf. - pub fn source_name(&self) -> Option<&str> { - self.leaf_source().map(|s| s.name.as_str()) - } - - /// Whether the leftmost `Source` leaf carries a resolved schema — i.e. it is - /// a SQL table (`Source::Table`). Time-series (PromQL) leaves return - /// `false`. The converter uses this to keep the time-series fused per-series - /// reduction shape for PromQL while routing tabular GROUP BY through a - /// positional `Aggregate.by` (so group keys land in the output schema). - pub fn leaf_is_tabular(&self) -> bool { - self.leaf_source().is_some_and(|s| s.schema.is_some()) - } -} diff --git a/crates/types/src/pre_asap/agg_intent.rs b/crates/types/src/pre_asap/agg_intent.rs index 230b7b93..d7701f5f 100644 --- a/crates/types/src/pre_asap/agg_intent.rs +++ b/crates/types/src/pre_asap/agg_intent.rs @@ -26,45 +26,51 @@ use crate::types::AccuracyTarget; /// carries only `k` + the accuracy target. /// /// The single-column reducers (`Sum` / `Min` / `Max` / `Avg` / `StdDev` / -/// `Variance` / `Quantile` / `Cardinality`) carry `col: Option` — the -/// positional input column they reduce. `None` is the PromQL convention "the -/// time-series sample value"; SQL `SUM(bytes), AVG(latency)` sets distinct -/// `Some(id)`s so a multi-aggregate node binds each reducer to the right -/// column, and `plan::bind` knows which column to summarise over (issue #115). +/// `Variance` / `Quantile` / `Cardinality`) carry `col: Option` — the input +/// column they reduce, generic over the column-reference state the same way +/// [`QueryExpr`](super::query_expr::QueryExpr) is: positional `ColumnId` once +/// bound (the default, and every existing use of the bare `AggIntent` name), +/// or an unresolved name-based `ColumnRef` for a front end constructing this +/// intent directly, before the [`Binder`](super::binder::Binder) has run. +/// `None` is the PromQL convention "the time-series sample value"; SQL +/// `SUM(bytes), AVG(latency)` sets distinct `Some(_)`s so a multi-aggregate +/// node binds each reducer to the right column, and `plan::bind` knows which +/// column to summarise over (issue #115). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum AggIntent { +#[serde(bound(serialize = "C: Serialize", deserialize = "C: Deserialize<'de>"))] +pub enum AggIntent { // ── Data-model-agnostic ────────────────────────────────────────────── Count { accuracy: AccuracyTarget, }, Sum { #[serde(default)] - col: Option, + col: Option, }, Min { #[serde(default)] - col: Option, + col: Option, }, Max { #[serde(default)] - col: Option, + col: Option, }, Avg { #[serde(default)] - col: Option, + col: Option, }, /// Sample standard deviation when `population == false`; population stddev /// otherwise. PromQL `stddev` / `stddev_over_time`; SQL `STDDEV(col)`. StdDev { #[serde(default)] - col: Option, + col: Option, population: bool, }, /// Variance — PromQL `stdvar` / `stdvar_over_time`; SQL `VARIANCE(col)`. Variance { #[serde(default)] - col: Option, + col: Option, population: bool, }, /// φ-quantile of `col`. SQL `approx_percentile_cont(col, φ)` and @@ -72,7 +78,7 @@ pub enum AggIntent { /// (the sample value). Quantile { #[serde(default)] - col: Option, + col: Option, q: f64, accuracy: AccuracyTarget, }, @@ -89,7 +95,7 @@ pub enum AggIntent { /// `count_values` leaves `col` as `None` (the sample value). Cardinality { #[serde(default)] - col: Option, + col: Option, accuracy: AccuracyTarget, }, @@ -322,7 +328,16 @@ pub enum MathFunc { }, } -impl AggIntent { +// `requires` / `is_per_series` / `output_column` never read `col`'s value — +// only its presence via a `{ .. }` pattern — so, unlike +// `QueryExpr::output_schema` (which genuinely cannot compile for an +// unresolved tree — see its own doc), nothing stops these from being generic +// over every `C`. And a front end constructing `AggIntent` +// directly (issue #179) does need `is_per_series` pre-binding — it decides +// the `PerEntity`/`Reduce` reduction shape right at construction time (see +// `asap_frontend_promql::promql::reduction_for`) — so they stay generic +// alongside `input_col`, in one `impl` block. +impl AggIntent { /// Which data model this intent semantically requires. L4 rules consult /// this to skip non-applicable intents (e.g. `Rate` over a tabular source). pub fn requires(&self) -> DataModel { @@ -394,13 +409,15 @@ impl AggIntent { | Self::TsOfLastOverTime ) } +} - /// The positional input column this intent reduces, if it carries one. - /// `None` = the synthetic time-series sample value (PromQL) or an - /// argument-less aggregate (`Count` / `TopK`). Used by schema derivation to - /// resolve each reducer's input column, and by `plan::bind` to pick the - /// column a summary is built over. - pub fn input_col(&self) -> Option { +impl AggIntent { + /// The input column this intent reduces, if it carries one. `None` = the + /// synthetic time-series sample value (PromQL) or an argument-less + /// aggregate (`Count` / `TopK`). Used by schema derivation to resolve each + /// reducer's input column, and by `plan::bind` to pick the column a + /// summary is built over. + pub fn input_col(&self) -> Option { match self { AggIntent::Sum { col } | AggIntent::Min { col } @@ -409,11 +426,13 @@ impl AggIntent { | AggIntent::Quantile { col, .. } | AggIntent::Cardinality { col, .. } | AggIntent::StdDev { col, .. } - | AggIntent::Variance { col, .. } => *col, + | AggIntent::Variance { col, .. } => col.clone(), _ => None, } } +} +impl AggIntent { /// Output column name + type produced by this intent over `input`. /// Used by `QueryExpr::Aggregate`'s schema-derivation rule. The PromQL /// convention names the column after the intent kind so consumers can @@ -652,16 +671,21 @@ mod tests { fn output_column_names_are_intent_keyed() { let v = c("value", DataType::Float64); assert_eq!( - AggIntent::Count { + AggIntent::::Count { accuracy: AccuracyTarget::Exact } .output_column(&v) .name, "count" ); - assert_eq!(AggIntent::Sum { col: None }.output_column(&v).name, "sum"); assert_eq!( - AggIntent::Quantile { + AggIntent::::Sum { col: None } + .output_column(&v) + .name, + "sum" + ); + assert_eq!( + AggIntent::::Quantile { col: None, q: 0.99, accuracy: AccuracyTarget::Epsilon(0.01) @@ -675,7 +699,7 @@ mod tests { #[test] fn sum_preserves_input_dtype() { assert!(matches!( - AggIntent::Sum { col: None } + AggIntent::::Sum { col: None } .output_column(&c("c", DataType::Int64)) .dtype, DataType::Int64 @@ -729,12 +753,12 @@ mod tests { fn input_col_tracks_only_reducers() { assert_eq!(AggIntent::Sum { col: Some(3) }.input_col(), Some(3)); assert_eq!( - AggIntent::Avg { col: None }.input_col(), + AggIntent::::Avg { col: None }.input_col(), None, "None = PromQL sample value" ); assert_eq!( - AggIntent::Count { + AggIntent::::Count { accuracy: AccuracyTarget::Exact } .input_col(), diff --git a/crates/l2/src/binder.rs b/crates/types/src/pre_asap/binder.rs similarity index 52% rename from crates/l2/src/binder.rs rename to crates/types/src/pre_asap/binder.rs index a4c9d0a4..5eafa942 100644 --- a/crates/l2/src/binder.rs +++ b/crates/types/src/pre_asap/binder.rs @@ -1,9 +1,9 @@ //! The L3 **Binder** — name resolution as an explicit pass. //! //! [`Binder::bind`] produces the complete, self-contained [`Schema`] every -//! `ColumnId` in the converted canonical tree indexes into. The converter -//! ([`crate::lower::convert`]) then becomes purely structural: it threads the -//! Binder's schema and positional resolution downstream is **total**. +//! `ColumnId` in the canonical tree indexes into. [`resolve`](super::resolve) +//! then becomes purely structural: it threads the Binder's schema and +//! positional resolution downstream is **total**. //! //! The default [`UsageDerivedCatalog`] knows nothing — every schema is derived //! purely from the query's own usage. That is the honest state for the @@ -11,10 +11,10 @@ //! `SchemaCatalog` is future work; the `Binder` pass does not change when it //! lands, only the catalog impl swaps. -use crate::relational::QueryExpr as LQueryExpr; -use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::pre_asap::expr_ir::L2Expr; -use asap_types::pre_asap::schema::{Column, DataType, Schema}; +use super::expr_ir::ColumnRef; +use super::expr_ir::L2Expr; +use super::query_expr::L2QueryExpr; +use super::schema::{Column, DataType, Schema}; /// The DB / source-schema metadata source — resolves a source (metric / /// table) name to its known columns. @@ -72,7 +72,7 @@ impl Binder { /// Contains the time axis, the synthetic `value` column, and one column /// per distinct name referenced anywhere in the tree — so positional /// `ColumnId` resolution downstream is total. - pub fn bind(&self, tree: &LQueryExpr) -> Schema { + pub fn bind(&self, tree: &L2QueryExpr) -> Schema { self.bind_with_inherited(tree, &[]) } @@ -82,9 +82,8 @@ impl Binder { /// own sub-tree) still sees an outer aggregate's group keys — e.g. the /// `__name__` / `job` in `sum by (__name__)(a or b)`, which appear in neither /// side's own matchers (issue #52). - pub fn bind_with_inherited(&self, tree: &LQueryExpr, inherited: &[String]) -> Schema { - let mut columns: Vec = tree - .source_name() + pub fn bind_with_inherited(&self, tree: &L2QueryExpr, inherited: &[String]) -> Schema { + let mut columns: Vec = leftmost_scan_name(tree) .and_then(|name| self.catalog.columns_for(name)) .unwrap_or_else(default_leaf_columns); @@ -124,12 +123,6 @@ fn default_leaf_columns() -> Vec { ] } -/// Collect every distinct column name the converter resolves positionally: -/// group keys (`Aggregate.keys`, `TopK.by`, `Partition.keys`) **and** the -/// columns referenced by name in filter / having / project / sort / join -/// expressions (e.g. a PromQL label matcher `m{env="prod"}` references `env`). -/// The Binder seeds these into the usage-derived leaf so positional resolution -/// downstream is total. /// Push a `ColumnRef`'s bare name (the schema-seedable identifier). `Qualified` /// collapses to its `name`; `SampleValue`/`Wildcard` carry no name. fn push_ref_name(c: &ColumnRef, out: &mut Vec) { @@ -140,47 +133,167 @@ fn push_ref_name(c: &ColumnRef, out: &mut Vec) { } } -pub(crate) fn collect_referenced_columns(tree: &LQueryExpr) -> Vec { +/// The leftmost `Scan`'s source name in a canonical (`L2QueryExpr`) tree — +/// the [`collect_referenced_columns`] counterpart to what a dedicated +/// `Source` leaf type would carry as a method; the canonical tree's `Scan` +/// leaf needs this walk written out instead. +fn leftmost_scan_name(tree: &L2QueryExpr) -> Option<&str> { + use L2QueryExpr as QE; + match tree { + QE::Scan { source, .. } => Some(match source { + super::query_expr::Source::TimeSeries { metric } => metric.as_str(), + super::query_expr::Source::Table { table_ref } => table_ref.as_str(), + }), + QE::Scalar(_) | QE::EvalTime => None, + QE::VectorFromScalar(child) | QE::ScalarFromVector(child) => leftmost_scan_name(child), + QE::Relabel { child, .. } + | QE::InfoJoin { child, .. } + | QE::Sample { child, .. } + | QE::Filter { child, .. } + | QE::Project { child, .. } + | QE::Aggregate { child, .. } + | QE::Distinct { child, .. } + | QE::Sort { child, .. } + | QE::Limit { child, .. } + | QE::Subquery { child, .. } + | QE::TimeRange { child, .. } + | QE::TimeShift { child, .. } + | QE::WindowFunc { child, .. } => leftmost_scan_name(child), + QE::Merge { children } => children.first().and_then(leftmost_scan_name), + QE::Join { left, .. } | QE::SetOp { left, .. } | QE::BinaryOp { lhs: left, .. } => { + leftmost_scan_name(left) + } + } +} + +/// Collect every distinct column name referenced anywhere in `tree` that +/// resolves positionally — every place a front end constructing +/// [`QueryExpr`](super::query_expr::QueryExpr) directly (issue +/// #179) puts a name-based reference: `Scan.predicates`, `Aggregate`'s +/// `reduction`/`having`/per-measure `col`, `Distinct.cols`, `Sample.by`, +/// `Filter.pred`, `Project.cols`, `Sort.keys`/`partition_by`, +/// `WindowFunc.args`/`partition_by`/`order_by`, `Join.pred`, `Relabel.value`. +/// The Binder seeds these into the usage-derived leaf so positional +/// resolution downstream is total. +pub(crate) fn collect_referenced_columns(tree: &L2QueryExpr) -> Vec { + use L2QueryExpr as QE; fn named(expr: &L2Expr, out: &mut Vec) { for c in expr.columns_referenced() { push_ref_name(c, out); } } - let mut out: Vec = Vec::new(); - tree.walk(&mut |node| match node { - LQueryExpr::Aggregate { keys, having, .. } => { - keys.iter().for_each(|k| push_ref_name(k, &mut out)); - if let Some(h) = having { - named(h, &mut out); - } + fn opt_ref(c: &Option, out: &mut Vec) { + if let Some(c) = c { + push_ref_name(c, out); } - LQueryExpr::TopK { by, .. } => by.iter().for_each(|k| push_ref_name(k, &mut out)), - // `limitk`/`limit_ratio` grouping keys resolve positionally (issue #86). - LQueryExpr::Sample { keys, .. } => keys.iter().for_each(|k| push_ref_name(k, &mut out)), - LQueryExpr::Filter { pred, .. } => named(pred, &mut out), - LQueryExpr::Project { cols, .. } => { - for item in cols { - named(&item.expr, &mut out); - } + } + fn group_keys(g: &super::query_expr::GroupKeys, out: &mut Vec) { + g.keys().iter().for_each(|k| push_ref_name(k, out)); + } + fn measure_cols(measures: &[super::agg_intent::AggIntent], out: &mut Vec) { + for m in measures { + opt_ref(&m.input_col(), out); } - LQueryExpr::Sort { - keys, partition_by, .. - } => { - for k in keys { - named(&k.expr, &mut out); + } + fn walk(node: &L2QueryExpr, out: &mut Vec) { + match node { + QE::Scan { predicates, .. } => { + for super::query_expr::Predicate(p) in predicates { + named(p, out); + } + } + QE::Aggregate { + reduction, + measures, + having, + child, + .. + } => { + if let super::query_expr::Reduction::Reduce(by) = reduction { + group_keys(by, out); + } + measure_cols(measures, out); + if let Some(super::query_expr::Predicate(h)) = having { + named(h, out); + } + walk(child, out); + } + QE::Distinct { cols, child } => { + cols.iter().for_each(|c| push_ref_name(c, out)); + walk(child, out); + } + QE::Sample { by, child, .. } => { + group_keys(by, out); + walk(child, out); + } + QE::Filter { pred, child } => { + named(&pred.0, out); + walk(child, out); + } + QE::Project { cols, child, .. } => { + for item in cols { + named(&item.expr, out); + } + walk(child, out); + } + QE::Sort { + keys, + partition_by, + child, + } => { + for k in keys { + named(&k.expr, out); + } + group_keys(partition_by, out); + walk(child, out); + } + QE::WindowFunc { + args, + partition_by, + order_by, + child, + .. + } => { + for a in args { + named(a, out); + } + group_keys(partition_by, out); + for k in order_by { + named(&k.expr, out); + } + walk(child, out); + } + QE::Relabel { value, child, .. } => { + named(value, out); + walk(child, out); + } + QE::Join { + pred, left, right, .. + } => { + named(&pred.0, out); + walk(left, out); + walk(right, out); + } + QE::Scalar(_) | QE::EvalTime => {} + QE::VectorFromScalar(child) | QE::ScalarFromVector(child) => walk(child, out), + QE::InfoJoin { child, .. } + | QE::Limit { child, .. } + | QE::Subquery { child, .. } + | QE::TimeRange { child, .. } + | QE::TimeShift { child, .. } => walk(child, out), + QE::Merge { children } => children.iter().for_each(|c| walk(c, out)), + QE::SetOp { left, right, .. } => { + walk(left, out); + walk(right, out); + } + QE::BinaryOp { lhs, rhs, .. } => { + walk(lhs, out); + walk(rhs, out); } - // Per-group ranking keys (`topk by (…)`) must be seeded into the - // leaf schema so they resolve positionally to `Sort.partition_by`. - partition_by.iter().for_each(|k| push_ref_name(k, &mut out)); } - LQueryExpr::Join { pred: Some(p), .. } => named(p, &mut out), - // A relabel's source labels are referenced by name inside `value` - // (`label_replace(instance, …)`); seed them so they resolve - // positionally. `dst` is an output — only seeded if `value` also reads - // it (an in-place `label_replace` on the same label). - LQueryExpr::Relabel { value, .. } => named(value, &mut out), - _ => {} - }); + } + let mut out: Vec = Vec::new(); + walk(tree, &mut out); out.sort(); out.dedup(); out @@ -188,13 +301,18 @@ pub(crate) fn collect_referenced_columns(tree: &LQueryExpr) -> Vec { #[cfg(test)] mod tests { + use super::super::query_expr::{GroupKeys, Source}; + use super::super::L2Expr; use super::*; - use crate::relational::{L2SortKey, QueryExpr as LQueryExpr, SourceSpec}; - use asap_types::pre_asap::expr_ir::ColumnRef; - use asap_types::pre_asap::L2Expr; - fn src(name: &str) -> LQueryExpr { - LQueryExpr::Source(SourceSpec::new(name)) + fn src(name: &str) -> L2QueryExpr { + L2QueryExpr::Scan { + source: Source::TimeSeries { + metric: name.into(), + }, + predicates: vec![], + schema: None, + } } #[test] @@ -210,14 +328,14 @@ mod tests { fn sort_partition_keys_land_in_schema() { // Per-group ranking keys (`topk by (host)` → `Sort.partition_by`) must be // seeded into the usage-derived leaf so they resolve positionally. - let tree = LQueryExpr::Sort { - keys: vec![L2SortKey { + let tree = L2QueryExpr::Sort { + keys: vec![super::super::query_expr::SortKey { expr: L2Expr::Column(ColumnRef::SampleValue), ascending: false, nulls_first: false, }], - partition_by: vec![ColumnRef::Named("host".into())], - input: Box::new(src("hits")), + partition_by: GroupKeys::by(vec![ColumnRef::Named("host".into())]), + child: Box::new(src("hits")), }; let schema = Binder::new().bind(&tree); assert!(schema.column_id("host").is_some()); diff --git a/crates/l2/src/canonicalize.rs b/crates/types/src/pre_asap/canonicalize.rs similarity index 96% rename from crates/l2/src/canonicalize.rs rename to crates/types/src/pre_asap/canonicalize.rs index a11bffbd..83ea7866 100644 --- a/crates/l2/src/canonicalize.rs +++ b/crates/types/src/pre_asap/canonicalize.rs @@ -1,7 +1,7 @@ //! Shared post-lowering canonicalization of the L3 [`QueryExpr`]. //! -//! Both language front ends funnel through [`convert_root`](crate::convert_root), -//! which runs this pass over the converted tree. Its job is to erase +//! Both language front ends funnel through [`resolve_root`](super::resolve::resolve_root), +//! which runs this pass over the resolved tree. Its job is to erase //! *structural* differences between semantically identical queries so an L4 rule //! matching on the intent algebra sees one canonical spelling regardless of //! source language (issue #34). @@ -24,10 +24,9 @@ //! column) it is oblivious to whether the count was aliased in the source, which //! is exactly the SQL alias gap (#20) the old front-end gate missed. -use asap_types::pre_asap::agg_intent::{is_frequency_heavy_hitter, ranking_measure, AggIntent}; -use asap_types::pre_asap::expr_ir::{CompareOp, L3Scalar}; -use asap_types::pre_asap::query_expr::{Predicate, QueryExpr, Reduction, SortKey, WindowFuncKind}; -use asap_types::pre_asap::L3Expr; +use super::agg_intent::{is_frequency_heavy_hitter, ranking_measure, AggIntent}; +use super::expr_ir::{CompareOp, L3Expr, L3Scalar}; +use super::query_expr::{Predicate, QueryExpr, Reduction, SortKey, WindowFuncKind}; /// Rewrite `expr` into its canonical form (bottom-up). Idempotent: a tree that /// is already canonical is returned unchanged. @@ -252,9 +251,9 @@ fn try_rewrite_rownumber_topk(expr: &QueryExpr) -> Option { #[cfg(test)] mod tests { use super::*; - use asap_types::pre_asap::query_expr::{GroupKeys, ProjectItem, Source}; - use asap_types::pre_asap::schema::{Column, DataType, Schema}; - use asap_types::types::AccuracyTarget; + use crate::pre_asap::query_expr::{GroupKeys, ProjectItem, Source}; + use crate::pre_asap::schema::{Column, DataType, Schema}; + use crate::types::AccuracyTarget; fn scan() -> QueryExpr { QueryExpr::Scan { diff --git a/crates/l2/src/column_resolution.rs b/crates/types/src/pre_asap/column_resolution.rs similarity index 88% rename from crates/l2/src/column_resolution.rs rename to crates/types/src/pre_asap/column_resolution.rs index 920b6cd3..bacb6084 100644 --- a/crates/l2/src/column_resolution.rs +++ b/crates/types/src/pre_asap/column_resolution.rs @@ -1,21 +1,19 @@ -//! Schema-driven column resolution for the Layer-2 `relational` IR. +//! Schema-driven column resolution. //! -//! The Layer-2 IR uses `ColumnRef` (name-based, optionally table-qualified); -//! the canonical IR uses positional [`ColumnId`] resolved against a per-node -//! [`Schema`]. These helpers bridge the two — the [`Binder`](crate::binder) -//! builds the schema, and [`resolve_column_refs`] turns the L2 refs (group -//! keys, dedup columns) into positional ids, qualifier-aware. +//! Front ends (issue #179) emit `ColumnRef` (name-based, optionally +//! table-qualified); the canonical tree uses positional [`ColumnId`] resolved +//! against a per-node [`Schema`]. These helpers bridge the two — the +//! [`Binder`](super::binder) builds the schema, and [`resolve_column_refs`] +//! turns name-based refs (group keys, dedup columns) into positional ids, +//! qualifier-aware. use thiserror::Error; -use crate::relational::QueryExpr; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::pre_asap::expr_ir::{L2Expr, L3Expr}; -use asap_types::pre_asap::query_expr::{ - aggregate_output_schema, GroupKeys, QueryExprError, Reduction, -}; -use asap_types::pre_asap::schema::{Column, ColumnId, DataType, Schema}; +use super::agg_intent::AggIntent; +use super::expr_ir::ColumnRef; +use super::expr_ir::{L2Expr, L3Expr}; +use super::query_expr::{aggregate_output_schema, GroupKeys, QueryExprError, Reduction}; +use super::schema::{ColumnId, DataType, Schema}; /// Errors returned by the resolution helpers. #[derive(Debug, Error, PartialEq, Eq)] @@ -31,26 +29,6 @@ pub enum ResolveError { WildcardNotPositional, } -/// Synthesize the conventional PromQL leaf schema `(ts, value)` for a metric. -pub fn infer_source_schema(_metric_or_table: &str) -> Schema { - Schema::with_time_index( - vec![ - Column::new("ts", DataType::Timestamp, false), - Column::new("value", DataType::Float64, false), - ], - 0, - Vec::new(), - ) -} - -/// Synthesise the root schema by walking to the outermost `Source` leaf. -pub fn infer_schema_for_root(expr: &QueryExpr) -> Schema { - match expr.source_name() { - Some(name) => infer_source_schema(name), - None => Schema::default(), - } -} - /// Resolve a single [`ColumnRef`] to a positional [`ColumnId`]. pub fn resolve_column_ref(col: &ColumnRef, schema: &Schema) -> Result { match col { @@ -220,18 +198,23 @@ pub fn output_schema_for_aggregate( #[cfg(test)] mod tests { use super::*; + use crate::pre_asap::schema::Column; - #[test] - fn source_schema_has_ts_and_value() { - let s = infer_source_schema("m"); - assert_eq!(s.columns.len(), 2); - assert_eq!(s.time_index, Some(0)); - assert!(!s.has_unique_key()); + /// The conventional PromQL leaf shape: `(ts: Timestamp, value: Float64)`. + fn ts_value_schema() -> Schema { + Schema::with_time_index( + vec![ + Column::new("ts", DataType::Timestamp, false), + Column::new("value", DataType::Float64, false), + ], + 0, + Vec::new(), + ) } #[test] fn resolve_sample_value() { - let s = infer_source_schema("m"); + let s = ts_value_schema(); assert_eq!(resolve_column_ref(&ColumnRef::SampleValue, &s), Ok(1)); } @@ -281,7 +264,7 @@ mod tests { #[test] fn resolve_unknown_name_errors() { - let s = infer_source_schema("m"); + let s = ts_value_schema(); let err = resolve_column_ref(&ColumnRef::Named("host".into()), &s).unwrap_err(); assert!(matches!(err, ResolveError::NotFound { .. })); } @@ -321,7 +304,7 @@ mod tests { // An open schema can't prove absence — an unresolved key there is a // resolution bug (the Binder seeds every referenced label), not an // absent label. Keep the strict error. - let open = infer_source_schema("m"); // closed: false + let open = ts_value_schema(); // closed: false assert!(matches!( resolve_group_keys_promql(&[ColumnRef::Named("job".into())], &open), Err(ResolveError::NotFound { .. }) @@ -330,7 +313,7 @@ mod tests { #[test] fn aggregate_strips_time_and_keeps_unique_keys() { - let mut input = infer_source_schema("m"); + let mut input = ts_value_schema(); input .columns .push(Column::new("host", DataType::Utf8, false)); @@ -355,7 +338,7 @@ mod tests { // for the same aggregate. Before the dedup this diverged on a per-series // reduction — the HAVING mirror lacked the per-series branch and would // collapse `[ts, value]` to a single `rate` column. - use asap_types::pre_asap::query_expr::{QueryExpr as L3, Source}; + use crate::pre_asap::query_expr::{QueryExpr as L3, Source}; use std::time::Duration; let leaf_schema = Schema::with_time_index( diff --git a/crates/types/src/pre_asap/mod.rs b/crates/types/src/pre_asap/mod.rs index f4047f16..3750b6de 100644 --- a/crates/types/src/pre_asap/mod.rs +++ b/crates/types/src/pre_asap/mod.rs @@ -1,32 +1,54 @@ //! Layer 3 — the canonical intent algebra IR. //! //! - [`query_expr`] — the canonical, language- and deployment-independent L3 -//! intent algebra ([`QueryExpr`] + [`AggIntent`]), with positional -//! [`ColumnId`] schema flow. +//! intent algebra ([`QueryExpr`] + [`AggIntent`]), generic over the +//! column-reference state (positional [`ColumnId`] once bound, name-based +//! [`ColumnRef`] before). //! - [`agg_intent`] — the L3 aggregation-intent vocabulary. //! - [`expr_ir`] — the scalar expression IR ([`L2Expr`] / [`L3Expr`] / //! [`ColumnRef`]) shared by L2 and L3. //! - [`schema`] — the per-edge [`Schema`] every L3 node carries. +//! - [`binder`] / [`column_resolution`] — name resolution: turn a `ColumnRef` +//! into a positional `ColumnId` against an in-scope [`Schema`]. +//! - [`resolve`] — binds a whole front-end-emitted [`L2QueryExpr`] tree to +//! canonical [`L3QueryExpr`] (issue #179): both front ends +//! (`asap-frontend-promql`, `asap-frontend-sql`) construct `L2QueryExpr` +//! directly during their own `interpret` step and call +//! [`resolve_root`] on the result — there is no separate per-language +//! relational tree or converter anymore. +//! - [`canonicalize`] — post-lowering structural normalization of [`QueryExpr`] +//! (issue #34), run by [`resolve_root`]. //! -//! The Layer-2 relational tree and the L2→L3 converter (`convert_root`, the -//! `Binder`, column resolution) live in the `asap-l2` crate — front ends need -//! them, but L3-only consumers (optimizer, sketch) do not, so they stay out of -//! this crate. +//! Formerly the separate `asap-l2` crate; folded in here since +//! `binder`/`column_resolution`/`canonicalize`/`resolve` have no +//! front-end-specific logic — they operate directly on this crate's own +//! `QueryExpr`. pub mod agg_intent; +pub mod binder; +pub mod canonicalize; +pub mod column_resolution; pub mod expr_ir; pub mod query_expr; +pub mod resolve; pub mod schema; pub use agg_intent::{ agg_accuracy, agg_is_exact, agg_is_mergeable, default_cardinality, default_quantile, is_frequency_heavy_hitter, ranking_measure, AggIntent, MathFunc, RankingMeasure, TimeFunc, }; +pub use binder::{Binder, SchemaCatalog, UsageDerivedCatalog}; +pub use canonicalize::canonicalize; +pub use column_resolution::{ + output_schema_for_aggregate, resolve_column_ref, resolve_column_refs, resolve_expr, + ResolveError, +}; pub use expr_ir::{ArithOp, ColumnRef, CompareOp, Expr, L2Expr, L3Expr, L3Scalar}; pub use query_expr::{ - aggregate_output_schema, AtModifier, BinaryOpKind, DataModel, GroupKeys, GroupSide, - InfoMatcher, JoinKind, Predicate, ProjectItem, QueryExpr, QueryExprError, Reduction, - SampleKind, SetOpKind, SortKey, Source, TimeShift, VectorGrouping, VectorMatch, - VectorMatchKind, WindowFuncKind, + aggregate_output_schema, AtModifier, BinaryOpKind, ColState, DataModel, GroupKeys, GroupSide, + InfoMatcher, JoinKind, L2QueryExpr, L3QueryExpr, Predicate, ProjectItem, QueryExpr, + QueryExprError, Reduction, SampleKind, SetOpKind, SortKey, Source, TimeShift, VectorGrouping, + VectorMatch, VectorMatchKind, WindowFuncKind, }; +pub use resolve::{resolve_root, ResolveTreeError}; pub use schema::{Column, ColumnId, DataType, Schema}; diff --git a/crates/types/src/pre_asap/query_expr.rs b/crates/types/src/pre_asap/query_expr.rs index 442126da..1736b158 100644 --- a/crates/types/src/pre_asap/query_expr.rs +++ b/crates/types/src/pre_asap/query_expr.rs @@ -11,9 +11,35 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use super::agg_intent::AggIntent; -use super::expr_ir::{ArithOp, CompareOp, L3Expr, L3Scalar}; +use super::expr_ir::{ArithOp, ColumnRef, CompareOp, Expr, L3Expr, L3Scalar}; use super::schema::{Column, ColumnId, DataType, Schema}; +/// The column-reference resolution state a [`QueryExpr`] tree carries — +/// [`ColumnId`] (the default, and what the bare `QueryExpr` name has always +/// meant) once the [`Binder`](super::binder::Binder) has resolved every +/// reference positionally, or the front-end-emitted, name-based [`ColumnRef`] +/// before binding. The only place the two states differ in *shape* rather +/// than just in which type fills `C` is [`QueryExpr::Scan`]'s `schema` field: +/// a bound tree's binding schema is always known (the Binder is total, so +/// [`ScanSchema`](Self::ScanSchema) `= Schema`); an unresolved front-end +/// `Scan` knows its schema only when the front end already has it without +/// binding — a SQL leaf, catalog-backed (`Some`) — `None` (PromQL) defers to +/// the Binder, so `ScanSchema = Option`. +pub trait ColState: + Clone + std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de> +{ + /// What [`QueryExpr::Scan`]'s `schema` field holds for a tree in this state. + type ScanSchema: Clone + std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>; +} + +impl ColState for ColumnId { + type ScanSchema = Schema; +} + +impl ColState for ColumnRef { + type ScanSchema = Option; +} + /// Errors from schema derivation over a canonical tree. #[derive(Debug, Error)] pub enum QueryExprError { @@ -48,19 +74,31 @@ pub enum QueryExprError { /// Serialises as a bare array for the (overwhelmingly common) `by` case — /// wire-compatible with the `Vec` this field held before — and as /// `{"without": [...]}` for the exclusion case. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] -pub struct GroupKeys { - keys: Vec, +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupKeys { + keys: Vec, without: bool, } -impl GroupKeys { +// Not `#[derive(Default)]`: derive would add a `C: Default` bound, but an +// empty key set needs nothing from `C` — `ColumnRef` has no meaningful +// default anyway. +impl Default for GroupKeys { + fn default() -> Self { + Self { + keys: Vec::new(), + without: false, + } + } +} + +impl GroupKeys { /// An empty key set — a global (ungrouped) operation. pub fn none() -> Self { Self::default() } - /// `by(keys)` — group by exactly these positional columns. - pub fn by(keys: Vec) -> Self { + /// `by(keys)` — group by exactly these columns. + pub fn by(keys: Vec) -> Self { Self { keys, without: false, @@ -68,7 +106,7 @@ impl GroupKeys { } /// `without(keys)` — group by every label *except* these (issue #39). The /// kept set is runtime-resolved; only the excluded positions are stored. - pub fn without(keys: Vec) -> Self { + pub fn without(keys: Vec) -> Self { Self { keys, without: true, @@ -79,70 +117,79 @@ impl GroupKeys { self.without } /// The named keys — kept labels for `by`, excluded labels for `without`. - pub fn keys(&self) -> &[ColumnId] { + pub fn keys(&self) -> &[C] { &self.keys } } -impl std::ops::Deref for GroupKeys { - type Target = [ColumnId]; +impl std::ops::Deref for GroupKeys { + type Target = [C]; fn deref(&self) -> &Self::Target { &self.keys } } -impl From> for GroupKeys { - fn from(keys: Vec) -> Self { +impl From> for GroupKeys { + fn from(keys: Vec) -> Self { Self::by(keys) } } -impl FromIterator for GroupKeys { - fn from_iter>(iter: I) -> Self { +impl FromIterator for GroupKeys { + fn from_iter>(iter: I) -> Self { Self::by(iter.into_iter().collect()) } } -impl<'a> IntoIterator for &'a GroupKeys { - type Item = &'a ColumnId; - type IntoIter = std::slice::Iter<'a, ColumnId>; +impl<'a, C> IntoIterator for &'a GroupKeys { + type Item = &'a C; + type IntoIter = std::slice::Iter<'a, C>; fn into_iter(self) -> Self::IntoIter { self.keys.iter() } } -/// Compare directly against a `Vec` so call sites and tests can keep +/// Compare directly against a `Vec` so call sites and tests can keep /// writing `keys == vec![..]` / `assert_eq!(keys, &vec![..])`. A `without` /// grouping never equals a bare `by` list. -impl PartialEq> for GroupKeys { - fn eq(&self, other: &Vec) -> bool { +impl PartialEq> for GroupKeys { + fn eq(&self, other: &Vec) -> bool { !self.without && &self.keys == other } } /// (De)serialise as a bare array for `by`, or `{"without": [...]}` for the /// exclusion form — keeping the `by` wire format identical to the old newtype. -#[derive(Serialize, Deserialize)] +/// Borrowed for `Serialize` (no `C: Clone` needed to write one out), owned for +/// `Deserialize` (there's nothing to borrow from). +#[derive(Serialize)] +#[serde(untagged)] +enum GroupKeysReprRef<'a, C> { + By(&'a [C]), + Without { without: &'a [C] }, +} + +#[derive(Deserialize)] #[serde(untagged)] -enum GroupKeysRepr { - By(Vec), - Without { without: Vec }, +enum GroupKeysRepr { + By(Vec), + Without { without: Vec }, } -impl Serialize for GroupKeys { +impl Serialize for GroupKeys { fn serialize(&self, serializer: S) -> Result { if self.without { - GroupKeysRepr::Without { - without: self.keys.clone(), + GroupKeysReprRef::Without { + without: self.keys.as_slice(), } .serialize(serializer) } else { - GroupKeysRepr::By(self.keys.clone()).serialize(serializer) + GroupKeysReprRef::By(self.keys.as_slice()).serialize(serializer) } } } -impl<'de> Deserialize<'de> for GroupKeys { +impl<'de, C: Deserialize<'de>> Deserialize<'de> for GroupKeys { fn deserialize>(deserializer: D) -> Result { Ok(match GroupKeysRepr::deserialize(deserializer)? { GroupKeysRepr::By(keys) => Self::by(keys), @@ -297,8 +344,8 @@ pub enum SampleKind { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SortKey { - pub expr: L3Expr, +pub struct SortKey { + pub expr: Expr, pub ascending: bool, pub nulls_first: bool, } @@ -367,13 +414,13 @@ pub enum GroupSide { /// A row-level filter predicate (WHERE clause / PromQL label matcher). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Predicate(pub L3Expr); +pub struct Predicate(pub Expr); /// One item in a SELECT projection list. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ProjectItem { +pub struct ProjectItem { pub alias: Option, - pub expr: L3Expr, + pub expr: Expr, } // ── L3 intent algebra IR ────────────────────────────────────────────────────── @@ -385,12 +432,12 @@ pub struct ProjectItem { /// whether a grouping-key list happens to be empty or from a neighboring /// node's shape. See design proposal #165. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Reduction { +pub enum Reduction { /// Collapses input rows via `by` — `by`/`without` semantics are exactly /// [`GroupKeys`]'s. May still collapse every row into one (an empty, /// non-`without` `by`) — that's a genuine reduction with zero grouping /// columns, not "no grouping concept." - Reduce(GroupKeys), + Reduce(GroupKeys), /// No grouping concept at all: preserves one output row per input /// entity (e.g. a per-series windowed computation with no `by(...)` /// clause to begin with, because there's no aggregation operator here @@ -401,16 +448,16 @@ pub enum Reduction { PerEntity, } -impl Reduction { +impl Reduction { /// Shorthand for the common case — group by these (possibly empty) /// keys, kept rather than excluded. - pub fn by(keys: Vec) -> Self { + pub fn by(keys: Vec) -> Self { Self::Reduce(GroupKeys::by(keys)) } /// The grouping keys, if this is a genuine reduction — `None` for /// `PerEntity`, which has no grouping-keys concept to report. - pub fn group_keys(&self) -> Option<&GroupKeys> { + pub fn group_keys(&self) -> Option<&GroupKeys> { match self { Self::Reduce(by) => Some(by), Self::PerEntity => None, @@ -421,7 +468,7 @@ impl Reduction { /// (tests, mostly) that already know, from the shape they built or are /// asserting on, that this must be a genuine reduction. Prefer /// [`group_keys`](Self::group_keys) wherever the caller can't assume that. - pub fn expect_reduce(&self) -> &GroupKeys { + pub fn expect_reduce(&self) -> &GroupKeys { match self { Self::Reduce(by) => by, Self::PerEntity => panic!("expected Reduction::Reduce, got PerEntity"), @@ -430,21 +477,27 @@ impl Reduction { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum QueryExpr { +#[serde(bound(serialize = "C: ColState", deserialize = "C: ColState"))] +pub enum QueryExpr { /// Outermost leaf. `schema` is the **binding schema** — the resolved column /// set every positional `ColumnId` in the tree indexes into, *not* a full - /// description of the runtime row. Complete when catalog-backed (SQL); for - /// schemaless PromQL it is usage-derived by the [`Binder`](super::binder) - /// (the `(ts, value)` floor + the labels the query references), since a - /// metric's label set is open and known only at runtime. That distinction is - /// carried explicitly by [`Schema::closed`](super::schema::Schema::closed) - /// (SQL leaf → `true`, PromQL leaf → `false`). `predicates` are leaf-level - /// row filters (PromQL label matchers, pushed-down `WHERE` conjuncts). + /// description of the runtime row — once bound (`schema: Schema`, always + /// present: the [`Binder`](super::binder) is total). Before binding, a + /// front-end-emitted `Scan` (`C = ColumnRef`) knows it only when the front + /// end already has it without binding — a catalog-backed SQL leaf — `None` + /// (PromQL) defers to the Binder; see [`ColState::ScanSchema`]. Complete + /// when catalog-backed (SQL); for schemaless PromQL the bound schema is + /// usage-derived (the `(ts, value)` floor + the labels the query + /// references), since a metric's label set is open and known only at + /// runtime. That distinction is carried explicitly by + /// [`Schema::closed`](super::schema::Schema::closed) (SQL leaf → `true`, + /// PromQL leaf → `false`). `predicates` are leaf-level row filters (PromQL + /// label matchers, pushed-down `WHERE` conjuncts). Scan { source: Source, #[serde(default)] - predicates: Vec, - schema: Schema, + predicates: Vec>, + schema: C::ScanSchema, }, /// A scalar constant leaf — a PromQL number literal or a folded constant /// scalar expression (`10*1024*1024`). Appears as a [`BinaryOp`](Self::BinaryOp) @@ -460,13 +513,13 @@ pub enum QueryExpr { /// scalar-typed child to a single label-less series carrying the scalar's /// value at every step. Lets a scalar participate where a vector is required /// (`up or vector(0)` dead-man's-switch). Issue #48. - VectorFromScalar(Box), + VectorFromScalar(Box>), /// PromQL `scalar(v)` — the instant-vector→scalar bridge. Collapses a /// single-element vector to its value (NaN at runtime if the input is not /// exactly one series). Lets a vector feed a scalar position (`vector` / /// aggregation `k` args, thresholds). Issue #48. - ScalarFromVector(Box), + ScalarFromVector(Box>), /// ρ — a per-series **label rewrite** (PromQL `label_replace` / /// `label_join`). Every input row passes through unchanged except for the @@ -478,8 +531,8 @@ pub enum QueryExpr { Relabel { /// The label written by this rewrite (PromQL `dst_label`). dst: String, - value: L3Expr, - child: Box, + value: Expr, + child: Box>, }, /// PromQL `info(v, [selector])` — left-join **label enrichment** (#84). Each @@ -494,7 +547,7 @@ pub enum QueryExpr { InfoJoin { #[serde(default)] selector: Vec, - child: Box, + child: Box>, }, /// Series-sampling **selection** — PromQL `limitk` / `limit_ratio` (#86). @@ -503,31 +556,30 @@ pub enum QueryExpr { /// reduction: the output schema equals the child's. Sample { #[serde(default)] - by: GroupKeys, + by: GroupKeys, kind: SampleKind, - child: Box, + child: Box>, }, /// σ — row-level filter. Output schema = child schema. Filter { - pred: Predicate, - child: Box, + pred: Predicate, + child: Box>, }, /// π — column projection. Project { - cols: Vec, + cols: Vec>, /// Re-qualifies every output column with this table alias (a derived - /// table / inline view). `None` for an ordinary SELECT list. See - /// [`relational::QueryExpr::Project`](super::relational). + /// table / inline view). `None` for an ordinary SELECT list. #[serde(default)] qualifier: Option, - child: Box, + child: Box>, }, /// γ + α — GROUP BY (positional) + aggregate intents. Aggregate { - reduction: Reduction, - measures: Vec, + reduction: Reduction, + measures: Vec>, /// Output column names parallel to `measures`. A non-empty entry overrides /// the synthetic intent-keyed name — SQL threads DataFusion's generated /// name (e.g. `"sum(metrics.bytes)"`) here so an enclosing `Project` @@ -537,15 +589,15 @@ pub enum QueryExpr { #[serde(default)] output_names: Vec, #[serde(default)] - having: Option, - child: Box, + having: Option>, + child: Box>, }, /// δ — SQL `DISTINCT` / row deduplication. Positional like every other L3 /// column reference; empty = dedup on all columns (`SELECT DISTINCT *`). Distinct { - cols: Vec, - child: Box, + cols: Vec, + child: Box>, }, /// ⊕ — exact, n-ary `UNION ALL` of independent branches. Rows are /// concatenated, never deduplicated; SQL's `UNION`/`INTERSECT`/`EXCEPT` are @@ -567,20 +619,20 @@ pub enum QueryExpr { /// /// Empty children is an error ([`QueryExprError::EmptyMerge`]), not an /// empty relation: there would be no schema to derive. - Merge { children: Vec }, + Merge { children: Vec> }, /// Logical join. L4 picks the physical alternative. Join { kind: JoinKind, - pred: Predicate, - left: Box, - right: Box, + pred: Predicate, + left: Box>, + right: Box>, }, SetOp { kind: SetOpKind, all: bool, - left: Box, - right: Box, + left: Box>, + right: Box>, }, /// Generic order-by for non-heavy-hitter cases. @@ -593,15 +645,15 @@ pub enum QueryExpr { /// `Partition` node (issue #12: reducing GROUP BY → `Aggregate.by`, per-group /// ranking → here, parallel sharding → L5). Empty = a global order-by. Sort { - keys: Vec, + keys: Vec>, #[serde(default)] - partition_by: GroupKeys, - child: Box, + partition_by: GroupKeys, + child: Box>, }, Limit { n: usize, offset: usize, - child: Box, + child: Box>, }, /// PromQL sub-query (`[range:resolution]`). Logical pass-through. @@ -609,7 +661,7 @@ pub enum QueryExpr { range: Duration, #[serde(default)] resolution: Option, - child: Box, + child: Box>, }, /// Temporal range selection — "look back `range` of history for this @@ -621,7 +673,7 @@ pub enum QueryExpr { /// plain `Scan` or another `Aggregate` is a *cross-series* reduction. TimeRange { range: Duration, - child: Box, + child: Box>, }, /// PromQL `offset` / `@` **time shift** on a selector (issue #40). A @@ -634,7 +686,7 @@ pub enum QueryExpr { /// selector when neither modifier is present). TimeShift { shift: TimeShift, - child: Box, + child: Box>, }, /// SQL analytic window function: `func(args) OVER (PARTITION BY … ORDER BY …)`. @@ -644,26 +696,44 @@ pub enum QueryExpr { func: WindowFuncKind, /// Operand expressions (`LAG(value)` → `[Column(value_id)]`); empty for /// the rank-only functions (`ROW_NUMBER`/`RANK`/`DENSE_RANK`). - args: Vec, - partition_by: GroupKeys, - order_by: Vec, + args: Vec>, + partition_by: GroupKeys, + order_by: Vec>, /// The output column's name — DataFusion's window-expr field name, so a /// `Project` above resolves it (cf. `Aggregate.output_names`). output_name: String, - child: Box, + child: Box>, }, /// Arithmetic / comparison / boolean composition (PromQL binary ops). BinaryOp { op: BinaryOpKind, - lhs: Box, - rhs: Box, + lhs: Box>, + rhs: Box>, #[serde(default)] vector_match: Option, }, } -impl QueryExpr { +/// The canonical, positional Layer-3 tree — what the bare `QueryExpr` name has +/// always meant (the default `C = ColumnId`). Every existing consumer keeps +/// using `QueryExpr` unparameterized; this alias exists only to name the +/// resolved state explicitly at a use site that also wants to name +/// [`L2QueryExpr`] nearby. +pub type L3QueryExpr = QueryExpr; + +/// The front-end-emitted, name-based Layer-2 tree — `QueryExpr`, +/// unresolved: front ends construct this directly during their own `interpret` +/// step (issue #179), and the [`Binder`](super::binder) resolves it into +/// [`L3QueryExpr`]. +pub type L2QueryExpr = QueryExpr; + +// `output_schema` needs a fully bound tree — it reads `Scan.schema` as a plain +// `Schema` and resolves every scalar `Expr::Column` positionally — so it lives +// only on the resolved instantiation, not `impl QueryExpr`. +// Same reasoning as `AggIntent`'s `output_column`/`requires`/`is_per_series` +// (#205): a schema-shaped property that is only meaningful post-binding. +impl QueryExpr { /// Output schema of the root of a canonical tree. pub fn output_schema(&self) -> Result { match self { diff --git a/crates/types/src/pre_asap/resolve.rs b/crates/types/src/pre_asap/resolve.rs new file mode 100644 index 00000000..17719b43 --- /dev/null +++ b/crates/types/src/pre_asap/resolve.rs @@ -0,0 +1,489 @@ +//! Resolve a front-end-emitted, unresolved [`L2QueryExpr`] (`QueryExpr`) +//! into the canonical, positional [`L3QueryExpr`] (`QueryExpr`). +//! +//! Both front ends (`asap-frontend-promql`, `asap-frontend-sql`) construct +//! canonical `QueryExpr` shapes directly during their own `interpret` step +//! (issue #179) — heavy-hitter `topk` recognition, the window-over-aggregate +//! fold, the `PerEntity`/`Reduce` reduction choice, and every other +//! *structural* decision happen right there, since a front end already knows +//! the answer at parse time. What's left for [`resolve_root`] is exactly the +//! "mechanical, schema-dependent substitution" #179 describes: a single +//! generic, shape-preserving walk — every [`L2QueryExpr`] variant maps to the +//! identical [`L3QueryExpr`] variant — that resolves every [`ColumnRef`] to +//! the [`Binder`](super::binder::Binder)-computed positional [`ColumnId`]. + +use thiserror::Error; + +use super::agg_intent::AggIntent; +use super::binder::Binder; +use super::column_resolution::{ + resolve_column_ref, resolve_column_refs, resolve_expr, resolve_group_keys_promql, ResolveError, +}; +use super::expr_ir::ColumnRef; +use super::query_expr::{ + aggregate_output_schema, GroupKeys, L2QueryExpr, L3QueryExpr, Predicate, ProjectItem, + QueryExprError, Reduction, SortKey, +}; +use super::schema::{ColumnId, Schema}; + +/// Errors from resolving a canonical, unresolved [`L2QueryExpr`] tree. +#[derive(Debug, Error)] +pub enum ResolveTreeError { + /// A column reference did not resolve against its in-scope schema. + #[error("column resolution failed: {0}")] + Resolve(#[from] ResolveError), + /// Deriving the schema of an already-resolved child failed (needed to + /// resolve positional column references against it). + #[error("schema derivation failed: {0}")] + Schema(#[from] QueryExprError), +} + +/// Resolve a whole [`L2QueryExpr`] tree rooted at `tree` into canonical +/// [`L3QueryExpr`]: binds every `ColumnRef` to a `ColumnId` via the +/// [`Binder`], then [`canonicalize`](super::canonicalize::canonicalize)s the +/// result. +pub fn resolve_root(tree: &L2QueryExpr) -> Result { + resolve_root_with_inherited(tree, &[]) +} + +/// [`resolve_root`] with label names inherited from an enclosing scope seeded +/// into the leaf schema, used when re-binding a `BinaryOp` side (issue #52). +fn resolve_root_with_inherited( + tree: &L2QueryExpr, + inherited: &[String], +) -> Result { + let fallback = Binder::new().bind_with_inherited(tree, inherited); + let l3 = resolve(tree, &fallback)?; + Ok(super::canonicalize::canonicalize(l3)) +} + +/// The generic substitution walk: converts children first (bottom-up), then +/// resolves this node's own `ColumnRef`s against the *converted child's* +/// derived output schema — so a `JOIN`'s concatenated schema and a cross- +/// series aggregate's frozen-closed output bind to the right positions. +fn resolve(tree: &L2QueryExpr, fallback: &Schema) -> Result { + use super::query_expr::QueryExpr as QE; + Ok(match tree { + QE::Scan { + source, + predicates, + schema, + } => { + let schema = schema.clone().unwrap_or_else(|| fallback.clone()); + let predicates = predicates + .iter() + .map(|Predicate(e)| Ok(Predicate(resolve_expr(e, &schema)?))) + .collect::, ResolveError>>()?; + QE::Scan { + source: source.clone(), + predicates, + schema, + } + } + + QE::Scalar(v) => QE::Scalar(*v), + QE::EvalTime => QE::EvalTime, + + QE::VectorFromScalar(child) => QE::VectorFromScalar(Box::new(resolve(child, fallback)?)), + QE::ScalarFromVector(child) => QE::ScalarFromVector(Box::new(resolve(child, fallback)?)), + + QE::Relabel { dst, value, child } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + QE::Relabel { + dst: dst.clone(), + value: resolve_expr(value, &child_schema)?, + child: Box::new(child), + } + } + + QE::InfoJoin { selector, child } => QE::InfoJoin { + selector: selector.clone(), + child: Box::new(resolve(child, fallback)?), + }, + + QE::Sample { by, kind, child } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + QE::Sample { + by: resolve_group_keys(by, &child_schema)?, + kind: *kind, + child: Box::new(child), + } + } + + QE::Filter { pred, child } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + QE::Filter { + pred: Predicate(resolve_expr(&pred.0, &child_schema)?), + child: Box::new(child), + } + } + + QE::Project { + cols, + qualifier, + child, + } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + let cols = cols + .iter() + .map(|item| -> Result { + Ok(ProjectItem { + alias: item.alias.clone(), + expr: resolve_expr(&item.expr, &child_schema)?, + }) + }) + .collect::, _>>()?; + QE::Project { + cols, + qualifier: qualifier.clone(), + child: Box::new(child), + } + } + + QE::Aggregate { + reduction, + measures, + output_names, + having, + child, + } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + let reduction = resolve_reduction(reduction, &child_schema)?; + let measures = measures + .iter() + .map(|m| resolve_agg_intent(m, &child_schema)) + .collect::, ResolveError>>()?; + let having = having + .as_ref() + .map(|Predicate(h)| -> Result { + let out_schema = aggregate_output_schema( + &child_schema, + &reduction, + &measures, + output_names, + )?; + Ok(Predicate(resolve_expr(h, &out_schema)?)) + }) + .transpose()?; + QE::Aggregate { + reduction, + measures, + output_names: output_names.clone(), + having, + child: Box::new(child), + } + } + + QE::Distinct { cols, child } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + QE::Distinct { + cols: resolve_column_refs(cols, &child_schema)?, + child: Box::new(child), + } + } + + QE::Merge { children } => QE::Merge { + children: children + .iter() + .map(|c| resolve(c, fallback)) + .collect::, _>>()?, + }, + + QE::Join { + kind, + pred, + left, + right, + } => { + // Each branch is bound independently, same reasoning as `BinaryOp` + // below — different leaves / label sets. + let left = resolve_root_with_inherited(left, &[])?; + let right = resolve_root_with_inherited(right, &[])?; + let mut concat = left.output_schema()?; + concat.columns.extend(right.output_schema()?.columns); + let pred = Predicate(resolve_expr(&pred.0, &concat)?); + QE::Join { + kind: kind.clone(), + pred, + left: Box::new(left), + right: Box::new(right), + } + } + + QE::SetOp { + kind, + all, + left, + right, + } => QE::SetOp { + kind: kind.clone(), + all: *all, + left: Box::new(resolve_root_with_inherited(left, &[])?), + right: Box::new(resolve_root_with_inherited(right, &[])?), + }, + + QE::Sort { + keys, + partition_by, + child, + } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + let keys = keys + .iter() + .map(|k| -> Result { + Ok(SortKey { + expr: resolve_expr(&k.expr, &child_schema)?, + ascending: k.ascending, + nulls_first: k.nulls_first, + }) + }) + .collect::, _>>()?; + let partition_by = resolve_group_keys(partition_by, &child_schema)?; + QE::Sort { + keys, + partition_by, + child: Box::new(child), + } + } + + QE::Limit { n, offset, child } => QE::Limit { + n: *n, + offset: *offset, + child: Box::new(resolve(child, fallback)?), + }, + + QE::Subquery { + range, + resolution, + child, + } => QE::Subquery { + range: *range, + resolution: *resolution, + child: Box::new(resolve(child, fallback)?), + }, + + QE::TimeRange { range, child } => QE::TimeRange { + range: *range, + child: Box::new(resolve(child, fallback)?), + }, + + QE::TimeShift { shift, child } => QE::TimeShift { + shift: *shift, + child: Box::new(resolve(child, fallback)?), + }, + + QE::WindowFunc { + func, + args, + partition_by, + order_by, + output_name, + child, + } => { + let child = resolve(child, fallback)?; + let child_schema = child.output_schema()?; + let args = args + .iter() + .map(|a| resolve_expr(a, &child_schema)) + .collect::, _>>()?; + let partition_by = resolve_group_keys(partition_by, &child_schema)?; + let order_by = order_by + .iter() + .map(|k| -> Result { + Ok(SortKey { + expr: resolve_expr(&k.expr, &child_schema)?, + ascending: k.ascending, + nulls_first: k.nulls_first, + }) + }) + .collect::, _>>()?; + QE::WindowFunc { + func: func.clone(), + args, + partition_by, + order_by, + output_name: output_name.clone(), + child: Box::new(child), + } + } + + QE::BinaryOp { + op, + lhs, + rhs, + vector_match, + } => { + // A binary op's two sides may scan different metrics with + // different label sets, so each branch resolves against its OWN + // bound schema; but an independently-bound side still has to see + // label names an *enclosing* node references (issue #52). + let own = super::binder::collect_referenced_columns(tree); + let inherited: Vec = inherited_names(fallback) + .into_iter() + .filter(|n| !own.contains(n)) + .collect(); + QE::BinaryOp { + op: op.clone(), + lhs: Box::new(resolve_root_with_inherited(lhs, &inherited)?), + rhs: Box::new(resolve_root_with_inherited(rhs, &inherited)?), + vector_match: vector_match.clone(), + } + } + }) +} + +/// The label names an enclosing scope's schema carries beyond the `(ts, +/// value)` floor — mirrors [`lower::inherited_names`](super::lower). +fn inherited_names(schema: &Schema) -> Vec { + schema + .columns + .iter() + .filter(|c| c.name != "ts" && c.name != "value") + .map(|c| c.name.clone()) + .collect() +} + +/// Resolve a name-based [`GroupKeys`] into positional +/// [`GroupKeys`], preserving its `by`/`without` mode. +fn resolve_group_keys( + keys: &GroupKeys, + schema: &Schema, +) -> Result, ResolveError> { + let ids = resolve_column_refs(keys.keys(), schema)?; + Ok(if keys.is_without() { + GroupKeys::without(ids) + } else { + GroupKeys::by(ids) + }) +} + +/// Resolve a name-based [`Reduction`] into positional +/// [`Reduction`]. +/// +/// Uses [`resolve_group_keys_promql`] rather than the strict +/// [`resolve_group_keys`], unlike every other group-key site in `resolve` +/// (`Sample.by`, `Sort.partition_by`, `WindowFunc.partition_by`): a key +/// absent from a **closed** schema (e.g. the output of a nested cross-series +/// aggregate that collapsed the label) is provably absent from every row, so +/// PromQL drops it from the grouping rather than rejecting the query (issue +/// #53) — `sum(sum by (group) (m)) by (job)` is the canonical case, `job` +/// absent from the inner aggregate's closed `[group, sum]` output. Applied +/// uniformly to every `Aggregate`, not just PromQL's: SQL's `GROUP BY` keys +/// are always genuinely present (DataFusion validates the plan), so the +/// "drop instead of reject" branch is simply never exercised there — the +/// lenient resolver is a no-op difference for a SQL tree, not a behavior +/// change. +fn resolve_reduction( + reduction: &Reduction, + schema: &Schema, +) -> Result, ResolveError> { + Ok(match reduction { + Reduction::Reduce(by) => { + let ids = resolve_group_keys_promql(by.keys(), schema)?; + Reduction::Reduce(if by.is_without() { + GroupKeys::without(ids) + } else { + GroupKeys::by(ids) + }) + } + Reduction::PerEntity => Reduction::PerEntity, + }) +} + +/// Resolve a name-based [`AggIntent`] into positional +/// [`AggIntent`] — every `col: Option` resolves to +/// `Option` (`None` stays `None`, the sample-value convention); +/// every other field carries straight through unchanged. +fn resolve_agg_intent( + intent: &AggIntent, + schema: &Schema, +) -> Result, ResolveError> { + let col = |c: &Option| -> Result, ResolveError> { + c.as_ref() + .map(|r| resolve_column_ref(r, schema)) + .transpose() + }; + Ok(match intent { + AggIntent::Count { accuracy } => AggIntent::Count { + accuracy: accuracy.clone(), + }, + AggIntent::Sum { col: c } => AggIntent::Sum { col: col(c)? }, + AggIntent::Min { col: c } => AggIntent::Min { col: col(c)? }, + AggIntent::Max { col: c } => AggIntent::Max { col: col(c)? }, + AggIntent::Avg { col: c } => AggIntent::Avg { col: col(c)? }, + AggIntent::StdDev { col: c, population } => AggIntent::StdDev { + col: col(c)?, + population: *population, + }, + AggIntent::Variance { col: c, population } => AggIntent::Variance { + col: col(c)?, + population: *population, + }, + AggIntent::Quantile { + col: c, + q, + accuracy, + } => AggIntent::Quantile { + col: col(c)?, + q: *q, + accuracy: accuracy.clone(), + }, + AggIntent::TopK { k, accuracy } => AggIntent::TopK { + k: *k, + accuracy: accuracy.clone(), + }, + AggIntent::Cardinality { col: c, accuracy } => AggIntent::Cardinality { + col: col(c)?, + accuracy: accuracy.clone(), + }, + AggIntent::Rate => AggIntent::Rate, + AggIntent::Increase => AggIntent::Increase, + AggIntent::Changes => AggIntent::Changes, + AggIntent::Delta => AggIntent::Delta, + AggIntent::IDelta => AggIntent::IDelta, + AggIntent::Deriv => AggIntent::Deriv, + AggIntent::Resets => AggIntent::Resets, + AggIntent::PredictLinear { seconds } => AggIntent::PredictLinear { seconds: *seconds }, + AggIntent::DoubleExpSmoothing { smoothing, trend } => AggIntent::DoubleExpSmoothing { + smoothing: *smoothing, + trend: *trend, + }, + AggIntent::HistogramCount => AggIntent::HistogramCount, + AggIntent::HistogramSum => AggIntent::HistogramSum, + AggIntent::HistogramAvg => AggIntent::HistogramAvg, + AggIntent::HistogramStdDev => AggIntent::HistogramStdDev, + AggIntent::HistogramStdVar => AggIntent::HistogramStdVar, + AggIntent::HistogramFraction { lower, upper } => AggIntent::HistogramFraction { + lower: *lower, + upper: *upper, + }, + AggIntent::HistogramQuantile { q } => AggIntent::HistogramQuantile { q: *q }, + AggIntent::Math(f) => AggIntent::Math(f.clone()), + AggIntent::Absent => AggIntent::Absent, + AggIntent::AbsentOverTime => AggIntent::AbsentOverTime, + AggIntent::PresentOverTime => AggIntent::PresentOverTime, + AggIntent::TimeFn(f) => AggIntent::TimeFn(*f), + AggIntent::Group => AggIntent::Group, + AggIntent::CountValues { label } => AggIntent::CountValues { + label: label.clone(), + }, + AggIntent::LastOverTime => AggIntent::LastOverTime, + AggIntent::FirstOverTime => AggIntent::FirstOverTime, + AggIntent::MadOverTime => AggIntent::MadOverTime, + AggIntent::TsOfMinOverTime => AggIntent::TsOfMinOverTime, + AggIntent::TsOfMaxOverTime => AggIntent::TsOfMaxOverTime, + AggIntent::TsOfFirstOverTime => AggIntent::TsOfFirstOverTime, + AggIntent::TsOfLastOverTime => AggIntent::TsOfLastOverTime, + AggIntent::Extension { ext_kind, payload } => AggIntent::Extension { + ext_kind: ext_kind.clone(), + payload: payload.clone(), + }, + }) +}