diff --git a/src/aggregate.zig b/src/aggregate.zig index 72f6267..1f7c82d 100644 --- a/src/aggregate.zig +++ b/src/aggregate.zig @@ -161,3 +161,186 @@ pub fn filterHaving(allocator: Allocator, groups: []GroupRow, groupField: []cons 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); + }, + } + } +