From dcbdae99b40740799857c0da0f3cb3a6d67e63e6 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:55:50 +0200 Subject: [PATCH] feat: extend window --- src/window.zig | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/src/window.zig b/src/window.zig index 7860711..2e62223 100644 --- a/src/window.zig +++ b/src/window.zig @@ -1923,3 +1923,168 @@ test "first_value/last_value accept a RANGE frame, unlike lag/lead" { for (first) |v| try std.testing.expectEqual(@as(i64, 10), v.int); } +test "first_value/last_value propagate OutOfMemory without leaking" { + 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", "banana" }; + 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" }}; + 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 = .last_value }, .rows)); +} + +test "first_value/last_value on zero rows in a partition does not crash (empty indices guard)" { + const allocator = std.testing.allocator; + const rows = [_]Row{}; + const results = try compute(allocator, &rows, &.{}, &.{}, .{ .field = "x", .kind = .first_value }, .rows); + defer freeResults(allocator, results); + try std.testing.expectEqual(@as(usize, 0), results.len); +} + +test "nth_value without ORDER BY: whole-partition frame, n counts from the first-seen row" { + const allocator = std.testing.allocator; + var rows = try threeRowsOnePartition(allocator); // scores 30, 10, 20, first-seen order + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const n1 = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .nth_value, .offset = 1 }, .rows); + defer freeResults(allocator, n1); + for (n1) |v| try std.testing.expectEqual(@as(i64, 30), v.int); // same as first_value + + const n3 = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .nth_value, .offset = 3 }, .rows); + defer freeResults(allocator, n3); + for (n3) |v| try std.testing.expectEqual(@as(i64, 20), v.int); // same as last_value (partition has 3 rows) + + // Past the whole-partition frame's end -> spec.default (SQL NULL unless set). + const n4 = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .nth_value, .offset = 4 }, .rows); + defer freeResults(allocator, n4); + for (n4) |v| try std.testing.expectEqual(Value{ .none = {} }, v); +} + +test "nth_value ROWS frame: position visible only once the frame has grown to include it" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const scores = [_]i64{ 30, 10, 20 }; + for (scores) |s| { + try rows.append(try makeRow(allocator, &.{.{ .name = "score", .value = .{ .int = s } }})); + } + const order = [_]querymod.OrderBy{.{ .field = "score" }}; + // Sorted ascending walk order: 10 (idx1, pos0), 20 (idx2, pos1), 30 (idx0, pos2). + // `results` stays aligned with the ORIGINAL insertion order [30, 10, 20], not + // the sorted walk order - same convention `first_value`/`last_value`'s ROWS + // test above uses. + const results = try compute(allocator, rows.items, &.{}, &order, .{ .field = "score", .kind = .nth_value, .offset = 2 }, .rows); + defer freeResults(allocator, results); + // idx1 (score 10, pos0): frame is just itself (size 1) - position 2 does not + // exist yet -> default. + try std.testing.expectEqual(Value{ .none = {} }, results[1]); + // idx2 (score 20, pos1): frame now covers pos0-1 (size 2) - position 2 is + // this row's own value, 20. + try std.testing.expectEqual(@as(i64, 20), results[2].int); + // idx0 (score 30, pos2): frame covers pos0-2 (size 3) - position 2 is still + // idx2's value, 20 (the frame grew, but the row AT position 2 did not change). + try std.testing.expectEqual(@as(i64, 20), results[0].int); +} + +test "nth_value RANGE frame: position resolved against the current tie group's growing frame" { + 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 = 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" }}; + // RANGE's frame is CUMULATIVE (UNBOUNDED PRECEDING through the current tie + // group, same as the running-aggregate family), not limited to the current + // group alone - position 3 only becomes reachable once the frame has grown + // to include the day=2 group too. + const results = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .nth_value, .offset = 3 }, .range); + defer freeResults(allocator, results); + // day=1's tie group (2 members): frame size is still only 2 - position 3 + // does not exist yet -> default for BOTH members. + try std.testing.expectEqual(Value{ .none = {} }, results[0]); + try std.testing.expectEqual(Value{ .none = {} }, results[1]); + // day=2's tie group: frame now covers all 3 rows - position 3 is this row's + // own value, 5. + try std.testing.expectEqual(@as(i64, 5), results[2].int); +} + +test "nth_value uses a custom default instead of NULL when set" { + const allocator = std.testing.allocator; + var rows = try threeRowsOnePartition(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const results = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .nth_value, .offset = 10, .default = .{ .int = -1 } }, .rows); + defer freeResults(allocator, results); + for (results) |v| try std.testing.expectEqual(@as(i64, -1), v.int); +} + +test "nth_value with offset 0 never resolves (positions are 1-based)" { + const allocator = std.testing.allocator; + var rows = try threeRowsOnePartition(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const results = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .nth_value, .offset = 0 }, .rows); + defer freeResults(allocator, results); + for (results) |v| try std.testing.expectEqual(Value{ .none = {} }, v); +} + +test "nth_value restart per partition, RANGE tie group stays within one city" { + 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, day: i64, amount: i64 }{ + .{ .city = "warsaw", .day = 1, .amount = 100 }, + .{ .city = "warsaw", .day = 1, .amount = 250 }, + .{ .city = "krakow", .day = 1, .amount = 40 }, + .{ .city = "krakow", .day = 1, .amount = 90 }, + }; + 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 = .nth_value, .offset = 2 }, .range); + defer freeResults(allocator, results); + try std.testing.expectEqual(@as(i64, 250), results[0].int); // warsaw's 2nd member + try std.testing.expectEqual(@as(i64, 250), results[1].int); + try std.testing.expectEqual(@as(i64, 90), results[2].int); // krakow's OWN 2nd member, not warsaw's + try std.testing.expectEqual(@as(i64, 90), results[3].int); +} +