diff --git a/src/aggregate.zig b/src/aggregate.zig index 9dcb156..d8db1b9 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -658,3 +658,155 @@ test "sum stays exact integer until a float value forces it to switch" { .{ .name = "x", .value = .{ .int = 4 } }, })); + const specs = [_]AggSpec{.{ .field = "x", .kind = .sum }}; + { + const groups = try run(allocator, rows.items, "g", &specs); + defer freeGroupRows(allocator, groups); + // All-int input: exact int result, not a float with rounding risk. + try std.testing.expectEqual(rowmod.ValueTag.int, @as(rowmod.ValueTag, groups[0].values[0])); + try std.testing.expectEqual(@as(i64, 7), groups[0].values[0].int); + } + + try rows.append(try makeRow(allocator, &.{ + .{ .name = "g", .value = .{ .int = 1 } }, + .{ .name = "x", .value = .{ .float = 0.5 } }, + })); + { + const groups = try run(allocator, rows.items, "g", &specs); + defer freeGroupRows(allocator, groups); + try std.testing.expectEqual(rowmod.ValueTag.float, @as(rowmod.ValueTag, groups[0].values[0])); + try std.testing.expectApproxEqAbs(@as(f64, 7.5), groups[0].values[0].float, 1e-9); + } +} + +test "sum reports overflow instead of silently wrapping" { + 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 = std.math.maxInt(i64) } }, + })); + try rows.append(try makeRow(allocator, &.{ + .{ .name = "g", .value = .{ .int = 1 } }, + .{ .name = "x", .value = .{ .int = 1 } }, + })); + + const specs = [_]AggSpec{.{ .field = "x", .kind = .sum }}; + var gb = GroupBy.init(allocator, "g", &specs); + defer gb.deinit(); + try gb.observe(&rows.items[0]); + try std.testing.expectError(error.Overflow, gb.observe(&rows.items[1])); +} + +test "sum on a non-numeric field errors without leaking accumulated state" { + 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 = .{ .text = "nope" } }, + })); + + const specs = [_]AggSpec{.{ .field = "x", .kind = .sum }}; + var gb = GroupBy.init(allocator, "g", &specs); + defer gb.deinit(); + try std.testing.expectError(error.NonNumericField, gb.observe(&rows.items[0])); +} + +test "min and max use the total order across owned text values" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const names = [_][]const u8{ "delta", "alpha", "charlie", "bravo" }; + for (names) |n| { + try rows.append(try makeRow(allocator, &.{ + .{ .name = "g", .value = .{ .int = 1 } }, + .{ .name = "name", .value = .{ .text = n } }, + })); + } + + const specs = [_]AggSpec{ + .{ .field = "name", .kind = .min }, + .{ .field = "name", .kind = .max }, + }; + const groups = try run(allocator, rows.items, "g", &specs); + defer freeGroupRows(allocator, groups); + + try std.testing.expectEqualStrings("alpha", groups[0].values[0].text); + try std.testing.expectEqualStrings("delta", groups[0].values[1].text); +} + +test "count_distinct counts unique field values once per group, repeats collapsed" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const data = [_]struct { team: []const u8, tag: []const u8 }{ + .{ .team = "a", .tag = "red" }, + .{ .team = "a", .tag = "blue" }, + .{ .team = "a", .tag = "red" }, // repeat, must not double count + .{ .team = "b", .tag = "green" }, + }; + for (data) |d| { + try rows.append(try makeRow(allocator, &.{ + .{ .name = "team", .value = .{ .text = d.team } }, + .{ .name = "tag", .value = .{ .text = d.tag } }, + })); + } + + const specs = [_]AggSpec{.{ .field = "tag", .kind = .count_distinct }}; + const groups = try run(allocator, rows.items, "team", &specs); + defer freeGroupRows(allocator, groups); + + const a = findGroup(groups, .{ .text = "a" }).?; + try std.testing.expectEqual(@as(i64, 2), a.values[0].int); + const b = findGroup(groups, .{ .text = "b" }).?; + try std.testing.expectEqual(@as(i64, 1), b.values[0].int); +} + +test "count_distinct ignores rows missing the field and never counts none as a value" { + 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 = "tag", .value = .{ .text = "red" } }, + })); + // No "tag" field on this row - must not create a phantom distinct value. + try rows.append(try makeRow(allocator, &.{ + .{ .name = "team", .value = .{ .text = "a" } }, + })); + + const specs = [_]AggSpec{.{ .field = "tag", .kind = .count_distinct }}; + 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, 1), groups[0].values[0].int); +} + +test "count_distinct on zero observed values reports zero, not none" { + const allocator = std.testing.allocator; + const rows: []const Row = &.{}; + const specs = [_]AggSpec{.{ .field = "tag", .kind = .count_distinct }}; + const values = try runGlobal(allocator, rows, &specs); + defer freeGlobalResult(allocator, values); + + try std.testing.expectEqual(@as(i64, 0), values[0].int); +} +