Skip to content

refactor(ir): single canonical Aggregate output-schema derivation (#41) - #92

Merged
zzylol merged 1 commit into
mainfrom
refactor/41-dedup-aggregate-schema
Jul 4, 2026
Merged

zzylol merged 1 commit into
mainfrom
refactor/41-dedup-aggregate-schema

Conversation

@zzylol

@zzylol zzylol commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #41.

Problem

"What is the output schema of an Aggregate { by, aggs }?" was answered by two independent implementations:

  1. QueryExpr::output_schema_in's Aggregate arm — the canonical, general-purpose one.
  2. column_resolution::output_schema_for_aggregate — a hand-mirror used only to resolve a HAVING predicate against the aggregate's output columns.

The mirror was documented as a copy rather than a shared call, and had already drifted: it lacked the per-series-reduction branch (rate/increase/*_over_time, which preserve label columns instead of collapsing to by ++ aggs) and — after #49 — the count_values two-column special case and its unique_keys conservatism. Latent today (HAVING is SQL-only, per-series is PromQL-only, so they never disagree on a reachable input), but nothing prevented that, and no test asserted agreement.

Fix

Extract one canonical aggregate_output_schema(in_schema, by, aggs, output_names, per_series) in query_expr.rs; both callers delegate to it:

  • output_schema_in's arm computes per_series from the child node (the TimeRange/Subquery marker or aggs[0].is_per_series()) and calls the shared fn — its behavior is unchanged.
  • output_schema_for_aggregate becomes a thin wrapper. It can't see the child node, so it computes the child-independent part of per_series (by.is_empty() && aggs.len()==1 && aggs[0].is_per_series()) — enough to agree with the canonical derivation on every reachable input (HAVING is cross-series-only and never co-occurs with the range-child marker). It now returns Result (the shared fn errors on an out-of-range group-by id, matching the canonical arm); the single converter call site threads ? (auto-converted via the existing ConvertError: #[from] QueryExprError).

Net: the count_values / per-series / unique_keys / closed logic now lives in exactly one place, so the two can never drift again. Future language paths that resolve HAVING get the full derivation for free.

Test

having_schema_agrees_with_canonical_for_a_per_series_reduction constructs the exact shape the issue named — Aggregate{ by: [], [Rate], child: TimeRange{Scan} } — and asserts output_schema_for_aggregate now equals QueryExpr::output_schema for it (label-preserving [ts, value], not a collapsed [rate]). This test fails on main and passes here.

Full workspace suite green; clippy clean.

The "output schema of an Aggregate{by, aggs}" was implemented twice: the
canonical `QueryExpr::output_schema_in` arm and a hand-mirrored
`column_resolution::output_schema_for_aggregate` (used only to resolve HAVING
against the aggregate's output). The mirror had already drifted — it lacked
the per-series-reduction branch (rate/increase/*_over_time preserve labels
instead of collapsing to `by ++ aggs`) and, after #49, the count_values
two-column special case and its unique_keys conservatism.

Extract one `aggregate_output_schema(in_schema, by, aggs, output_names,
per_series)` in query_expr.rs and have both callers delegate to it:
- `output_schema_in`'s Aggregate arm computes `per_series` from the child
  (the `TimeRange`/`Subquery` marker OR `aggs[0].is_per_series()`), then calls
  the shared fn.
- `output_schema_for_aggregate` becomes a thin wrapper: it can't see the child
  node, so it computes the child-independent part of `per_series`
  (`by.is_empty() && aggs.len()==1 && aggs[0].is_per_series()`) — enough to
  agree with the canonical derivation on every reachable input (HAVING is
  SQL/cross-series-only and never co-occurs with the range-child marker). It
  now returns `Result` (the shared fn errors on an out-of-range group-by id,
  matching the canonical arm); the one converter call site threads `?`.

Test: `having_schema_agrees_with_canonical_for_a_per_series_reduction` builds a
per-series `Aggregate{[Rate], TimeRange{Scan}}` and asserts the two
derivations now produce identical schemas — the exact divergence the issue
described, which had no coverage before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zzylol
zzylol merged commit 77881ee into main Jul 4, 2026
1 check passed
@zzylol
zzylol deleted the refactor/41-dedup-aggregate-schema branch July 4, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Aggregate output-schema derivation is duplicated by hand between query_expr.rs and column_resolution.rs

1 participant