From 4bb136858e86251a683cef3fce4fe3fc43edc3bb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 15:04:26 +0200 Subject: [PATCH] feat: expand aggregate --- src/aggregate.zig | 202 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/aggregate.zig b/src/aggregate.zig index 837eedd..6af3ab7 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -1134,3 +1134,205 @@ test "filterHaving composes with applyPageGroups: HAVING narrows before LIMIT/OF const paged = try applyPageGroups(allocator, filtered, .{ .limit = 1 }); defer freeGroupRows(allocator, paged); + try std.testing.expectEqual(@as(usize, 1), paged.len); + try std.testing.expectEqualStrings("warsaw", paged[0].group.text); +} + +test "filterHaving propagates OutOfMemory without leaking any group, kept or rejected" { + 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 = 1 } } }; + + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 0 }); + try std.testing.expectError(error.OutOfMemory, filterHaving(failing.allocator(), groups, "city", &specs, having)); + // Leak detector on `std.testing.allocator` at process exit is the real + // check here (same pattern as `applyPageGroups`'s analogous OOM test): + // every one of the 3 groups `threeCityGroups` built with the real + // allocator must still get freed on this early failure, even though + // `filterHaving` itself was asked to allocate its OWN bookkeeping + // (`kept`) through the failing allocator. +} + +test "globalMatchesHaving reports true or false without freeing the caller's values" { + 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 <= 5) : (i += 1) { + try rows.append(try makeRow(allocator, &.{.{ .name = "x", .value = .{ .int = i } }})); + } + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "x", .kind = .sum }, + }; + const values = try runGlobal(allocator, rows.items, &specs); + defer freeGlobalResult(allocator, values); + + const passing = Predicate{ .eq = .{ .field = "count", .value = .{ .int = 5 } } }; + try std.testing.expect(try globalMatchesHaving(allocator, &specs, values, passing)); + + const failing = Predicate{ .gt = .{ .field = "sum_x", .value = .{ .int = 1000 } } }; + try std.testing.expect(!try globalMatchesHaving(allocator, &specs, values, failing)); +} + +test "sortGroupsBy orders by the group key ascending by default" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + + const order = [_]querymod.OrderBy{.{ .field = "city" }}; + try sortGroupsBy(allocator, groups, "city", &.{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }, &order); + + try std.testing.expectEqualStrings("gdansk", groups[0].group.text); + try std.testing.expectEqualStrings("krakow", groups[1].group.text); + try std.testing.expectEqualStrings("warsaw", groups[2].group.text); +} + +test "sortGroupsBy orders by the group key descending when asked" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + + const order = [_]querymod.OrderBy{.{ .field = "city", .descending = true }}; + try sortGroupsBy(allocator, groups, "city", &.{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }, &order); + + try std.testing.expectEqualStrings("warsaw", groups[0].group.text); + try std.testing.expectEqualStrings("krakow", groups[1].group.text); + try std.testing.expectEqualStrings("gdansk", groups[2].group.text); +} + +test "sortGroupsBy orders by an aggregate result label, not just the group key" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + + // warsaw sum=40, krakow sum=5, gdansk sum=300 - ascending by sum_score + // is a completely different order than ascending by city. + const order = [_]querymod.OrderBy{.{ .field = "sum_score" }}; + try sortGroupsBy(allocator, groups, "city", &specs, &order); + + try std.testing.expectEqualStrings("krakow", groups[0].group.text); + try std.testing.expectEqualStrings("warsaw", groups[1].group.text); + try std.testing.expectEqualStrings("gdansk", groups[2].group.text); +} + +test "sortGroupsBy breaks ties left to right across multiple order columns" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + // Every group has count == 1 (a full tie on the first order column), so + // only the second column (the group key itself) can decide the order - + // built out of first-seen order d,b,a,c specifically so an + // alphabetical result proves the second column actually ran, rather + // than the sort coincidentally matching input order. + const groupNames = [_][]const u8{ "d", "b", "a", "c" }; + for (groupNames) |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); + + const order = [_]querymod.OrderBy{ + .{ .field = "count" }, + .{ .field = "g" }, + }; + try sortGroupsBy(allocator, groups, "g", &specs, &order); + + try std.testing.expectEqualStrings("a", groups[0].group.text); + try std.testing.expectEqualStrings("b", groups[1].group.text); + try std.testing.expectEqualStrings("c", groups[2].group.text); + try std.testing.expectEqualStrings("d", groups[3].group.text); +} + +test "sortGroupsBy with an empty order list leaves groups in first-seen order" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + + try sortGroupsBy(allocator, groups, "city", &.{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }, &.{}); + + try std.testing.expectEqualStrings("warsaw", groups[0].group.text); + try std.testing.expectEqualStrings("krakow", groups[1].group.text); + try std.testing.expectEqualStrings("gdansk", groups[2].group.text); +} + +test "sortGroupsBy rejects a field that names neither the group key nor a known aggregate label" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + + const order = [_]querymod.OrderBy{.{ .field = "not_a_real_field" }}; + try std.testing.expectError(error.UnknownOrderField, sortGroupsBy(allocator, groups, "city", &.{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }, &order)); +} + +test "sortGroupsBy composes with filterHaving and applyPageGroups: ORDER BY reorders before LIMIT/OFFSET windows" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + // HAVING keeps only warsaw (count=2, sum=40) and gdansk (count=3, + // sum=300); descending sum_score then puts gdansk first even though + // warsaw was first-seen - a limit of 1 must keep gdansk, not warsaw. + const having = Predicate{ .gt = .{ .field = "count", .value = .{ .int = 1 } } }; + const filtered = try filterHaving(allocator, groups, "city", &specs, having); + const order = [_]querymod.OrderBy{.{ .field = "sum_score", .descending = true }}; + try sortGroupsBy(allocator, filtered, "city", &specs, &order); + const paged = try applyPageGroups(allocator, filtered, .{ .limit = 1 }); + defer freeGroupRows(allocator, paged); + + try std.testing.expectEqual(@as(usize, 1), paged.len); + try std.testing.expectEqualStrings("gdansk", paged[0].group.text); +} + +test "sortGroupsBy propagates OutOfMemory without leaking or freeing the caller's groups" { + const allocator = std.testing.allocator; + const groups = try threeCityGroups(allocator); + defer freeGroupRows(allocator, groups); + const specs = [_]AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "score", .kind = .sum }, + }; + + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 0 }); + const order = [_]querymod.OrderBy{.{ .field = "city" }}; + try std.testing.expectError(error.OutOfMemory, sortGroupsBy(failing.allocator(), groups, "city", &specs, &order)); + + // `sortGroupsBy` never took ownership of `groups` (unlike + // `filterHaving`), so the outer `defer freeGroupRows(allocator, ...)` + // above must still see every group intact and still first-seen order + // (untouched, not partially sorted) - both the leak detector on + // `std.testing.allocator` and this ordering check are the real + // assertions here. + try std.testing.expectEqualStrings("warsaw", groups[0].group.text); + try std.testing.expectEqualStrings("krakow", groups[1].group.text); + try std.testing.expectEqualStrings("gdansk", groups[2].group.text); +}