const std = @import("std"); const rowmod = @import("row.zig"); const querymod = @import("query.zig"); const Allocator = std.mem.Allocator; const Value = rowmod.Value; const Row = rowmod.Row; const Page = querymod.Page; const Predicate = querymod.Predicate; /// GROUP BY / aggregate engine over already-materialized `Row` slices (the /// same currency `join.zig` uses): callers filter and fetch rows through /// `Table.select`/`Table.scanAll` first, then feed them here one at a time. /// Keeping this decoupled from `query.zig`'s `Table` (rather than adding a /// method on it) mirrors the existing split between the storage-facing /// `Table` and the purely row-shaped `join.zig`. pub const AggKind = enum { /// Counts rows. `field.len == 0` counts every observed row /// (`COUNT(*)`); otherwise only rows where `field` is present /// (`COUNT(field)`). count, sum, avg, min, max, /// Counts distinct non-none values of `field` (SQL's /// `COUNT(DISTINCT field)`). Always requires a `field` - there is no /// bare `COUNT(DISTINCT *)` form, mirroring `sum`/`avg`/`min`/`max`. count_distinct, }; pub const AggSpec = struct { /// Ignored for `.count` with `COUNT(*)` semantics (pass `""`). field: []const u8, kind: AggKind, }; pub const AggregateError = error{ /// `.sum`/`.avg` observed a field holding a non-numeric `Value` /// (anything but `.int`/`.float`). NonNumericField, /// The running integer sum of a `.sum`/`.avg` accumulator would not fit /// in `i64`. Never silently wraps or drops precision by switching to /// float behind the caller's back. Overflow, /// `sortGroupsBy` was asked to order by a field that names neither the /// GROUP BY key nor any `specLabel` of the aggregate results - the /// ORDER BY counterpart of `expr.parse`'s "malformed input is an error, /// not a guess" convention `filterHaving` already applies to HAVING. UnknownOrderField, } || Allocator.Error; /// One row of `GroupBy.finish()`'s result: `group` is the distinct group-key /// value, `values` holds one result `Value` per input `AggSpec`, same order. pub const GroupRow = struct { group: Value, values: []Value, pub fn deinit(self: *GroupRow, allocator: Allocator) void { self.group.free(allocator); for (self.values) |v| v.free(allocator); allocator.free(self.values); } }; fn freeGroupRows(allocator: Allocator, rows: []GroupRow) void { for (rows) |*r| r.deinit(allocator); allocator.free(rows); } /// Trims a `GroupBy.finish()`/`run()` result to a `query.zig` `Page` window /// (0 = unlimited, the same convention `Table.selectPage`/`join.applyPage` /// use) - the GROUP BY counterpart of pagination for `table_aggregate`/ /// `remote-aggregate`. Grouping has already had to materialize every /// distinct group to know how many there are, so - exactly like /// `join.applyPage` - there is no cheaper way to honor `offset`/`limit` /// than windowing the already-complete result; this always runs after /// `run()` returns, never instead of it. Not a generic /// `join.applyPage(GroupRow, ...)` call because `GroupRow.deinit` takes an /// `allocator` argument (it owns heap payloads on `.group`/`.values`) /// where `join.applyPage`'s `T.deinit()` takes none. /// /// Takes ownership of `rows`: frees the backing slice, `.deinit()`s every /// row that falls outside `[offset, offset+limit)`, and returns a freshly /// allocated slice holding only the kept rows in their original (first-seen) /// order. Callers own the returned slice and the rows in it exactly as they /// would have owned `rows`. pub fn applyPageGroups(allocator: Allocator, rows: []GroupRow, page: Page) ![]GroupRow { const total = rows.len; const start = @min(page.offset, total); const end = if (page.limit == 0) total else @min(start + page.limit, total); for (rows[0..start]) |*r| r.deinit(allocator); for (rows[end..total]) |*r| r.deinit(allocator); const kept = end - start; const result = allocator.alloc(GroupRow, kept) catch |err| { for (rows[start..end]) |*r| r.deinit(allocator); allocator.free(rows); return err; }; @memcpy(result, rows[start..end]); allocator.free(rows); return result; } /// The row field name a `GroupRow`/global aggregate result is exposed under /// for HAVING and CLI output purposes: bare `AggKind` name for `field.len == /// 0` (`COUNT(*)`), otherwise `"kind_field"`. Must stay in sync with /// `main.zig`'s `printAggLabel`, which prints this same format directly to a /// writer without allocating - `filterHaving`/`globalMatchesHaving` need an /// owned string instead because it becomes a `Row` field name, so this is a /// separate (but format-identical) helper rather than a shared one. pub fn specLabel(allocator: Allocator, spec: AggSpec) Allocator.Error![]u8 { if (spec.field.len == 0) return allocator.dupe(u8, @tagName(spec.kind)); return std.fmt.allocPrint(allocator, "{s}_{s}", .{ @tagName(spec.kind), spec.field }); } /// Builds the synthetic probe `Row` a HAVING predicate is evaluated against: /// one field per `spec` (named via `specLabel`) holding that spec's result /// `Value` for this group, plus (when `groupField.len > 0`) one field named /// `groupField` holding the group's own key - so a HAVING clause can /// reference an aggregate result (`count`, `sum_score`, ...) or the group key /// itself through the exact same `expr.zig` grammar `WHERE` already uses. /// Does not take ownership of `groupValue`/`values` - `Row.set` dupes. fn buildHavingProbe(allocator: Allocator, groupField: []const u8, groupValue: ?Value, specs: []const AggSpec, values: []const Value) !Row { var probe = Row.init(allocator); errdefer probe.deinit(); if (groupValue) |gv| try probe.set(groupField, gv); for (specs, values) |spec, v| { const label = try specLabel(allocator, spec); defer allocator.free(label); try probe.set(label, v); } return probe; } /// Filters an already-grouped `run()` result by a HAVING predicate - the /// GROUP BY counterpart of `Predicate.evaluate` on plain rows, applied AFTER /// aggregation instead of before it (the same WHERE/HAVING split SQL makes). /// Takes ownership of `groups` (same convention as `applyPageGroups`): every /// group that fails `having` is `.deinit()`-ed here, the rest are moved /// (first-seen order preserved) into a freshly allocated result slice. /// Callers typically run this between `run()` and `applyPageGroups` - HAVING /// narrows which groups exist before LIMIT/OFFSET windows them, exactly like /// SQL evaluates `HAVING` before `LIMIT`. pub fn filterHaving(allocator: Allocator, groups: []GroupRow, groupField: []const u8, specs: []const AggSpec, having: Predicate) AggregateError![]GroupRow { var kept: std.ArrayListUnmanaged(GroupRow) = .{}; var i: usize = 0; // `groups[0..i)` is always fully accounted for by this point: each one // either moved into `kept` (accepted) or was already `.deinit()`-ed // in-loop (rejected) - touching it again here would double-free. Only // `groups[i..]` (the current index, mid-evaluation when this errdefer // fires, plus everything not yet reached) is still owned by `groups` // and needs disposing, the same "always dispose of the input on every // path" contract `applyPageGroups` upholds for its own OOM case. errdefer { for (kept.items) |*g| g.deinit(allocator); kept.deinit(allocator); for (groups[i..]) |*g| g.deinit(allocator); allocator.free(groups); } while (i < groups.len) : (i += 1) { var probe = try buildHavingProbe(allocator, groupField, groups[i].group, specs, groups[i].values); defer probe.deinit(); if (having.evaluate(&probe)) { try kept.append(allocator, groups[i]); } else { groups[i].deinit(allocator); } } allocator.free(groups); return kept.toOwnedSlice(allocator); } /// HAVING for the ungrouped (`runGlobal`, CLI's `*` sentinel) aggregate path: /// evaluates `having` against the same kind of synthetic probe row /// `filterHaving` builds, minus any group-key field (there is no group). /// Reports only whether the single global result should be kept - unlike /// `filterHaving` it never frees `values`, since `runGlobal` always produces /// exactly one result and the caller (which already owns it via /// `freeGlobalResult`) decides whether to print it or not. pub fn globalMatchesHaving(allocator: Allocator, specs: []const AggSpec, values: []const Value, having: Predicate) AggregateError!bool { var probe = try buildHavingProbe(allocator, "", null, specs, values); defer probe.deinit(); return having.evaluate(&probe); } /// Which part of a `GroupRow` a single `OrderBy` column reads from - /// resolved once per column by `resolveSortKeys` rather than re-matching /// `ob.field` against `groupField`/every `specLabel` on every comparison /// `sortGroupsBy`'s sort makes. const SortKey = union(enum) { group, value: usize, }; /// Resolves each `order[i].field` to either the group key itself /// (`groupField`, e.g. `city`) or the index of the `specs` entry whose /// `specLabel` matches (e.g. `sum_score`) - the same two-namespace lookup /// `buildHavingProbe` exposes to a HAVING predicate, done once up front /// instead of building a probe `Row` per comparison. An `order[i].field` /// matching neither is `error.UnknownOrderField` rather than silently /// sorting as if every row tied on that column. Caller owns the returned /// slice. fn resolveSortKeys(allocator: Allocator, groupField: []const u8, specs: []const AggSpec, order: []const querymod.OrderBy) AggregateError![]SortKey { var keys = try allocator.alloc(SortKey, order.len); errdefer allocator.free(keys); for (order, 0..) |ob, i| { if (groupField.len > 0 and std.mem.eql(u8, ob.field, groupField)) { keys[i] = .group; continue; } var found: ?usize = null; for (specs, 0..) |spec, j| { const label = try specLabel(allocator, spec); defer allocator.free(label); if (std.mem.eql(u8, label, ob.field)) { found = j; break; } } keys[i] = .{ .value = found orelse return error.UnknownOrderField }; } return keys; } /// Stable in-place sort of `groups` by `order` - the GROUP BY counterpart of /// `query.zig`'s `sortRowsBy`, applied to already-aggregated `GroupRow`s /// instead of plain `Row`s so an ORDER BY column can name either the group /// key (`city`) or an aggregate result (`sum_score`), the same two things a /// HAVING predicate can reference. Columns compare left to right exactly /// like `sortRowsBy`/SQL's `ORDER BY a, b`: the first entry decides unless /// it ties, in which case the next breaks it. `order.len == 0` is a no-op /// (the common case - most `table aggregate` calls have no ORDER BY). /// Does not take ownership of `groups` - unlike `filterHaving`/ /// `applyPageGroups`, this never frees or reallocates any group, only /// reorders the slice in place, so callers keep using their own `defer /// freeGroupRows(...)` around the call exactly as if it were never made. /// Typically run after `filterHaving` and before `applyPageGroups` - ORDER /// BY sees the HAVING-narrowed set and decides what LIMIT/OFFSET's window /// actually keeps, the same clause order SQL evaluates. pub fn sortGroupsBy(allocator: Allocator, groups: []GroupRow, groupField: []const u8, specs: []const AggSpec, order: []const querymod.OrderBy) AggregateError!void { if (order.len == 0) return; const keys = try resolveSortKeys(allocator, groupField, specs, order); defer allocator.free(keys); const Ctx = struct { order: []const querymod.OrderBy, keys: []const SortKey, fn lessThan(ctx: @This(), a: GroupRow, b: GroupRow) bool { for (ctx.order, ctx.keys) |ob, key| { const av = switch (key) { .group => a.group, .value => |j| a.values[j], }; const bv = switch (key) { .group => b.group, .value => |j| b.values[j], }; const cmp = rowmod.compare(av, bv); if (cmp == .eq) continue; return if (ob.descending) cmp == .gt else cmp == .lt; } return false; } }; std.sort.block(GroupRow, groups, Ctx{ .order = order, .keys = keys }, Ctx.lessThan); } const AccState = struct { kind: AggKind, field: []const u8, n: u64 = 0, sumInt: i64 = 0, sumFloat: f64 = 0, allInt: bool = true, minv: ?Value = null, maxv: ?Value = null, /// `.count_distinct` only: encoded-`Value` bytes seen so far, one entry /// per distinct value (owns each key, same encode-as-hashmap-key trick /// `GroupBy.observe` uses for the group key itself). distinct: std.StringHashMapUnmanaged(void) = .{}, fn numericFieldValue(self: *const AccState, row: *const Row) ?Value { return row.get(self.field); } fn observe(self: *AccState, allocator: Allocator, row: *const Row) AggregateError!void { switch (self.kind) { .count => { if (self.field.len == 0) { self.n += 1; } else if (row.get(self.field) != null) { self.n += 1; } }, .sum, .avg => { const v = self.numericFieldValue(row) orelse return; switch (v) { .int => |i| { if (self.allInt) { self.sumInt = std.math.add(i64, self.sumInt, i) catch return error.Overflow; } else { self.sumFloat += @floatFromInt(i); } }, .float => |f| { if (self.allInt) { // First float observed after an int-only run: // migrate the exact integer sum so far into the // float accumulator once, then continue in float. self.sumFloat = @floatFromInt(self.sumInt); self.allInt = false; } self.sumFloat += f; }, else => return error.NonNumericField, } self.n += 1; }, .min => { const v = row.get(self.field) orelse return; if (self.minv == null or rowmod.compare(v, self.minv.?) == .lt) { if (self.minv) |old| old.free(allocator); self.minv = try v.dupe(allocator); } }, .max => { const v = row.get(self.field) orelse return; if (self.maxv == null or rowmod.compare(v, self.maxv.?) == .gt) { if (self.maxv) |old| old.free(allocator); self.maxv = try v.dupe(allocator); } }, .count_distinct => { const v = row.get(self.field) orelse return; const key = try rowmod.encodeValue(allocator, v); const res = try self.distinct.getOrPut(allocator, key); if (res.found_existing) allocator.free(key); }, } } /// Produces the final result `Value`, transferring ownership of any /// heap payload (`.min`/`.max` on text/bytes fields) out of this /// accumulator - a subsequent `deinit` sees `null` and frees nothing, /// so the same payload is never freed twice. `.count_distinct` instead /// frees its whole tracking set right here (the result is a plain /// `.int`, nothing to hand ownership of), which is why this needs /// `allocator` - both call sites (`GroupBy.finish`, `runGlobal`) skip a /// separate `deinit` call after `take()` entirely, so this is the only /// place that set is ever freed on the success path. fn take(self: *AccState, allocator: Allocator) Value { return switch (self.kind) { .count => .{ .int = @intCast(self.n) }, .sum => if (self.allInt) .{ .int = self.sumInt } else .{ .float = self.sumFloat }, .avg => blk: { if (self.n == 0) break :blk .{ .none = {} }; const total: f64 = if (self.allInt) @floatFromInt(self.sumInt) else self.sumFloat; break :blk .{ .float = total / @as(f64, @floatFromInt(self.n)) }; }, .min => blk: { const v = self.minv orelse Value{ .none = {} }; self.minv = null; break :blk v; }, .max => blk: { const v = self.maxv orelse Value{ .none = {} }; self.maxv = null; break :blk v; }, .count_distinct => blk: { const result = Value{ .int = @intCast(self.distinct.count()) }; var it = self.distinct.keyIterator(); while (it.next()) |k| allocator.free(k.*); self.distinct.deinit(allocator); self.distinct = .{}; break :blk result; }, }; } fn deinit(self: *AccState, allocator: Allocator) void { if (self.minv) |v| v.free(allocator); if (self.maxv) |v| v.free(allocator); var it = self.distinct.keyIterator(); while (it.next()) |k| allocator.free(k.*); self.distinct.deinit(allocator); } }; const Bundle = struct { groupValue: Value, accs: []AccState, fn deinit(self: *Bundle, allocator: Allocator) void { self.groupValue.free(allocator); for (self.accs) |*a| a.deinit(allocator); allocator.free(self.accs); } }; /// Streaming GROUP BY accumulator: call `observe` once per candidate row /// (already filtered by whatever `Predicate` the caller wants applied), then /// `finish` to materialize one `GroupRow` per distinct group-key value seen, /// in first-seen order (stable, not sorted - callers that want sorted /// output can sort the returned slice by `.group` using `row.compare`). pub const GroupBy = struct { allocator: Allocator, groupField: []const u8, specs: []const AggSpec, map: std.StringHashMapUnmanaged(*Bundle) = .{}, order: std.ArrayListUnmanaged([]const u8) = .{}, pub fn init(allocator: Allocator, groupField: []const u8, specs: []const AggSpec) GroupBy { return .{ .allocator = allocator, .groupField = groupField, .specs = specs }; } /// Safe to call after `finish()` (which empties the internal maps) or on /// a `GroupBy` that was never `finish()`-ed (e.g. `observe` returned an /// error partway through a batch) - either way, exactly what is still /// owned gets freed once. pub fn deinit(self: *GroupBy) void { var it = self.map.iterator(); while (it.next()) |entry| { self.allocator.free(entry.key_ptr.*); entry.value_ptr.*.deinit(self.allocator); self.allocator.destroy(entry.value_ptr.*); } self.map.deinit(self.allocator); self.order.deinit(self.allocator); self.map = .{}; self.order = .{}; } /// Rows missing `groupField` entirely are excluded from every group /// (no "null group" bucket) - the same omission `Table.createIndex`'s /// backfill applies to rows missing an indexed field. pub fn observe(self: *GroupBy, row: *const Row) AggregateError!void { const groupVal = row.get(self.groupField) orelse return; const key = try rowmod.encodeValue(self.allocator, groupVal); if (self.map.get(key)) |bundle| { self.allocator.free(key); for (bundle.accs) |*a| try a.observe(self.allocator, row); return; } // Scoped to this block so the errdefers below only guard the // allocation itself: once `bundle` is handed to `self.map.put` // just after, ownership has moved there, and a later failure (the // final observe loop) must NOT free it out from under the map - // an errdefer spanning the whole function would do exactly that // and leave a dangling pointer in `self.map`. const bundle = blk: { const b = try self.allocator.create(Bundle); errdefer self.allocator.destroy(b); const accs = try self.allocator.alloc(AccState, self.specs.len); errdefer self.allocator.free(accs); for (self.specs, 0..) |spec, i| accs[i] = .{ .kind = spec.kind, .field = spec.field }; const dupedGroup = try groupVal.dupe(self.allocator); b.* = .{ .groupValue = dupedGroup, .accs = accs }; break :blk b; }; try self.map.put(self.allocator, key, bundle); try self.order.append(self.allocator, key); for (bundle.accs) |*a| try a.observe(self.allocator, row); } /// Consumes the accumulated groups into an owned `[]GroupRow` and /// leaves `self` empty (a subsequent `deinit()` is then a no-op). pub fn finish(self: *GroupBy) AggregateError![]GroupRow { var results = try self.allocator.alloc(GroupRow, self.order.items.len); var filled: usize = 0; errdefer freeGroupRows(self.allocator, results[0..filled]); for (self.order.items) |key| { const bundle = self.map.get(key).?; var values = try self.allocator.alloc(Value, bundle.accs.len); errdefer self.allocator.free(values); for (bundle.accs, 0..) |*a, j| values[j] = a.take(self.allocator); results[filled] = .{ .group = bundle.groupValue, .values = values }; filled += 1; } var it = self.map.iterator(); while (it.next()) |entry| { self.allocator.free(entry.key_ptr.*); self.allocator.free(entry.value_ptr.*.accs); self.allocator.destroy(entry.value_ptr.*); } self.map.deinit(self.allocator); self.order.deinit(self.allocator); self.map = .{}; self.order = .{}; return results; } }; /// 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 } }, })); 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); } 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; } 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); 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); }