diff --git a/src/aggregate.zig b/src/aggregate.zig index 3756c94..9dcb156 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -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 } }, + })); +