diff --git a/src/aggregate.zig b/src/aggregate.zig index d8db1b9..5165378 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -810,3 +810,164 @@ test "count_distinct on zero observed values reports zero, not none" { try std.testing.expectEqual(@as(i64, 0), values[0].int); } +test "count_distinct propagates OutOfMemory without leaking already-tracked distinct values" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const tags = [_][]const u8{ "red", "blue", "green" }; + for (tags) |t| { + try rows.append(try makeRow(allocator, &.{ + .{ .name = "g", .value = .{ .text = "x" } }, + .{ .name = "tag", .value = .{ .text = t } }, + })); + } + + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 6 }); + const specs = [_]AggSpec{.{ .field = "tag", .kind = .count_distinct }}; + var gb = GroupBy.init(failing.allocator(), "g", &specs); + defer gb.deinit(); + + var sawError = false; + for (rows.items) |*r| { + gb.observe(r) catch { + sawError = true; + break; + }; + } + try std.testing.expect(sawError); +} + +test "rows missing the group field are excluded from every group" { + 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" } }})); + try rows.append(try makeRow(allocator, &.{})); // no "team" field at all + + const specs = [_]AggSpec{.{ .field = "", .kind = .count }}; + 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 "group order is first-seen, not sorted" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const order = [_][]const u8{ "z", "a", "m" }; + for (order) |g| { + try rows.append(try makeRow(allocator, &.{.{ .name = "g", .value = .{ .text = g } }})); + } + + const specs = [_]AggSpec{.{ .field = "", .kind = .count }}; + const groups = try run(allocator, rows.items, "g", &specs); + defer freeGroupRows(allocator, groups); + + try std.testing.expectEqual(@as(usize, 3), groups.len); + try std.testing.expectEqualStrings("z", groups[0].group.text); + try std.testing.expectEqualStrings("a", groups[1].group.text); + try std.testing.expectEqualStrings("m", groups[2].group.text); +} + +test "runGlobal aggregates every row into a single result with no grouping" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + var i: i64 = 1; + while (i <= 4) : (i += 1) { + try rows.append(try makeRow(allocator, &.{.{ .name = "x", .value = .{ .int = i } }})); + } + + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "x", .kind = .sum }, + .{ .field = "x", .kind = .max }, + }; + const values = try runGlobal(allocator, rows.items, &specs); + defer freeGlobalResult(allocator, values); + + try std.testing.expectEqual(@as(i64, 4), values[0].int); + try std.testing.expectEqual(@as(i64, 10), values[1].int); + try std.testing.expectEqual(@as(i64, 4), values[2].int); +} + +test "runGlobal on zero rows still reports identity results, not an error" { + const allocator = std.testing.allocator; + const rows: []const Row = &.{}; + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "x", .kind = .sum }, + .{ .field = "x", .kind = .max }, + }; + const values = try runGlobal(allocator, rows, &specs); + defer freeGlobalResult(allocator, values); + + try std.testing.expectEqual(@as(i64, 0), values[0].int); + try std.testing.expectEqual(@as(i64, 0), values[1].int); + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, values[2])); +} + +test "GroupBy propagates OutOfMemory without leaking any accumulated group" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const groupNames = [_][]const u8{ "a", "b", "c" }; + for (groupNames) |g| { + try rows.append(try makeRow(allocator, &.{ + .{ .name = "g", .value = .{ .text = g } }, + .{ .name = "x", .value = .{ .int = 1 } }, + })); + } + + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 6 }); + const specs = [_]AggSpec{.{ .field = "x", .kind = .sum }}; + var gb = GroupBy.init(failing.allocator(), "g", &specs); + defer gb.deinit(); + + var sawError = false; + for (rows.items) |*r| { + gb.observe(r) catch { + sawError = true; + break; + }; + } + try std.testing.expect(sawError); +} + +fn fourGroupRows(allocator: Allocator) ![]GroupRow { + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const groupNames = [_][]const u8{ "a", "b", "c", "d" }; + for (groupNames) |g| { + try rows.append(try makeRow(allocator, &.{.{ .name = "g", .value = .{ .text = g } }})); + } + const specs = [_]AggSpec{.{ .field = "", .kind = .count }}; + return run(allocator, rows.items, "g", &specs); +} + +fn firstLettersOf(rows: []const GroupRow) [4]u8 { + var out: [4]u8 = undefined; + for (rows, 0..) |r, i| out[i] = r.group.text[0]; + return out; +} +