feat: add run and runGlobal to aggregate

This commit is contained in:
dualfroz committed 2026-09-01 13:57:40 +02:00
1 parent 9b639f1592
commit 96f2142f82
1 file changed
+156
+156
View File
@@ -502,3 +502,159 @@ pub const GroupBy = struct {
}
};
/// Convenience wrapper: groups `rows` by `groupField`, applies `specs`, and
/// returns the finished result in one call.
pub fn run(allocator: Allocator, rows: []const Row, groupField: []const u8, specs: []const AggSpec) AggregateError![]GroupRow {
var gb = GroupBy.init(allocator, groupField, specs);
errdefer gb.deinit();
for (rows) |*r| try gb.observe(r);
return gb.finish();
}
/// A single aggregate row over all of `rows` with no grouping (SQL's
/// `SELECT COUNT(*), SUM(x) FROM t` with no `GROUP BY`) - always produces
/// exactly one result, even for zero input rows (every accumulator still
/// reports its identity result: `count` 0, `sum` 0, `avg`/`min`/`max`
/// `.none`).
pub fn runGlobal(allocator: Allocator, rows: []const Row, specs: []const AggSpec) AggregateError![]Value {
var accs = try allocator.alloc(AccState, specs.len);
defer allocator.free(accs);
for (specs, 0..) |spec, i| accs[i] = .{ .kind = spec.kind, .field = spec.field };
errdefer for (accs) |*a| a.deinit(allocator);
for (rows) |*r| {
for (accs) |*a| try a.observe(allocator, r);
}
var values = try allocator.alloc(Value, specs.len);
for (accs, 0..) |*a, i| values[i] = a.take(allocator);
return values;
}
pub fn freeGlobalResult(allocator: Allocator, values: []Value) void {
for (values) |v| v.free(allocator);
allocator.free(values);
}
fn makeRow(allocator: Allocator, fields: []const rowmod.Field) !Row {
var r = Row.init(allocator);
for (fields) |f| try r.set(f.name, f.value);
return r;
}
fn findGroup(rows: []const GroupRow, group: Value) ?*const GroupRow {
for (rows) |*r| {
if (Value.eql(r.group, group)) return r;
}
return null;
}
test "count sum avg min max group by text field" {
const allocator = std.testing.allocator;
var rows = std.ArrayList(Row).init(allocator);
defer {
for (rows.items) |*r| r.deinit();
rows.deinit();
}
const data = [_]struct { city: []const u8, score: i64 }{
.{ .city = "warsaw", .score = 10 },
.{ .city = "warsaw", .score = 30 },
.{ .city = "krakow", .score = 5 },
};
for (data) |d| {
try rows.append(try makeRow(allocator, &.{
.{ .name = "city", .value = .{ .text = d.city } },
.{ .name = "score", .value = .{ .int = d.score } },
}));
}
const specs = [_]AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "score", .kind = .sum },
.{ .field = "score", .kind = .avg },
.{ .field = "score", .kind = .min },
.{ .field = "score", .kind = .max },
};
const groups = try run(allocator, rows.items, "city", &specs);
defer freeGroupRows(allocator, groups);
try std.testing.expectEqual(@as(usize, 2), groups.len);
const warsaw = findGroup(groups, .{ .text = "warsaw" }).?;
try std.testing.expectEqual(@as(i64, 2), warsaw.values[0].int);
try std.testing.expectEqual(@as(i64, 40), warsaw.values[1].int);
try std.testing.expectApproxEqAbs(@as(f64, 20), warsaw.values[2].float, 1e-9);
try std.testing.expectEqual(@as(i64, 10), warsaw.values[3].int);
try std.testing.expectEqual(@as(i64, 30), warsaw.values[4].int);
const krakow = findGroup(groups, .{ .text = "krakow" }).?;
try std.testing.expectEqual(@as(i64, 1), krakow.values[0].int);
try std.testing.expectEqual(@as(i64, 5), krakow.values[1].int);
}
test "sum and avg skip rows missing the field, count star still counts them" {
const allocator = std.testing.allocator;
var rows = std.ArrayList(Row).init(allocator);
defer {
for (rows.items) |*r| r.deinit();
rows.deinit();
}
try rows.append(try makeRow(allocator, &.{
.{ .name = "team", .value = .{ .text = "a" } },
.{ .name = "points", .value = .{ .int = 7 } },
}));
// No "points" field on this row - must not participate in sum/avg, but
// must still be counted by COUNT(*).
try rows.append(try makeRow(allocator, &.{
.{ .name = "team", .value = .{ .text = "a" } },
}));
const specs = [_]AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "points", .kind = .count },
.{ .field = "points", .kind = .sum },
.{ .field = "points", .kind = .avg },
};
const groups = try run(allocator, rows.items, "team", &specs);
defer freeGroupRows(allocator, groups);
try std.testing.expectEqual(@as(usize, 1), groups.len);
try std.testing.expectEqual(@as(i64, 2), groups[0].values[0].int); // COUNT(*)
try std.testing.expectEqual(@as(i64, 1), groups[0].values[1].int); // COUNT(points)
try std.testing.expectEqual(@as(i64, 7), groups[0].values[2].int); // SUM(points)
try std.testing.expectApproxEqAbs(@as(f64, 7), groups[0].values[3].float, 1e-9); // AVG(points)
}
test "avg on a group where the field is always missing reports none" {
const allocator = std.testing.allocator;
var rows = std.ArrayList(Row).init(allocator);
defer {
for (rows.items) |*r| r.deinit();
rows.deinit();
}
try rows.append(try makeRow(allocator, &.{.{ .name = "team", .value = .{ .text = "a" } }}));
const specs = [_]AggSpec{.{ .field = "points", .kind = .avg }};
const groups = try run(allocator, rows.items, "team", &specs);
defer freeGroupRows(allocator, groups);
try std.testing.expectEqual(@as(usize, 1), groups.len);
try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, groups[0].values[0]));
}
test "sum stays exact integer until a float value forces it to switch" {
const allocator = std.testing.allocator;
var rows = std.ArrayList(Row).init(allocator);
defer {
for (rows.items) |*r| r.deinit();
rows.deinit();
}
try rows.append(try makeRow(allocator, &.{
.{ .name = "g", .value = .{ .int = 1 } },
.{ .name = "x", .value = .{ .int = 3 } },
}));
try rows.append(try makeRow(allocator, &.{
.{ .name = "g", .value = .{ .int = 1 } },
.{ .name = "x", .value = .{ .int = 4 } },
}));