Implement aggregate execution with wide-batch model

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
This commit is contained in:
Claude 2026-04-12 20:59:13 +00:00
parent 34cc2ccf3a
commit 351610d18c
No known key found for this signature in database
6 changed files with 868 additions and 152 deletions

View file

@ -189,6 +189,29 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeCont
));
}
// T9: If any return expression is an aggregate, non-aggregate expressions
// must be valid group-by keys (PropAccess or Variable).
let has_agg = query
.return_clause
.iter()
.any(|p| matches!(p.expr, Expr::Aggregate { .. }));
if has_agg {
for proj in &query.return_clause {
if !matches!(proj.expr, Expr::Aggregate { .. }) {
match &proj.expr {
Expr::PropAccess { .. } | Expr::Variable(_) => {}
_ => {
return Err(NanoError::Type(
"T9: non-aggregate expressions in an aggregate query must be \
property accesses or variables"
.to_string(),
));
}
}
}
}
}
Ok(ctx)
}
@ -1298,9 +1321,9 @@ fn resolve_expr_type(
Expr::Aggregate { func, arg } => {
let arg_type = resolve_expr_type(catalog, arg, ctx, params)?;
// T8: sum/avg/min/max require numeric
// T8: sum/avg require numeric; min/max require numeric or string
match func {
AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
AggFunc::Sum | AggFunc::Avg => {
if let ResolvedType::Scalar(s) = &arg_type
&& (s.list || !s.scalar.is_numeric())
{
@ -1311,6 +1334,17 @@ fn resolve_expr_type(
)));
}
}
AggFunc::Min | AggFunc::Max => {
if let ResolvedType::Scalar(s) = &arg_type
&& (s.list || (!s.scalar.is_numeric() && s.scalar != ScalarType::String))
{
return Err(NanoError::Type(format!(
"T8: {} requires numeric or string type, got {}",
func,
s.display_name()
)));
}
}
_ => {} // count works on any type
}
@ -1340,8 +1374,8 @@ fn infer_projection_field(
Expr::Aggregate { func, arg } => {
let (data_type, nullable) = match func {
AggFunc::Count => (DataType::Int64, true),
AggFunc::Avg => (DataType::Float64, true),
_ => {
AggFunc::Avg | AggFunc::Sum => (DataType::Float64, true),
AggFunc::Min | AggFunc::Max => {
let resolved = resolve_expr_type(catalog, arg, ctx, params)?;
let (data_type, _) = resolved_type_to_field_shape(catalog, &resolved)?;
(data_type, true)