Skip to content

Implement aggregate functions with GROUP BY support - #6

Merged
ragnorc merged 1 commit into
mainfrom
claude/omnigraph-aggregates-a53rG
Apr 13, 2026
Merged

Implement aggregate functions with GROUP BY support#6
ragnorc merged 1 commit into
mainfrom
claude/omnigraph-aggregates-a53rG

Conversation

@ragnorc

@ragnorc ragnorc commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds support for aggregate functions (COUNT, SUM, AVG, MIN, MAX) with GROUP BY semantics to the query execution engine. It also refactors the graph traversal (Expand) operation to maintain row alignment across correlated bindings, which is essential for correct aggregate computation.

Key Changes

Aggregate Function Support

  • Added aggregate_return() function to handle queries with aggregate expressions
  • Implements grouping logic that classifies projections into group-by keys (non-aggregate expressions) and aggregate expressions
  • Supports all five aggregate functions with proper type handling:
    • COUNT: counts non-null values per group
    • SUM: sums numeric values (Int32/64, UInt32/64, Float32/64)
    • AVG: computes average as Float64
    • MIN/MAX: finds extrema with support for numeric and string types
  • Handles edge case of empty input (returns single row with COUNT=0, others=NULL)
  • Added type checking rule T9 to ensure non-aggregate expressions in aggregate queries are valid group-by keys (PropAccess or Variable only)

Row Alignment for Expand Operations

  • Refactored execute_expand() to mutate bindings in-place and maintain row alignment
  • Changed return type from Result<RecordBatch> to Result<()> with direct binding updates
  • Implements join-aligned semantics: for each (source_row, dest_node) pair, all existing bindings are repeated so row i across all bindings represents one matched tuple
  • Uses index arrays (src_indices, dst_indices) to perform aligned take operations on all correlated bindings
  • Sources with zero destinations are dropped (inner-join semantics)

Filter and AntiJoin Alignment

  • Updated apply_filter() to filter all correlated bindings (same row count) to maintain alignment
  • Enhanced execute_anti_join() to filter all correlated bindings when removing rows
  • Added fast-path optimization in execute_anti_join() that reconstructs the filter mask for other bindings

Compiler Changes

  • Added has_aggregate flag to QueryIR struct to track if query contains aggregates
  • Updated query lowering to propagate aggregate information
  • Added AggFunc import to execution module

Testing

  • Added comprehensive test suite (aggregation.rs) covering:
    • COUNT with GROUP BY (friend counts per person)
    • Global COUNT (no group key)
    • SUM/AVG/MIN/MAX with GROUP BY (age statistics per company)
    • Aggregate with ORDER BY and LIMIT (top connected person)
  • Added test queries to fixture file

Implementation Details

  • Group keys are built as delimited strings (using \x1F unit separator) to handle multi-column grouping
  • Null values in group keys are represented as \x00NULL to distinguish from empty strings
  • Aggregate computation iterates through grouped row indices, handling nulls appropriately per function
  • Result columns are sorted back to original projection order after computation
  • Variable projection support added to evaluate_projection() for accessing node IDs in aggregates

https://claude.ai/code/session_019o5NRyYomgETFyd7hpiLey


Note

Medium Risk
Adds new aggregation execution path and refactors the query pipeline to operate on a single wide RecordBatch with prefixed columns, changing traversal/antijoin/filter semantics and ordering resolution; this touches core query execution and could impact result correctness/performance.

Overview
Enables aggregate queries (count, sum, avg, min, max) with implicit GROUP BY semantics by adding an aggregate projection path that groups by non-aggregate return expressions and computes per-group results (including an empty-input global-aggregate case).

Refactors execution from per-variable bindings to a single wide batch with var.property column names: node scans are cross-joined, expand now maintains row alignment by duplicating source rows per destination and horizontally concatenating prefixed destination columns, and filters/antijoins operate on the wide batch. Ordering is updated to resolve property accesses against either the wide batch or the aggregate result batch.

Tightens compiler/typechecking for aggregates: enforces that non-aggregate projections in aggregate queries are valid group keys (only prop accesses or variables) and allows min/max over strings; result schema inference updates sum/avg to Float64 and min/max to the argument type. Adds an aggregation-focused test suite and new fixture queries covering grouped/global aggregates plus ordering/limit.

Reviewed by Cursor Bugbot for commit 351610d. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd1e0ed15e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment on lines +1833 to +1837
for key in &binding_keys {
let batch = bindings.get(key).unwrap();
let aligned_columns: Vec<ArrayRef> = batch
.columns()
.iter()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict expand alignment to correlated bindings

This loop applies src_take (built from src_var row indices) to every binding in scope, not just the ones correlated with src_var. In queries that have additional bound variables with fewer rows (for example, an extra NodeScan in the same match block), take will receive out-of-range indices and fail the query at runtime. The alignment step should only rewrite correlated bindings (or at minimum guard on compatible row counts).

Useful? React with 👍 / 👎.

Comment thread crates/omnigraph/src/exec/mod.rs Outdated
match &proj.expr {
IRExpr::Aggregate { func, arg } => {
let (name, col) = evaluate_projection(bindings, arg, params)?;
let alias = proj.alias.as_deref().unwrap_or(&name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use aggregate function name as default output alias

For aggregate projections without an explicit alias, this uses the aggregate argument's name as the output column name (count($p) becomes p) instead of the aggregate expression name (count). That makes unnamed aggregate columns inconsistent and can break downstream lookups/order clauses that expect the default aggregate name unless users manually alias every aggregate.

Useful? React with 👍 / 👎.

Comment thread crates/omnigraph/src/exec/mod.rs Outdated
let mut sum = None;
for &i in group {
if !arr.is_null(i) {
*sum.get_or_insert(0) += arr.value(i) as i64;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve UInt64 precision in SUM accumulation

The integer SUM path casts every value to i64, including UInt64. Any input above i64::MAX will wrap/truncate and produce incorrect (potentially negative) sums, so valid unsigned counters can be silently corrupted. UInt64 needs a dedicated accumulation type/path instead of forcing i64.

Useful? React with 👍 / 👎.

Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment on lines +3087 to +3090
_ => {
// sum/min/max return null for empty input
fields.push(Field::new(name, DataType::Int64, true));
columns.push(Arc::new(Int64Array::from(vec![None as Option<i64>])) as ArrayRef);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive empty-aggregate types from aggregate arguments

The empty-input fallback hardcodes nullable Int64 for the non-COUNT/AVG aggregates, so sum/min/max return a different type when there are zero input rows. This makes result schemas data-dependent (e.g., sum(Float64) yields Int64 only on empty input), which can break typed consumers and schema assumptions.

Useful? React with 👍 / 👎.

Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment thread crates/omnigraph-compiler/src/ir/mod.rs Outdated
Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment thread crates/omnigraph/src/exec/mod.rs Outdated
Comment thread crates/omnigraph/src/exec/mod.rs Outdated
@ragnorc
ragnorc force-pushed the claude/omnigraph-aggregates-a53rG branch from f8ab772 to f6078d9 Compare April 12, 2026 12:34

@aaltshuler aaltshuler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three issues from the aggregate patch need attention:

  1. Variable projections no longer match their declared result type. The new IRExpr::Variable projection path returns the <binding>.id UTF-8 column from the wide batch, but the compiler still resolves Expr::Variable to ResolvedType::Node(...) and advertises a full struct via resolved_type_to_field_shape. Queries like return { $p } will therefore typecheck as a node-shaped result and execute as a string ID column instead.

  2. sum(...) output type diverges from the compiler contract. compute_sum now always materializes a Float64Array, but projection typing still uses the aggregate argument's type for every aggregate other than count and avg. A query such as return { sum($p.age) } will therefore be typed as Int32/Int64 at compile time and returned as Float64 at runtime.

  3. min/max on strings are still rejected by typechecking. The executor adds Utf8 handling in compute_min_max, but T8 in resolve_expr_type still groups min and max with sum and avg under a numeric-only check. As written, min($n.name) and max($n.name) still fail in the compiler even though the runtime support was added.

Add runtime support for aggregate functions (count, sum, avg, min, max)
with GROUP BY semantics, built on a single wide RecordBatch that
eliminates correlation tracking by construction.

Execution engine (exec/query.rs):
- Replace HashMap<String, RecordBatch> with Option<RecordBatch> where
  columns are prefixed as <variable>.<property>
- NodeScan prefixes columns and cross-joins with existing batch
- Expand collects (src_row, dst_id) pairs, takes wide batch rows,
  appends prefixed destination columns via hconcat
- Filter applies single mask to entire wide batch
- AntiJoin: fast-path returns BooleanArray mask; slow-path slices
  one row for inner pipeline execution

Projection engine (exec/projection.rs):
- aggregate_return groups rows by non-aggregate key columns using
  length-prefixed string encoding, computes per-group aggregates
- SUM accumulates into f64 to avoid integer overflow
- MIN/MAX support both numeric and string types
- Empty input returns count=0, others=null

Compiler (typecheck.rs):
- T8: split MIN/MAX from SUM/AVG — allow string arguments
- T9: non-aggregate expressions in aggregate queries must be
  property accesses or variables
- SUM type inference returns Float64 (matching runtime)

Tests: 8 new integration tests covering grouped count, global count,
sum/avg/min/max per company, aggregate+order+limit, string min/max,
multi-hop aggregates, and edge cases.

https://claude.ai/code/session_019o5NRyYomgETFyd7hpiLey
@ragnorc
ragnorc force-pushed the claude/omnigraph-aggregates-a53rG branch from 8b4a24a to 351610d Compare April 12, 2026 20:59

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 351610d. Configure here.

_ => {
fields.push(Field::new(name, DataType::Float64, true));
columns.push(Arc::new(Float64Array::from(vec![None as Option<f64>])) as ArrayRef);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty aggregate result uses wrong type for MIN/MAX

Medium Severity

build_empty_aggregate_result unconditionally uses DataType::Float64 for all non-COUNT aggregates (the _ wildcard arm), but MIN/MAX preserve the original column type in both the typecheck (infer_projection_field resolves to the arg type) and execution (compute_min_max builds typed arrays matching the input). This means a global min($p.age) on an empty table returns a Float64 NULL instead of Int32 NULL, and min($p.name) returns Float64 instead of Utf8 — a schema inconsistency that could cause downstream type-mismatch errors or client deserialization failures.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 351610d. Configure here.

@ragnorc
ragnorc merged commit c5a88ca into main Apr 13, 2026
4 checks passed
@aaltshuler aaltshuler mentioned this pull request Apr 14, 2026
@aaltshuler
aaltshuler deleted the claude/omnigraph-aggregates-a53rG branch June 8, 2026 15:43
ragnorc added a commit that referenced this pull request Jun 26, 2026
…tAdapter scope

Addresses the remaining valid review findings (doc/CI accuracy, no behavior change):

#2: s3_battery_holds is bucket-gated and lives in the `dst` target, which no CI
job ran — so the DST S3 battery never executed despite MATRIX/testing.md marking
S3 ✅. Add a 'RustFS DST white-box battery' step to the rustfs_integration job
(same grep-guard against a vacuous pass) and add the dst test paths +
omnigraph-dst crate to the RustFS change filter so a DST change triggers the job.

#6: the MATRIX 'open / recovery sweep' row said unsampled while the same harness
ships the Reopen walk op + the dst_recovery failpoint cells (roll-forward + the
#296 concurrent-opens cell). Mark it ✅ with the actual coverage.

#7: tighten the FaultAdapter wording (module doc + the seeded_op_loop_with_cas_faults
comment) — the seam is the StorageAdapter conditional TEXT-OBJECT write
(sidecars/schema-staging/cluster-state), NOT the Lance manifest-publish CAS
(MergeInsertBuilder), which dst_recovery covers via failpoints. No code change.
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.

3 participants