diff --git a/src/aggregate.zig b/src/aggregate.zig index 5165378..837eedd 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -971,3 +971,166 @@ fn firstLettersOf(rows: []const GroupRow) [4]u8 { return out; } +test "applyPageGroups with offset and limit keeps a middle window in first-seen order" { + const allocator = std.testing.allocator; + const groups = try fourGroupRows(allocator); + try std.testing.expectEqual(@as(usize, 4), groups.len); + + const paged = try applyPageGroups(allocator, groups, .{ .offset = 1, .limit = 2 }); + defer freeGroupRows(allocator, paged); + + try std.testing.expectEqual(@as(usize, 2), paged.len); + try std.testing.expectEqualStrings("b", paged[0].group.text); + try std.testing.expectEqualStrings("c", paged[1].group.text); +} + +test "applyPageGroups with limit 0 returns every group, matching unpaged run" { + const allocator = std.testing.allocator; + const groups = try fourGroupRows(allocator); + const paged = try applyPageGroups(allocator, groups, .{}); + defer freeGroupRows(allocator, paged); + + try std.testing.expectEqualSlices(u8, &.{ 'a', 'b', 'c', 'd' }, &firstLettersOf(paged)); +} + +test "applyPageGroups with offset past the end returns nothing" { + const allocator = std.testing.allocator; + const groups = try fourGroupRows(allocator); + const paged = try applyPageGroups(allocator, groups, .{ .offset = 50 }); + defer freeGroupRows(allocator, paged); + + try std.testing.expectEqual(@as(usize, 0), paged.len); +} + +test "applyPageGroups with limit larger than the remaining groups stops at the end" { + const allocator = std.testing.allocator; + const groups = try fourGroupRows(allocator); + const paged = try applyPageGroups(allocator, groups, .{ .offset = 3, .limit = 100 }); + defer freeGroupRows(allocator, paged); + + try std.testing.expectEqual(@as(usize, 1), paged.len); + try std.testing.expectEqualStrings("d", paged[0].group.text); +} + +test "applyPageGroups propagates OutOfMemory from its own result allocation without leaking any group" { + const allocator = std.testing.allocator; + const groups = try fourGroupRows(allocator); + try std.testing.expectEqual(@as(usize, 4), groups.len); + + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 0 }); + try std.testing.expectError(error.OutOfMemory, applyPageGroups(failing.allocator(), groups, .{ .offset = 1, .limit = 2 })); + // No explicit assertion beyond this: `testing.allocator`'s leak detector + // at process exit is the real check - every one of the 4 `GroupRow`s (2 + // kept by the window, 2 trimmed outside it) must have been + // `.deinit(allocator)`-ed on the error path, and the `rows` backing + // slice itself freed, or this test fails on leak. Every `GroupRow` here + // was built by `run()` using the real `testing.allocator`, so the error + // path frees through that same allocator regardless of which allocator + // `applyPageGroups` was asked to allocate its OWN result slice with - + // the exact isolation `join.zig`'s analogous OOM test documents. +} + +fn threeCityGroups(allocator: Allocator) ![]GroupRow { + 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 }, + .{ .city = "gdansk", .score = 100 }, + .{ .city = "gdansk", .score = 100 }, + .{ .city = "gdansk", .score = 100 }, + }; + 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 }, + }; + return run(allocator, rows.items, "city", &specs); +} + +test "filterHaving keeps only groups whose aggregate result matches the predicate" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + // warsaw: count=2 sum=40, krakow: count=1 sum=5, gdansk: count=3 sum=300 + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + const having = Predicate{ .gt = .{ .field = "count", .value = .{ .int = 1 } } }; + const kept = try filterHaving(allocator, groups, "city", &specs, having); + defer freeGroupRows(allocator, kept); + + try std.testing.expectEqual(@as(usize, 2), kept.len); + try std.testing.expectEqualStrings("warsaw", kept[0].group.text); + try std.testing.expectEqualStrings("gdansk", kept[1].group.text); +} + +test "filterHaving can reference the underlying aggregate field name (sum_field)" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + const having = Predicate{ .gte = .{ .field = "sum_score", .value = .{ .int = 100 } } }; + const kept = try filterHaving(allocator, groups, "city", &specs, having); + defer freeGroupRows(allocator, kept); + + try std.testing.expectEqual(@as(usize, 1), kept.len); + try std.testing.expectEqualStrings("gdansk", kept[0].group.text); +} + +test "filterHaving can also reference the group key itself, not just an aggregate result" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + const having = Predicate{ .ne = .{ .field = "city", .value = .{ .text = "krakow" } } }; + const kept = try filterHaving(allocator, groups, "city", &specs, having); + defer freeGroupRows(allocator, kept); + + try std.testing.expectEqual(@as(usize, 2), kept.len); + try std.testing.expectEqualStrings("warsaw", kept[0].group.text); + try std.testing.expectEqualStrings("gdansk", kept[1].group.text); +} + +test "filterHaving that rejects every group returns an empty, still-valid slice" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + const having = Predicate{ .gt = .{ .field = "count", .value = .{ .int = 1000 } } }; + const kept = try filterHaving(allocator, groups, "city", &specs, having); + defer freeGroupRows(allocator, kept); + + try std.testing.expectEqual(@as(usize, 0), kept.len); +} + +test "filterHaving composes with applyPageGroups: HAVING narrows before LIMIT/OFFSET windows" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + // Only warsaw and gdansk survive HAVING; a limit of 1 then keeps just + // the first of those two (warsaw, first-seen), not the first of all + // three original groups. + const having = Predicate{ .gt = .{ .field = "count", .value = .{ .int = 1 } } }; + const filtered = try filterHaving(allocator, groups, "city", &specs, having); + const paged = try applyPageGroups(allocator, filtered, .{ .limit = 1 }); + defer freeGroupRows(allocator, paged); +