1298 lines
62 KiB
Zig
1298 lines
62 KiB
Zig
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;
|
|
|
|
/// Window-function engine over already-materialized `Row` slices (the same
|
|
/// currency `aggregate.zig`/`join.zig` use): callers filter and fetch rows
|
|
/// through `Table.select`/`Table.scanAll` first, then feed the result here.
|
|
/// Unlike `aggregate.zig`'s GROUP BY (which COLLAPSES rows into one per
|
|
/// group), a window function ANNOTATES every input row with a computed
|
|
/// value while keeping all of them - SQL's `expr() OVER (PARTITION BY ...
|
|
/// ORDER BY ...)`. `compute()` returns one `Value` per row, aligned 1:1
|
|
/// with the caller's row order.
|
|
pub const WindowKind = enum {
|
|
/// Sequential position (1-based) within the partition in `orderBy`
|
|
/// order. Never skips or repeats, even across ties.
|
|
row_number,
|
|
/// 1-based rank within the partition; rows tying on every `orderBy`
|
|
/// column share a rank, and the NEXT distinct rank skips ahead by the
|
|
/// tie group's size (SQL's `RANK()`).
|
|
rank,
|
|
/// Like `rank`, but the next distinct rank is always exactly one more
|
|
/// than the previous (SQL's `DENSE_RANK()`, no gaps).
|
|
dense_rank,
|
|
sum,
|
|
avg,
|
|
count,
|
|
/// Running count of DISTINCT non-none values of `field` seen so far in
|
|
/// the current frame (SQL's `COUNT(DISTINCT field) OVER (...)`).
|
|
/// Mirrors `aggregate.zig`'s `AggKind.count_distinct` (same
|
|
/// encode-as-hashmap-key dedup via `rowmod.encodeValue`), but as a
|
|
/// RUNNING total instead of one final count per group: under `.rows` the
|
|
/// distinct set only ever grows row by row within a partition, so a
|
|
/// later row's count can never be smaller than an earlier row's. Always
|
|
/// requires `field` (no `COUNT(*)`-style bare form, unlike plain
|
|
/// `count`) - counting distinct values of "nothing" is not meaningful.
|
|
count_distinct,
|
|
min,
|
|
max,
|
|
/// Value of `field` `offset` rows BEFORE the current row, within the
|
|
/// same partition, walked in `orderBy` order (SQL's `LAG()`). A row
|
|
/// whose predecessor at that distance does not exist (falls before the
|
|
/// start of the partition) gets `WindowSpec.default` instead.
|
|
lag,
|
|
/// Like `lag`, but `offset` rows AFTER the current row (SQL's `LEAD()`).
|
|
lead,
|
|
/// `field`'s value on the FIRST row of the current frame (SQL's
|
|
/// `FIRST_VALUE()`). Unlike `lag`/`lead`, this DOES respect `frame`: the
|
|
/// frame's start is always the partition's first row regardless of
|
|
/// `FrameMode` (both variants share `UNBOUNDED PRECEDING`), so
|
|
/// `first_value` is constant across an entire partition no matter which
|
|
/// row is asking.
|
|
first_value,
|
|
/// `field`'s value on the LAST row of the current frame (SQL's
|
|
/// `LAST_VALUE()`). This is the well-known SQL surprise: under `.rows`
|
|
/// (`ROWS UNBOUNDED PRECEDING .. CURRENT ROW`) the frame's end IS the
|
|
/// current row, so `last_value` degenerates to the row's own `field` -
|
|
/// to see the partition's true final value with `orderBy` present, use
|
|
/// `.range`, whose frame end is instead the current row's whole tie
|
|
/// group (matching the running-aggregate family's `.range` semantics),
|
|
/// or omit `orderBy` entirely for the whole-partition frame.
|
|
last_value,
|
|
/// `field`'s value on the `WindowSpec.offset`-th row (1-based) of the
|
|
/// CURRENT frame (SQL's `NTH_VALUE(field, n)`). Shares `first_value`/
|
|
/// `last_value`'s frame-respecting behavior (same `UNBOUNDED PRECEDING`
|
|
/// start, same `.rows`/`.range`/no-`orderBy` frame-end split) rather than
|
|
/// `lag`/`lead`'s fixed physical offset from the CURRENT row - `n` here
|
|
/// counts from the frame's START, not from "here". A position that falls
|
|
/// AFTER the current frame's end (including the well-known `.rows`
|
|
/// degenerate case, where the frame end is the current row itself) gets
|
|
/// `WindowSpec.default` (SQL NULL unless the caller set one), reusing
|
|
/// `lag`/`lead`'s out-of-range convention rather than inventing a new one.
|
|
nth_value,
|
|
};
|
|
|
|
pub const WindowSpec = struct {
|
|
/// Ignored for `row_number`/`rank`/`dense_rank` and for `count`'s
|
|
/// `COUNT(*)` form (pass `""`).
|
|
field: []const u8,
|
|
kind: WindowKind,
|
|
/// `lag`/`lead`: how many rows back (`lag`) or forward (`lead`) within
|
|
/// the partition's `orderBy` sequence to read from. SQL's default of 1
|
|
/// (the immediately adjacent row). `0` is legal (reads the current row
|
|
/// itself). `nth_value`: the 1-based position `n` within the current
|
|
/// frame to read (SQL's `NTH_VALUE(field, n)` - `n` has no SQL default,
|
|
/// but the struct default of `1` degenerates to `first_value`, which is
|
|
/// a sensible fallback rather than a trap). Ignored by every other
|
|
/// `WindowKind`.
|
|
offset: u64 = 1,
|
|
/// `lag`/`lead`/`nth_value`: the value stamped on a row whose target
|
|
/// falls outside the partition (`lag`/`lead`) or outside the current
|
|
/// frame (`nth_value`), mirroring SQL's optional third `LAG`/`LEAD`
|
|
/// argument. Defaults to `.none` (SQL NULL when the argument is
|
|
/// omitted). Ignored by every other `WindowKind`.
|
|
default: Value = .{ .none = {} },
|
|
};
|
|
|
|
pub const WindowError = error{
|
|
/// `sum`/`avg` observed a field holding a non-numeric `Value` (anything
|
|
/// but `.int`/`.float`) - same convention as `aggregate.zig`.
|
|
NonNumericField,
|
|
/// The running integer sum would not fit in `i64`. Never silently
|
|
/// wraps or drops precision by switching to float behind the caller's
|
|
/// back.
|
|
Overflow,
|
|
/// `sum`/`avg`/`min`/`max` require a `field`; only the ranking family
|
|
/// and `count`'s `COUNT(*)` form may omit one.
|
|
MissingField,
|
|
/// `FrameMode.range` was requested together with `row_number`/`rank`/
|
|
/// `dense_rank`. Standard SQL does not accept a frame clause on those at
|
|
/// all (`row_number` is always ROWS-only; `rank`/`dense_rank` already
|
|
/// implement their own fixed tie semantics) - `compute()` rejects the
|
|
/// combination instead of silently ignoring the requested frame.
|
|
RangeNotApplicable,
|
|
} || Allocator.Error;
|
|
|
|
/// The frame a running-aggregate `WindowKind` (`sum`/`avg`/`count`/`min`/
|
|
/// `max`) walks when `orderBy` is non-empty - irrelevant when `orderBy` is
|
|
/// empty (both modes reduce to the same whole-partition frame then) and
|
|
/// irrelevant to the ranking family (`compute()` rejects `.range` there, see
|
|
/// `WindowError.RangeNotApplicable`). See `compute()`'s doc comment for the
|
|
/// SQL semantics each variant mirrors.
|
|
pub const FrameMode = enum {
|
|
/// `ROWS UNBOUNDED PRECEDING .. CURRENT ROW`: a genuine running total,
|
|
/// walked one physical row at a time regardless of ties.
|
|
rows,
|
|
/// `RANGE UNBOUNDED PRECEDING .. CURRENT ROW` (SQL's actual default):
|
|
/// rows tied on every `orderBy` column share the LAST tied row's value
|
|
/// instead of each accumulating their own individual running value.
|
|
range,
|
|
};
|
|
|
|
fn isRanking(kind: WindowKind) bool {
|
|
return kind == .row_number or kind == .rank or kind == .dense_rank;
|
|
}
|
|
|
|
/// `lag`/`lead`: pure positional lookups within the sorted partition. Not a
|
|
/// running aggregate (no `RunningAcc`) and not a ranking (no tie handling) -
|
|
/// its own third family, sharing `RangeNotApplicable` with the ranking
|
|
/// family below since a frame clause has no meaning for either.
|
|
fn isOffsetLookup(kind: WindowKind) bool {
|
|
return kind == .lag or kind == .lead;
|
|
}
|
|
|
|
/// `first_value`/`last_value`/`nth_value`: like the running-aggregate
|
|
/// family, these DO respect `frame` (unlike `lag`/`lead`) - so they get
|
|
/// their own predicate rather than folding into `isOffsetLookup`.
|
|
fn isFrameValue(kind: WindowKind) bool {
|
|
return kind == .first_value or kind == .last_value or kind == .nth_value;
|
|
}
|
|
|
|
/// The row field name a `compute()` result column is exposed under (e.g.
|
|
/// for CLI/protocol output) - bare `WindowKind` name for the ranking family
|
|
/// and for `count`'s `COUNT(*)` form (`field.len == 0`), otherwise
|
|
/// `"kind_field"`. Format-identical to `aggregate.zig`'s `specLabel`.
|
|
pub fn specLabel(allocator: Allocator, spec: WindowSpec) 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 });
|
|
}
|
|
|
|
/// Streaming per-row accumulator for the running-aggregate family
|
|
/// (`sum`/`avg`/`count`/`min`/`max`). Mirrors `aggregate.zig`'s `AccState`
|
|
/// (same int-exact/float-fallback dual for `sum`/`avg`, same
|
|
/// compare-and-replace for `min`/`max`), minus `count_distinct` (out of
|
|
/// scope here - a running "distinct count so far" is a real SQL feature but
|
|
/// a separate piece of work). The key behavioral difference from
|
|
/// `AccState.take()`: `snapshot()` does NOT transfer ownership of
|
|
/// `minv`/`maxv` out of the accumulator, because a running window emits a
|
|
/// result after EVERY row, not once at the very end - the accumulator must
|
|
/// keep its own copy alive for the next row's snapshot too.
|
|
const RunningAcc = struct {
|
|
kind: WindowKind,
|
|
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 - same encode-as-hashmap-key trick
|
|
/// `aggregate.zig`'s `AccState.distinct` uses. Unlike that GROUP BY
|
|
/// counterpart, `snapshot()` below never drains this (a running window
|
|
/// needs the set to keep growing across the WHOLE partition walk, not
|
|
/// just until one final result) - only `deinit` ever frees it.
|
|
distinct: std.StringHashMapUnmanaged(void) = .{},
|
|
|
|
fn observe(self: *RunningAcc, allocator: Allocator, row: *const Row) WindowError!void {
|
|
switch (self.kind) {
|
|
.count => {
|
|
if (self.field.len == 0 or row.get(self.field) != null) self.n += 1;
|
|
},
|
|
.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);
|
|
},
|
|
.sum, .avg => {
|
|
const v = row.get(self.field) 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);
|
|
}
|
|
},
|
|
.row_number, .rank, .dense_rank, .lag, .lead, .first_value, .last_value, .nth_value => unreachable,
|
|
}
|
|
}
|
|
|
|
/// Returns the accumulator's CURRENT state as a freshly owned `Value`
|
|
/// (a dupe for `min`/`max`, since the accumulator keeps its own copy
|
|
/// alive for subsequent rows - see the type doc comment above).
|
|
/// `.count_distinct` is likewise non-draining - `self.distinct.count()`
|
|
/// is read, never emptied, so the NEXT row's snapshot still sees every
|
|
/// value observed so far (only `deinit` ever frees the set).
|
|
fn snapshot(self: *const RunningAcc, allocator: Allocator) Allocator.Error!Value {
|
|
return switch (self.kind) {
|
|
.count => .{ .int = @intCast(self.n) },
|
|
.count_distinct => .{ .int = @intCast(self.distinct.count()) },
|
|
.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 => if (self.minv) |v| try v.dupe(allocator) else .{ .none = {} },
|
|
.max => if (self.maxv) |v| try v.dupe(allocator) else .{ .none = {} },
|
|
.row_number, .rank, .dense_rank, .lag, .lead, .first_value, .last_value, .nth_value => unreachable,
|
|
};
|
|
}
|
|
|
|
fn deinit(self: *RunningAcc, 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);
|
|
}
|
|
};
|
|
|
|
/// Groups row indices `0..rows.len` by `partitionBy` (empty = a single
|
|
/// partition covering every row), preserving first-seen partition order -
|
|
/// the window-function analogue of `aggregate.zig`'s `GroupBy` grouping,
|
|
/// except every member index is kept instead of collapsed into one result.
|
|
/// Multi-column partition keys are built with `row.appendOrdered`
|
|
/// (concatenation-safe and unambiguous by construction), the same
|
|
/// primitive `index.zig` uses for composite index keys - a row missing one
|
|
/// of the `partitionBy` fields contributes `.none` for that column rather
|
|
/// than being excluded (unlike `aggregate.zig`'s GROUP BY, which drops rows
|
|
/// missing the group field entirely: a window function must still annotate
|
|
/// every input row with SOME result, so there is no row to drop it from).
|
|
/// Caller owns the returned slice of slices AND each inner slice
|
|
/// (`allocator.free` each inner slice, then the outer one).
|
|
fn buildPartitions(allocator: Allocator, rows: []const Row, partitionBy: []const []const u8) Allocator.Error![][]usize {
|
|
var order: std.ArrayListUnmanaged([]const u8) = .{};
|
|
defer {
|
|
for (order.items) |k| allocator.free(k);
|
|
order.deinit(allocator);
|
|
}
|
|
var map: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(usize)) = .{};
|
|
defer {
|
|
var it = map.iterator();
|
|
while (it.next()) |e| e.value_ptr.deinit(allocator);
|
|
map.deinit(allocator);
|
|
}
|
|
|
|
for (rows, 0..) |*r, i| {
|
|
var keyBuf = std.ArrayList(u8).init(allocator);
|
|
defer keyBuf.deinit();
|
|
for (partitionBy) |f| {
|
|
const v = r.get(f) orelse Value{ .none = {} };
|
|
try rowmod.appendOrdered(&keyBuf, v);
|
|
}
|
|
|
|
if (map.getPtr(keyBuf.items)) |list| {
|
|
try list.append(allocator, i);
|
|
continue;
|
|
}
|
|
const key = try allocator.dupe(u8, keyBuf.items);
|
|
errdefer allocator.free(key);
|
|
var list: std.ArrayListUnmanaged(usize) = .{};
|
|
errdefer list.deinit(allocator);
|
|
try list.append(allocator, i);
|
|
// `map.putAssumeCapacity`/`order.appendAssumeCapacity` below are
|
|
// INFALLIBLE (capacity reserved just above) and back to back with
|
|
// no fallible statement between them - a bug caught by
|
|
// `computeMany`'s exhaustive OOM sweep test: with plain fallible
|
|
// `map.put` then `order.append`, a failure in `order.append` AFTER
|
|
// `map.put` already succeeded left `key`/`list` reachable from BOTH
|
|
// this iteration's still-armed `errdefer list.deinit(allocator)`
|
|
// AND the function-level `map` cleanup `defer` above (which iterates
|
|
// every entry, including the one just inserted) - both would then
|
|
// free the SAME `list` backing array. Reserving capacity for both
|
|
// `map` and `order` up front and using the assume-capacity variants
|
|
// means nothing fallible can happen between the two inserts, so
|
|
// there is no window for a failure to leave `key`/`list` owned by
|
|
// one structure while their local errdefers are still armed.
|
|
try map.ensureUnusedCapacity(allocator, 1);
|
|
try order.ensureUnusedCapacity(allocator, 1);
|
|
map.putAssumeCapacity(key, list);
|
|
order.appendAssumeCapacity(key);
|
|
}
|
|
|
|
const result = try allocator.alloc([]usize, order.items.len);
|
|
var filled: usize = 0;
|
|
errdefer {
|
|
for (result[0..filled]) |p| allocator.free(p);
|
|
allocator.free(result);
|
|
}
|
|
for (order.items, 0..) |key, idx| {
|
|
const list = map.getPtr(key).?;
|
|
result[idx] = try list.toOwnedSlice(allocator);
|
|
filled += 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Stable in-place sort of a partition's row INDICES by `orderBy` - the
|
|
/// window-function analogue of `query.zig`'s `sortRowsBy`/`aggregate.zig`'s
|
|
/// `sortGroupsBy`, but sorting `usize` indices into `rows` rather than the
|
|
/// rows themselves (so the caller's row order and `results`' index
|
|
/// alignment are never disturbed). `orderBy.len == 0` is a no-op, leaving
|
|
/// `indices` in first-seen (input) order.
|
|
fn sortPartitionByOrder(rows: []const Row, indices: []usize, orderBy: []const querymod.OrderBy) void {
|
|
if (orderBy.len == 0) return;
|
|
const Ctx = struct {
|
|
rows: []const Row,
|
|
orderBy: []const querymod.OrderBy,
|
|
|
|
fn lessThan(ctx: @This(), a: usize, b: usize) bool {
|
|
for (ctx.orderBy) |ob| {
|
|
const av = ctx.rows[a].get(ob.field) orelse Value{ .none = {} };
|
|
const bv = ctx.rows[b].get(ob.field) orelse Value{ .none = {} };
|
|
const cmp = rowmod.compare(av, bv);
|
|
if (cmp == .eq) continue;
|
|
return if (ob.descending) cmp == .gt else cmp == .lt;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
std.sort.block(usize, indices, Ctx{ .rows = rows, .orderBy = orderBy }, Ctx.lessThan);
|
|
}
|
|
|
|
/// Whether `rows[curIdx]` ties `rows[prevIdx]` on every `orderBy` column -
|
|
/// the tie-group boundary `rank`/`dense_rank` need to decide whether to
|
|
/// advance. Only meaningful once `indices` has already been sorted by the
|
|
/// same `orderBy`.
|
|
fn tiesWithPrev(rows: []const Row, orderBy: []const querymod.OrderBy, prevIdx: usize, curIdx: usize) bool {
|
|
for (orderBy) |ob| {
|
|
const av = rows[curIdx].get(ob.field) orelse Value{ .none = {} };
|
|
const bv = rows[prevIdx].get(ob.field) orelse Value{ .none = {} };
|
|
if (rowmod.compare(av, bv) != .eq) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// `buildPartitions` followed by sorting every partition in place by
|
|
/// `orderBy` - the shared prep step `compute()` and `orderedIndices()` both
|
|
/// need, factored out so the flattened display order `orderedIndices`
|
|
/// exposes is GUARANTEED to match the partition walk `compute()` uses
|
|
/// internally (computed once, not two independently-written sorts that
|
|
/// could drift apart).
|
|
fn partitionedAndSorted(allocator: Allocator, rows: []const Row, partitionBy: []const []const u8, orderBy: []const querymod.OrderBy) Allocator.Error![][]usize {
|
|
const partitions = try buildPartitions(allocator, rows, partitionBy);
|
|
for (partitions) |indices| sortPartitionByOrder(rows, indices, orderBy);
|
|
return partitions;
|
|
}
|
|
|
|
/// Returns the row-index walk order `compute()` uses internally, flattened:
|
|
/// every partition's member indices (each sorted by `orderBy`), partitions
|
|
/// concatenated in first-seen order. `compute()`'s own results stay aligned
|
|
/// to `rows`' ORIGINAL order regardless of this - this exists for callers
|
|
/// (namely `table window`'s CLI) that want to PAGE or DISPLAY results in
|
|
/// the same order the window computation itself walked, rather than raw
|
|
/// scan order, which is what plain `[start..end]` slicing of `rows` would
|
|
/// give instead. Caller owns the returned slice.
|
|
pub fn orderedIndices(allocator: Allocator, rows: []const Row, partitionBy: []const []const u8, orderBy: []const querymod.OrderBy) Allocator.Error![]usize {
|
|
const partitions = try partitionedAndSorted(allocator, rows, partitionBy, orderBy);
|
|
defer {
|
|
for (partitions) |p| allocator.free(p);
|
|
allocator.free(partitions);
|
|
}
|
|
const out = try allocator.alloc(usize, rows.len);
|
|
var pos: usize = 0;
|
|
for (partitions) |indices| {
|
|
@memcpy(out[pos..][0..indices.len], indices);
|
|
pos += indices.len;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// Computes one window-function result `Value` per input row, returned in
|
|
/// the SAME order as `rows` (never reorders the caller's data) - the window
|
|
/// counterpart of `aggregate.zig`'s `run`, which collapses rows into one
|
|
/// per group instead of annotating every one. `partitionBy` splits `rows`
|
|
/// into independent groups exactly like GROUP BY (first-seen order, empty
|
|
/// = a single partition covering every row); `orderBy` decides the
|
|
/// ranking/running-total walk WITHIN each partition.
|
|
///
|
|
/// Frame semantics follow standard SQL defaults, decided entirely by
|
|
/// whether `orderBy` is empty:
|
|
/// - `orderBy.len == 0`: `rank`/`dense_rank` treat the whole partition as
|
|
/// one tie (every row gets 1); `row_number` still assigns 1..n in
|
|
/// first-seen order, since it needs no ordering key to number rows.
|
|
/// The running-aggregate family uses the whole-partition frame
|
|
/// (`UNBOUNDED PRECEDING .. UNBOUNDED FOLLOWING`) - every row in the
|
|
/// partition sees the SAME final total, mirroring `SUM(x) OVER
|
|
/// (PARTITION BY p)` with no `ORDER BY` inside the `OVER` clause.
|
|
/// - `orderBy.len > 0`: `frame` picks the running-aggregate family's frame
|
|
/// along the sorted sequence. `.rows` walks one physical row at a time
|
|
/// (`ROWS UNBOUNDED PRECEDING .. CURRENT ROW`) - every row gets its own
|
|
/// individual running value, even a row tied with its predecessor.
|
|
/// `.range` (SQL's actual default, `RANGE UNBOUNDED PRECEDING ..
|
|
/// CURRENT ROW`) instead advances the accumulator by a whole tie group
|
|
/// at once and stamps every row in that group with the SAME value (the
|
|
/// group's combined total, not each member's own position within it) -
|
|
/// the same tie test `rank`/`dense_rank` already use
|
|
/// (`tiesWithPrev`), just driving accumulation instead of a rank
|
|
/// counter. `frame` is ignored (both variants agree) when `orderBy` is
|
|
/// empty, and rejected outright (`error.RangeNotApplicable`) for the
|
|
/// ranking family - see `WindowError.RangeNotApplicable`.
|
|
///
|
|
/// `lag`/`lead` (`spec.offset`/`spec.default`) ignore `frame` entirely (also
|
|
/// `error.RangeNotApplicable` under `.range`, same as the ranking family -
|
|
/// SQL does not accept a frame clause on either) and read a fixed physical
|
|
/// position `spec.offset` rows before (`lag`) or after (`lead`) each row
|
|
/// within the sorted partition sequence (first-seen order when `orderBy` is
|
|
/// empty) - unlike the running-aggregate family, a tie on `orderBy` does NOT
|
|
/// collapse two rows into one lookup target; each remains its own distinct
|
|
/// position. A row whose target position falls outside the partition gets
|
|
/// `spec.default` (SQL NULL, `.none`, unless the caller set one).
|
|
///
|
|
/// `first_value`/`last_value` DO respect `frame`, unlike `lag`/`lead`: the
|
|
/// frame's start is always the partition's first row regardless of
|
|
/// `FrameMode` (so `first_value` is constant across a whole partition), but
|
|
/// the frame's END for `last_value` follows the running-aggregate family's
|
|
/// same split - `.rows` ends the frame at the CURRENT row (so `last_value`
|
|
/// degenerates to that row's own field, SQL's well-known gotcha), `.range`
|
|
/// ends it at the current row's whole tie group, and no `orderBy` at all
|
|
/// means the whole-partition frame (so `last_value` is the last row in
|
|
/// first-seen order, same value for every row). Requires `field` like the
|
|
/// running-aggregate family (`error.MissingField` if omitted).
|
|
///
|
|
/// `nth_value` shares that same frame (start/end identical to
|
|
/// `first_value`/`last_value`) but reads the `spec.offset`-th row (1-based)
|
|
/// counted from the frame's start rather than always the first or always the
|
|
/// last; a position past the frame's current end gets `spec.default`.
|
|
///
|
|
/// Caller owns the returned slice; free each `Value` (`.free(allocator)`)
|
|
/// and the slice itself, or call `freeResults`.
|
|
pub fn compute(allocator: Allocator, rows: []const Row, partitionBy: []const []const u8, orderBy: []const querymod.OrderBy, spec: WindowSpec, frame: FrameMode) WindowError![]Value {
|
|
const partitions = try partitionedAndSorted(allocator, rows, partitionBy, orderBy);
|
|
defer {
|
|
for (partitions) |p| allocator.free(p);
|
|
allocator.free(partitions);
|
|
}
|
|
return computeOneSpec(allocator, rows, partitions, orderBy, spec, frame);
|
|
}
|
|
|
|
/// The single-`WindowSpec` body of `compute()`, factored out so
|
|
/// `computeMany()` can run it once per spec against partitions it already
|
|
/// built ONCE - the window-function counterpart of why `aggregate.zig`'s
|
|
/// `GroupBy` groups rows once and folds every spec into the same pass,
|
|
/// rather than re-grouping per spec. Takes already-partitioned-and-sorted
|
|
/// row indices (`partitionedAndSorted`'s output) instead of building them
|
|
/// itself.
|
|
fn computeOneSpec(allocator: Allocator, rows: []const Row, partitions: []const []usize, orderBy: []const querymod.OrderBy, spec: WindowSpec, frame: FrameMode) WindowError![]Value {
|
|
if (!isRanking(spec.kind) and spec.kind != .count and spec.field.len == 0) return error.MissingField;
|
|
if (frame == .range and (isRanking(spec.kind) or isOffsetLookup(spec.kind))) return error.RangeNotApplicable;
|
|
|
|
const results = try allocator.alloc(Value, rows.len);
|
|
for (results) |*r| r.* = .{ .none = {} };
|
|
errdefer freeResults(allocator, results);
|
|
|
|
for (partitions) |indices| {
|
|
if (isRanking(spec.kind)) {
|
|
var rank: usize = 0;
|
|
var dense: usize = 0;
|
|
for (indices, 0..) |idx, pos| {
|
|
const tied = pos > 0 and (orderBy.len == 0 or tiesWithPrev(rows, orderBy, indices[pos - 1], idx));
|
|
switch (spec.kind) {
|
|
.row_number => results[idx] = .{ .int = @intCast(pos + 1) },
|
|
.rank => {
|
|
if (pos == 0 or !tied) rank = pos + 1;
|
|
results[idx] = .{ .int = @intCast(rank) };
|
|
},
|
|
.dense_rank => {
|
|
if (pos == 0 or !tied) dense += 1;
|
|
results[idx] = .{ .int = @intCast(dense) };
|
|
},
|
|
else => unreachable,
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isOffsetLookup(spec.kind)) {
|
|
// Pure positional lookup along the sorted partition sequence -
|
|
// no accumulator, no tie handling (a row tied with its neighbor
|
|
// on `orderBy` is still a DISTINCT physical position for
|
|
// `lag`/`lead`, unlike the ranking family above).
|
|
const delta: i64 = @intCast(spec.offset);
|
|
for (indices, 0..) |idx, pos| {
|
|
const srcPos: i64 = @as(i64, @intCast(pos)) + (if (spec.kind == .lag) -delta else delta);
|
|
if (srcPos >= 0 and srcPos < @as(i64, @intCast(indices.len))) {
|
|
const srcIdx = indices[@intCast(srcPos)];
|
|
const v = rows[srcIdx].get(spec.field) orelse Value{ .none = {} };
|
|
results[idx] = try v.dupe(allocator);
|
|
} else {
|
|
results[idx] = try spec.default.dupe(allocator);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (spec.kind == .nth_value) {
|
|
// Positional lookup within the CURRENT frame (start is always
|
|
// the partition's first row, end follows the same
|
|
// whole-partition/.rows/.range split `first_value`/`last_value`
|
|
// use below) - `n` counts from the frame's start, unlike
|
|
// `lag`/`lead`'s offset from the current row. `n < 1` never
|
|
// resolves (WindowSpec.offset defaults to 1, never 0, but a
|
|
// caller reaching this directly - e.g. over the wire - could
|
|
// still send 0).
|
|
if (indices.len == 0) continue;
|
|
const n = spec.offset;
|
|
if (orderBy.len == 0) {
|
|
const frameEnd: u64 = @intCast(indices.len - 1);
|
|
const v = if (n >= 1 and n - 1 <= frameEnd)
|
|
rows[indices[@intCast(n - 1)]].get(spec.field) orelse Value{ .none = {} }
|
|
else
|
|
spec.default;
|
|
for (indices) |idx| results[idx] = try v.dupe(allocator);
|
|
} else if (frame == .rows) {
|
|
for (indices, 0..) |idx, pos| {
|
|
const frameEnd: u64 = @intCast(pos);
|
|
const v = if (n >= 1 and n - 1 <= frameEnd)
|
|
rows[indices[@intCast(n - 1)]].get(spec.field) orelse Value{ .none = {} }
|
|
else
|
|
spec.default;
|
|
results[idx] = try v.dupe(allocator);
|
|
}
|
|
} else {
|
|
var pos: usize = 0;
|
|
while (pos < indices.len) {
|
|
var end = pos + 1;
|
|
while (end < indices.len and tiesWithPrev(rows, orderBy, indices[end - 1], indices[end])) : (end += 1) {}
|
|
const frameEnd: u64 = @intCast(end - 1);
|
|
const v = if (n >= 1 and n - 1 <= frameEnd)
|
|
rows[indices[@intCast(n - 1)]].get(spec.field) orelse Value{ .none = {} }
|
|
else
|
|
spec.default;
|
|
for (indices[pos..end]) |idx| results[idx] = try v.dupe(allocator);
|
|
pos = end;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isFrameValue(spec.kind)) {
|
|
if (indices.len == 0) continue;
|
|
if (orderBy.len == 0) {
|
|
// Whole-partition frame: first/last row of the partition in
|
|
// first-seen order, same value stamped on every member.
|
|
const firstV = rows[indices[0]].get(spec.field) orelse Value{ .none = {} };
|
|
const lastV = rows[indices[indices.len - 1]].get(spec.field) orelse Value{ .none = {} };
|
|
const v = if (spec.kind == .first_value) firstV else lastV;
|
|
for (indices) |idx| results[idx] = try v.dupe(allocator);
|
|
} else if (frame == .rows) {
|
|
// ROWS frame: start is always the partition's first row;
|
|
// end is the CURRENT row (SQL's well-known LAST_VALUE
|
|
// surprise under the ROWS default).
|
|
const firstV = rows[indices[0]].get(spec.field) orelse Value{ .none = {} };
|
|
for (indices) |idx| {
|
|
const v = if (spec.kind == .first_value) firstV else (rows[idx].get(spec.field) orelse Value{ .none = {} });
|
|
results[idx] = try v.dupe(allocator);
|
|
}
|
|
} else {
|
|
// RANGE frame: end is the current row's whole tie group -
|
|
// every row in the group shares that group's LAST member's
|
|
// value, same tie-group walk the running-aggregate family's
|
|
// `.range` branch uses.
|
|
const firstV = rows[indices[0]].get(spec.field) orelse Value{ .none = {} };
|
|
var pos: usize = 0;
|
|
while (pos < indices.len) {
|
|
var end = pos + 1;
|
|
while (end < indices.len and tiesWithPrev(rows, orderBy, indices[end - 1], indices[end])) : (end += 1) {}
|
|
const groupLastV = rows[indices[end - 1]].get(spec.field) orelse Value{ .none = {} };
|
|
for (indices[pos..end]) |idx| {
|
|
const v = if (spec.kind == .first_value) firstV else groupLastV;
|
|
results[idx] = try v.dupe(allocator);
|
|
}
|
|
pos = end;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
var acc = RunningAcc{ .kind = spec.kind, .field = spec.field };
|
|
defer acc.deinit(allocator);
|
|
|
|
if (orderBy.len == 0) {
|
|
// Whole-partition frame: accumulate everything first, THEN
|
|
// stamp the same final value onto every row in the partition.
|
|
for (indices) |idx| try acc.observe(allocator, &rows[idx]);
|
|
for (indices) |idx| results[idx] = try acc.snapshot(allocator);
|
|
} else if (frame == .rows) {
|
|
// ROWS frame: emit the accumulator's state after EACH row,
|
|
// walked in sorted order, even a row tied with its predecessor.
|
|
for (indices) |idx| {
|
|
try acc.observe(allocator, &rows[idx]);
|
|
results[idx] = try acc.snapshot(allocator);
|
|
}
|
|
} else {
|
|
// RANGE frame: advance the accumulator by a whole tie GROUP at
|
|
// once (same `tiesWithPrev` test the ranking family uses to
|
|
// find its own tie boundaries), then stamp every row in that
|
|
// group with the group's combined value - mirroring the
|
|
// whole-partition branch above, just re-run once per group
|
|
// instead of once for the whole partition.
|
|
var pos: usize = 0;
|
|
while (pos < indices.len) {
|
|
var end = pos + 1;
|
|
while (end < indices.len and tiesWithPrev(rows, orderBy, indices[end - 1], indices[end])) : (end += 1) {}
|
|
for (indices[pos..end]) |idx| try acc.observe(allocator, &rows[idx]);
|
|
for (indices[pos..end]) |idx| results[idx] = try acc.snapshot(allocator);
|
|
pos = end;
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// Computes MULTIPLE window-function columns over the SAME shared
|
|
/// `PARTITION BY`/`ORDER BY`/`frame` in one pass - SQL's `SELECT sum(x)
|
|
/// OVER w, avg(y) OVER w, rank() OVER w FROM t WINDOW w AS (...)` (every
|
|
/// column sharing one window definition), the window-function counterpart
|
|
/// of `aggregate.zig`'s multi-`AggSpec` GROUP BY. `partitionedAndSorted` is
|
|
/// built exactly ONCE and reused for every spec - unlike calling `compute()`
|
|
/// in a loop, which would redo that O(n log n) sort per spec for no reason.
|
|
///
|
|
/// Returns one entry PER ROW (aligned 1:1 with `rows`, like `compute()`'s
|
|
/// result), each entry an independently-owned `[]Value` of length
|
|
/// `specs.len` in the SAME order as `specs` - row-major, not the
|
|
/// column-major shape the per-spec computation happens in internally,
|
|
/// because that is the shape every caller (CLI printing, wire encoding)
|
|
/// actually wants: all of one row's annotations together. Caller owns the
|
|
/// returned slice; free each inner slice (`.free(allocator)` on every
|
|
/// `Value`, then `allocator.free` the slice), or call `freeManyResults`.
|
|
pub fn computeMany(allocator: Allocator, rows: []const Row, partitionBy: []const []const u8, orderBy: []const querymod.OrderBy, specs: []const WindowSpec, frame: FrameMode) WindowError![][]Value {
|
|
const partitions = try partitionedAndSorted(allocator, rows, partitionBy, orderBy);
|
|
defer {
|
|
for (partitions) |p| allocator.free(p);
|
|
allocator.free(partitions);
|
|
}
|
|
|
|
var columns = try allocator.alloc([]Value, specs.len);
|
|
var filledCols: usize = 0;
|
|
errdefer {
|
|
for (columns[0..filledCols]) |c| freeResults(allocator, c);
|
|
allocator.free(columns);
|
|
}
|
|
for (specs) |spec| {
|
|
columns[filledCols] = try computeOneSpec(allocator, rows, partitions, orderBy, spec, frame);
|
|
filledCols += 1;
|
|
}
|
|
|
|
const results = try allocator.alloc([]Value, rows.len);
|
|
var filledRows: usize = 0;
|
|
errdefer {
|
|
for (results[0..filledRows]) |r| freeResults(allocator, r);
|
|
allocator.free(results);
|
|
}
|
|
for (0..rows.len) |rowIdx| {
|
|
const rowValues = try allocator.alloc(Value, specs.len);
|
|
var filledVals: usize = 0;
|
|
errdefer {
|
|
for (rowValues[0..filledVals]) |v| v.free(allocator);
|
|
allocator.free(rowValues);
|
|
}
|
|
for (columns, 0..) |col, specIdx| {
|
|
rowValues[specIdx] = try col[rowIdx].dupe(allocator);
|
|
filledVals += 1;
|
|
}
|
|
results[rowIdx] = rowValues;
|
|
filledRows += 1;
|
|
}
|
|
|
|
// Every column stays independently owned by `columns` up to this
|
|
// point - `results` above holds its OWN dupe of every `Value` (min/max
|
|
// may hold heap-owned text/bytes), never a shared pointer - so it is
|
|
// always safe to free `columns` in full here, on the success path only:
|
|
// the `errdefer` right after `columns` was allocated already covers
|
|
// EVERY error path (early, mid-build, or during the transpose loop
|
|
// above) using the same `filledCols`, which by now equals `specs.len` -
|
|
// a second, unconditional `defer` registered after that `errdefer`
|
|
// would double-free `columns` on any error occurring after it, since an
|
|
// `errdefer` stays armed for the rest of the enclosing scope regardless
|
|
// of what runs after it, not just the statements immediately following.
|
|
for (columns) |c| freeResults(allocator, c);
|
|
allocator.free(columns);
|
|
|
|
return results;
|
|
}
|
|
|
|
pub fn freeManyResults(allocator: Allocator, results: [][]Value) void {
|
|
for (results) |r| freeResults(allocator, r);
|
|
allocator.free(results);
|
|
}
|
|
|
|
pub fn freeResults(allocator: Allocator, results: []Value) void {
|
|
for (results) |v| v.free(allocator);
|
|
allocator.free(results);
|
|
}
|
|
|
|
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 threeRowsOnePartition(allocator: Allocator) !std.ArrayList(Row) {
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
errdefer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const data = [_]struct { team: []const u8, score: i64 }{
|
|
.{ .team = "a", .score = 30 },
|
|
.{ .team = "a", .score = 10 },
|
|
.{ .team = "a", .score = 20 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "team", .value = .{ .text = d.team } },
|
|
.{ .name = "score", .value = .{ .int = d.score } },
|
|
}));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
test "row_number assigns 1..n within a single implicit partition, ordered" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
|
|
const order = [_]querymod.OrderBy{.{ .field = "score" }};
|
|
const results = try compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .row_number }, .rows);
|
|
defer freeResults(allocator, results);
|
|
|
|
// Input order: score 30, 10, 20 -> ascending rank order is row1=10
|
|
// (input index 1), row2=20 (index 2), row3=30 (index 0). Results stay
|
|
// aligned to INPUT row order, not the sorted walk order.
|
|
try std.testing.expectEqual(@as(i64, 3), results[0].int); // score 30 -> 3rd
|
|
try std.testing.expectEqual(@as(i64, 1), results[1].int); // score 10 -> 1st
|
|
try std.testing.expectEqual(@as(i64, 2), results[2].int); // score 20 -> 2nd
|
|
}
|
|
|
|
test "row_number restarts at 1 per partition" {
|
|
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 = 5 },
|
|
.{ .city = "krakow", .score = 9 },
|
|
.{ .city = "warsaw", .score = 1 },
|
|
.{ .city = "krakow", .score = 2 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "city", .value = .{ .text = d.city } },
|
|
.{ .name = "score", .value = .{ .int = d.score } },
|
|
}));
|
|
}
|
|
|
|
const partition = [_][]const u8{"city"};
|
|
const order = [_]querymod.OrderBy{.{ .field = "score" }};
|
|
const results = try compute(allocator, rows.items, &partition, &order, .{ .field = "", .kind = .row_number }, .rows);
|
|
defer freeResults(allocator, results);
|
|
|
|
// warsaw: score 5 (idx0), score 1 (idx2) -> sorted asc: idx2=1st, idx0=2nd.
|
|
try std.testing.expectEqual(@as(i64, 2), results[0].int);
|
|
try std.testing.expectEqual(@as(i64, 1), results[2].int);
|
|
// krakow: score 9 (idx1), score 2 (idx3) -> sorted asc: idx3=1st, idx1=2nd.
|
|
try std.testing.expectEqual(@as(i64, 2), results[1].int);
|
|
try std.testing.expectEqual(@as(i64, 1), results[3].int);
|
|
}
|
|
|
|
test "rank skips over ties, dense_rank does not" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// Scores: 10, 10, 20, 30, 30, 30 (already in ascending order so the
|
|
// sort is a no-op tie-preserving identity, keeping the expected
|
|
// positions easy to reason about).
|
|
const scores = [_]i64{ 10, 10, 20, 30, 30, 30 };
|
|
for (scores) |s| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "score", .value = .{ .int = s } },
|
|
}));
|
|
}
|
|
|
|
const order = [_]querymod.OrderBy{.{ .field = "score" }};
|
|
|
|
const ranks = try compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .rank }, .rows);
|
|
defer freeResults(allocator, ranks);
|
|
const expectedRank = [_]i64{ 1, 1, 3, 4, 4, 4 };
|
|
for (expectedRank, 0..) |want, i| try std.testing.expectEqual(want, ranks[i].int);
|
|
|
|
const dense = try compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .dense_rank }, .rows);
|
|
defer freeResults(allocator, dense);
|
|
const expectedDense = [_]i64{ 1, 1, 2, 3, 3, 3 };
|
|
for (expectedDense, 0..) |want, i| try std.testing.expectEqual(want, dense[i].int);
|
|
}
|
|
|
|
test "rank and dense_rank without ORDER BY: whole partition ties, every row is 1" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
|
|
const ranks = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "", .kind = .rank }, .rows);
|
|
defer freeResults(allocator, ranks);
|
|
for (ranks) |r| try std.testing.expectEqual(@as(i64, 1), r.int);
|
|
|
|
const dense = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "", .kind = .dense_rank }, .rows);
|
|
defer freeResults(allocator, dense);
|
|
for (dense) |r| try std.testing.expectEqual(@as(i64, 1), r.int);
|
|
|
|
// row_number still needs no ORDER BY - just first-seen position.
|
|
const nums = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "", .kind = .row_number }, .rows);
|
|
defer freeResults(allocator, nums);
|
|
try std.testing.expectEqual(@as(i64, 1), nums[0].int);
|
|
try std.testing.expectEqual(@as(i64, 2), nums[1].int);
|
|
try std.testing.expectEqual(@as(i64, 3), nums[2].int);
|
|
}
|
|
|
|
test "running sum walks cumulatively in ORDER BY order, per partition" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// warsaw, in input order: 100, 250 (already ascending by "day").
|
|
// krakow: 40.
|
|
const data = [_]struct { city: []const u8, day: i64, amount: i64 }{
|
|
.{ .city = "warsaw", .day = 1, .amount = 100 },
|
|
.{ .city = "krakow", .day = 1, .amount = 40 },
|
|
.{ .city = "warsaw", .day = 2, .amount = 250 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "city", .value = .{ .text = d.city } },
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "amount", .value = .{ .int = d.amount } },
|
|
}));
|
|
}
|
|
|
|
const partition = [_][]const u8{"city"};
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
const results = try compute(allocator, rows.items, &partition, &order, .{ .field = "amount", .kind = .sum }, .rows);
|
|
defer freeResults(allocator, results);
|
|
|
|
try std.testing.expectEqual(@as(i64, 100), results[0].int); // warsaw day1: running=100
|
|
try std.testing.expectEqual(@as(i64, 40), results[1].int); // krakow day1: running=40
|
|
try std.testing.expectEqual(@as(i64, 350), results[2].int); // warsaw day2: running=100+250
|
|
}
|
|
|
|
test "RANGE frame: rows tied on ORDER BY share the tie group's combined total, ROWS gives each its own" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// One partition, ordered by "day": two rows tied at day=1 (10, 20),
|
|
// then one row at day=2 (5).
|
|
const data = [_]struct { day: i64, amount: i64 }{
|
|
.{ .day = 1, .amount = 10 },
|
|
.{ .day = 1, .amount = 20 },
|
|
.{ .day = 2, .amount = 5 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "amount", .value = .{ .int = d.amount } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
|
|
const rowsResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .sum }, .rows);
|
|
defer freeResults(allocator, rowsResult);
|
|
// ROWS: each physical row gets its own running total, even tied ones.
|
|
try std.testing.expectEqual(@as(i64, 10), rowsResult[0].int);
|
|
try std.testing.expectEqual(@as(i64, 30), rowsResult[1].int);
|
|
try std.testing.expectEqual(@as(i64, 35), rowsResult[2].int);
|
|
|
|
const rangeResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .sum }, .range);
|
|
defer freeResults(allocator, rangeResult);
|
|
// RANGE: both day=1 rows share the GROUP's combined total (10+20=30),
|
|
// not their own individual position within the tie; day=2 then adds 5.
|
|
try std.testing.expectEqual(@as(i64, 30), rangeResult[0].int);
|
|
try std.testing.expectEqual(@as(i64, 30), rangeResult[1].int);
|
|
try std.testing.expectEqual(@as(i64, 35), rangeResult[2].int);
|
|
}
|
|
|
|
test "RANGE frame on count/avg/min/max matches the same tie-group grouping as sum" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const data = [_]struct { day: i64, amount: i64 }{
|
|
.{ .day = 1, .amount = 30 },
|
|
.{ .day = 1, .amount = 10 },
|
|
.{ .day = 2, .amount = 20 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "amount", .value = .{ .int = d.amount } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
|
|
const counts = try compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .count }, .range);
|
|
defer freeResults(allocator, counts);
|
|
try std.testing.expectEqual(@as(i64, 2), counts[0].int);
|
|
try std.testing.expectEqual(@as(i64, 2), counts[1].int);
|
|
try std.testing.expectEqual(@as(i64, 3), counts[2].int);
|
|
|
|
const mins = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .min }, .range);
|
|
defer freeResults(allocator, mins);
|
|
try std.testing.expectEqual(@as(i64, 10), mins[0].int);
|
|
try std.testing.expectEqual(@as(i64, 10), mins[1].int);
|
|
try std.testing.expectEqual(@as(i64, 10), mins[2].int);
|
|
|
|
const maxs = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .max }, .range);
|
|
defer freeResults(allocator, maxs);
|
|
try std.testing.expectEqual(@as(i64, 30), maxs[0].int);
|
|
try std.testing.expectEqual(@as(i64, 30), maxs[1].int);
|
|
try std.testing.expectEqual(@as(i64, 30), maxs[2].int);
|
|
|
|
const avgs = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .avg }, .range);
|
|
defer freeResults(allocator, avgs);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 20), avgs[0].float, 0.0001);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 20), avgs[1].float, 0.0001);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 20), avgs[2].float, 0.0001);
|
|
}
|
|
|
|
test "RANGE with no ties reduces to the same result as ROWS" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const data = [_]i64{ 3, 1, 4 };
|
|
for (data) |amount| {
|
|
try rows.append(try makeRow(allocator, &.{.{ .name = "amount", .value = .{ .int = amount } }}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "amount" }};
|
|
|
|
const rowsResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .sum }, .rows);
|
|
defer freeResults(allocator, rowsResult);
|
|
const rangeResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .sum }, .range);
|
|
defer freeResults(allocator, rangeResult);
|
|
for (rowsResult, rangeResult) |r, g| try std.testing.expectEqual(r.int, g.int);
|
|
}
|
|
|
|
test "RANGE with every row tied gives everyone the whole-partition total, like no ORDER BY at all" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator); // scores 30, 10, 20, all same "team"
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "team" }}; // every row ties on "team"
|
|
|
|
const results = try compute(allocator, rows.items, &.{}, &order, .{ .field = "score", .kind = .sum }, .range);
|
|
defer freeResults(allocator, results);
|
|
for (results) |r| try std.testing.expectEqual(@as(i64, 60), r.int);
|
|
}
|
|
|
|
test "frame is rejected outright for the ranking family, never silently ignored" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "score" }};
|
|
try std.testing.expectError(error.RangeNotApplicable, compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .row_number }, .range));
|
|
try std.testing.expectError(error.RangeNotApplicable, compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .rank }, .range));
|
|
try std.testing.expectError(error.RangeNotApplicable, compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .dense_rank }, .range));
|
|
}
|
|
|
|
test "compute propagates OutOfMemory without leaking, mid-way through a RANGE tie group" {
|
|
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{ "mango", "mango", "apple" };
|
|
for (names) |n| {
|
|
try rows.append(try makeRow(allocator, &.{.{ .name = "name", .value = .{ .text = n } }}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "name" }};
|
|
|
|
var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 4 });
|
|
try std.testing.expectError(error.OutOfMemory, compute(failing.allocator(), rows.items, &.{}, &order, .{ .field = "name", .kind = .min }, .range));
|
|
}
|
|
|
|
test "sum without ORDER BY gives every row the whole-partition total" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// scores 30, 10, 20 -> total 60, same value for every row.
|
|
const results = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .sum }, .rows);
|
|
defer freeResults(allocator, results);
|
|
for (results) |r| try std.testing.expectEqual(@as(i64, 60), r.int);
|
|
}
|
|
|
|
test "avg running and count star running" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const scores = [_]i64{ 10, 20, 30 };
|
|
for (scores) |s| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "score", .value = .{ .int = s } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "score" }};
|
|
|
|
const avgs = try compute(allocator, rows.items, &.{}, &order, .{ .field = "score", .kind = .avg }, .rows);
|
|
defer freeResults(allocator, avgs);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 10), avgs[0].float, 1e-9);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 15), avgs[1].float, 1e-9);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 20), avgs[2].float, 1e-9);
|
|
|
|
const counts = try compute(allocator, rows.items, &.{}, &order, .{ .field = "", .kind = .count }, .rows);
|
|
defer freeResults(allocator, counts);
|
|
try std.testing.expectEqual(@as(i64, 1), counts[0].int);
|
|
try std.testing.expectEqual(@as(i64, 2), counts[1].int);
|
|
try std.testing.expectEqual(@as(i64, 3), counts[2].int);
|
|
}
|
|
|
|
test "count_distinct runs cumulatively per partition, repeats do not grow it" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// warsaw, in "day" order: tag a, tag b, tag a (repeat) -> distinct count
|
|
// grows 1, 2, 2 (the third row's "a" was already seen).
|
|
// krakow: tag c -> distinct count 1.
|
|
const data = [_]struct { city: []const u8, day: i64, tag: []const u8 }{
|
|
.{ .city = "warsaw", .day = 1, .tag = "a" },
|
|
.{ .city = "krakow", .day = 1, .tag = "c" },
|
|
.{ .city = "warsaw", .day = 2, .tag = "b" },
|
|
.{ .city = "warsaw", .day = 3, .tag = "a" },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "city", .value = .{ .text = d.city } },
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "tag", .value = .{ .text = d.tag } },
|
|
}));
|
|
}
|
|
|
|
const partition = [_][]const u8{"city"};
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
const results = try compute(allocator, rows.items, &partition, &order, .{ .field = "tag", .kind = .count_distinct }, .rows);
|
|
defer freeResults(allocator, results);
|
|
|
|
try std.testing.expectEqual(@as(i64, 1), results[0].int); // warsaw day1: {a}
|
|
try std.testing.expectEqual(@as(i64, 1), results[1].int); // krakow day1: {c}
|
|
try std.testing.expectEqual(@as(i64, 2), results[2].int); // warsaw day2: {a,b}
|
|
try std.testing.expectEqual(@as(i64, 2), results[3].int); // warsaw day3: {a,b} again, "a" repeats
|
|
}
|
|
|
|
test "count_distinct without ORDER BY gives every row the whole-partition distinct total" {
|
|
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{ "x", "y", "x", "z" };
|
|
for (tags) |t| {
|
|
try rows.append(try makeRow(allocator, &.{.{ .name = "tag", .value = .{ .text = t } }}));
|
|
}
|
|
const results = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "tag", .kind = .count_distinct }, .rows);
|
|
defer freeResults(allocator, results);
|
|
for (results) |r| try std.testing.expectEqual(@as(i64, 3), r.int); // {x,y,z}
|
|
}
|
|
|
|
test "count_distinct RANGE frame: a tied group's members share one snapshot after the WHOLE group is observed" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
// Tied at day=1: tags "a","b" (2 distinct together, not counted one at a
|
|
// time within the tie like ROWS would). Then day=2 adds "a" again (no
|
|
// growth) then "c" (grows to 3).
|
|
const data = [_]struct { day: i64, tag: []const u8 }{
|
|
.{ .day = 1, .tag = "a" },
|
|
.{ .day = 1, .tag = "b" },
|
|
.{ .day = 2, .tag = "a" },
|
|
.{ .day = 2, .tag = "c" },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "tag", .value = .{ .text = d.tag } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
|
|
const rowsResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "tag", .kind = .count_distinct }, .rows);
|
|
defer freeResults(allocator, rowsResult);
|
|
// ROWS: each physical row gets its own running distinct count.
|
|
try std.testing.expectEqual(@as(i64, 1), rowsResult[0].int); // {a}
|
|
try std.testing.expectEqual(@as(i64, 2), rowsResult[1].int); // {a,b}
|
|
try std.testing.expectEqual(@as(i64, 2), rowsResult[2].int); // {a,b}, "a" repeats
|
|
try std.testing.expectEqual(@as(i64, 3), rowsResult[3].int); // {a,b,c}
|
|
|
|
const rangeResult = try compute(allocator, rows.items, &.{}, &order, .{ .field = "tag", .kind = .count_distinct }, .range);
|
|
defer freeResults(allocator, rangeResult);
|
|
// RANGE: both day=1 rows share the GROUP's combined distinct count (2),
|
|
// not their own individual position within the tie; day=2 group adds
|
|
// "c" (a repeats), landing on 3 for both its members.
|
|
try std.testing.expectEqual(@as(i64, 2), rangeResult[0].int);
|
|
try std.testing.expectEqual(@as(i64, 2), rangeResult[1].int);
|
|
try std.testing.expectEqual(@as(i64, 3), rangeResult[2].int);
|
|
try std.testing.expectEqual(@as(i64, 3), rangeResult[3].int);
|
|
}
|
|
|
|
test "count_distinct requires a field like sum/avg/min/max, unlike plain count" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = try threeRowsOnePartition(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
try std.testing.expectError(error.MissingField, compute(allocator, rows.items, &.{}, &.{}, .{ .field = "", .kind = .count_distinct }, .rows));
|
|
}
|
|
|
|
test "count_distinct ignores rows missing the field entirely, 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 = "other", .value = .{ .int = 1 } }})); // missing "tag"
|
|
try rows.append(try makeRow(allocator, &.{.{ .name = "tag", .value = .{ .text = "a" } }}));
|
|
const results = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "tag", .kind = .count_distinct }, .rows);
|
|
defer freeResults(allocator, results);
|
|
for (results) |r| try std.testing.expectEqual(@as(i64, 1), r.int); // only "a" ever counted
|
|
}
|
|
|
|
test "count_distinct propagates OutOfMemory without leaking already-tracked distinct keys" {
|
|
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{ "mango", "apple", "kiwi" };
|
|
for (tags) |t| {
|
|
try rows.append(try makeRow(allocator, &.{.{ .name = "tag", .value = .{ .text = t } }}));
|
|
}
|
|
var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 6 });
|
|
try std.testing.expectError(error.OutOfMemory, compute(failing.allocator(), rows.items, &.{}, &.{}, .{ .field = "tag", .kind = .count_distinct }, .rows));
|
|
}
|
|
|
|
test "count_distinct alongside lag/lead/first_value/running sum/ranking in one computeMany call" {
|
|
const allocator = std.testing.allocator;
|
|
var rows = std.ArrayList(Row).init(allocator);
|
|
defer {
|
|
for (rows.items) |*r| r.deinit();
|
|
rows.deinit();
|
|
}
|
|
const data = [_]struct { day: i64, tag: []const u8, amount: i64 }{
|
|
.{ .day = 1, .tag = "a", .amount = 10 },
|
|
.{ .day = 2, .tag = "b", .amount = 20 },
|
|
.{ .day = 3, .tag = "a", .amount = 30 },
|
|
};
|
|
for (data) |d| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "day", .value = .{ .int = d.day } },
|
|
.{ .name = "tag", .value = .{ .text = d.tag } },
|
|
.{ .name = "amount", .value = .{ .int = d.amount } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "day" }};
|
|
const specs = [_]WindowSpec{
|
|
.{ .field = "tag", .kind = .count_distinct },
|
|
.{ .field = "amount", .kind = .lag },
|
|
.{ .field = "amount", .kind = .sum },
|
|
.{ .field = "", .kind = .row_number },
|
|
};
|
|
const results = try computeMany(allocator, rows.items, &.{}, &order, &specs, .rows);
|
|
defer freeManyResults(allocator, results);
|
|
|
|
try std.testing.expectEqual(@as(i64, 1), results[0][0].int); // count_distinct {a}
|
|
try std.testing.expectEqual(Value{ .none = {} }, results[0][1]); // lag: no predecessor
|
|
try std.testing.expectEqual(@as(i64, 10), results[0][2].int); // running sum
|
|
try std.testing.expectEqual(@as(i64, 1), results[0][3].int); // row_number
|
|
|
|
try std.testing.expectEqual(@as(i64, 2), results[1][0].int); // {a,b}
|
|
try std.testing.expectEqual(@as(i64, 10), results[1][1].int); // lag -> day1's amount
|
|
try std.testing.expectEqual(@as(i64, 30), results[1][2].int);
|
|
try std.testing.expectEqual(@as(i64, 2), results[1][3].int);
|
|
|
|
try std.testing.expectEqual(@as(i64, 2), results[2][0].int); // {a,b}, "a" repeats
|
|
try std.testing.expectEqual(@as(i64, 20), results[2][1].int); // lag -> day2's amount
|
|
try std.testing.expectEqual(@as(i64, 60), results[2][2].int);
|
|
try std.testing.expectEqual(@as(i64, 3), results[2][3].int);
|
|
}
|
|
|
|
test "running min and max over a text field, independently duped per row" {
|
|
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{ "mango", "apple", "kiwi" };
|
|
for (names, 0..) |n, i| {
|
|
try rows.append(try makeRow(allocator, &.{
|
|
.{ .name = "seq", .value = .{ .int = @intCast(i) } },
|
|
.{ .name = "name", .value = .{ .text = n } },
|
|
}));
|
|
}
|
|
const order = [_]querymod.OrderBy{.{ .field = "seq" }};
|
|
|