From 053af6011362d5272edc3c914b5cf2f69fdaac07 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:51:10 +0200 Subject: [PATCH] feat: continue work on window --- src/window.zig | 166 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/window.zig b/src/window.zig index b68e512..bb3a013 100644 --- a/src/window.zig +++ b/src/window.zig @@ -1603,3 +1603,169 @@ test "lag/lead default offset of 1: NULL (default) at the edge, adjacent value o try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lead[2])); } +test "lag/lead with an explicit offset skips that many physical positions" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + const amounts = [_]i64{ 10, 20, 30, 40 }; + for (amounts, 0..) |a, i| { + try rows.append(try makeRow(allocator, &.{ + .{ .name = "seq", .value = .{ .int = @intCast(i) } }, + .{ .name = "amount", .value = .{ .int = a } }, + })); + } + const order = [_]querymod.OrderBy{.{ .field = "seq" }}; + + const lag2 = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .lag, .offset = 2 }, .rows); + defer freeResults(allocator, lag2); + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lag2[0])); + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lag2[1])); + try std.testing.expectEqual(@as(i64, 10), lag2[2].int); + try std.testing.expectEqual(@as(i64, 20), lag2[3].int); + + // offset 0 reads the current row itself. + const lagZero = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .lag, .offset = 0 }, .rows); + defer freeResults(allocator, lagZero); + for (amounts, 0..) |a, i| try std.testing.expectEqual(a, lagZero[i].int); +} + +test "lag/lead use a caller-supplied default instead of NULL at the edge" { + 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 = "seq", .value = .{ .int = 0 } }, + .{ .name = "amount", .value = .{ .int = 5 } }, + })); + try rows.append(try makeRow(allocator, &.{ + .{ .name = "seq", .value = .{ .int = 1 } }, + .{ .name = "amount", .value = .{ .int = 6 } }, + })); + const order = [_]querymod.OrderBy{.{ .field = "seq" }}; + + const lag = try compute(allocator, rows.items, &.{}, &order, .{ .field = "amount", .kind = .lag, .default = .{ .int = -1 } }, .rows); + defer freeResults(allocator, lag); + try std.testing.expectEqual(@as(i64, -1), lag[0].int); + try std.testing.expectEqual(@as(i64, 5), lag[1].int); +} + +test "lag/lead restart per partition and treat orderBy ties as distinct physical positions" { + const allocator = std.testing.allocator; + var rows = std.ArrayList(Row).init(allocator); + defer { + for (rows.items) |*r| r.deinit(); + rows.deinit(); + } + // Two rows tied on "day" within "warsaw" - lag/lead must still treat + // them as two distinct positions (unlike the RANGE running-aggregate + // frame, which would collapse ties into one shared value). + const data = [_]struct { city: []const u8, day: i64, amount: i64 }{ + .{ .city = "warsaw", .day = 1, .amount = 10 }, + .{ .city = "warsaw", .day = 1, .amount = 20 }, + .{ .city = "krakow", .day = 1, .amount = 99 }, + }; + 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 lead = try compute(allocator, rows.items, &partition, &order, .{ .field = "amount", .kind = .lead }, .rows); + defer freeResults(allocator, lead); + // Stable sort keeps warsaw's two tied rows in first-seen order (10 then + // 20), so warsaw/10's lead is warsaw/20 (not krakow, a different + // partition entirely), and warsaw/20 (the partition's last row) is NULL. + try std.testing.expectEqual(@as(i64, 20), lead[0].int); + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lead[1])); + // krakow is its own partition of one row - always NULL. + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lead[2])); +} + +test "lag/lead without ORDER BY walk first-seen input order" { + 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 lag = try compute(allocator, rows.items, &.{}, &.{}, .{ .field = "score", .kind = .lag }, .rows); + defer freeResults(allocator, lag); + try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, lag[0])); + try std.testing.expectEqual(@as(i64, 30), lag[1].int); + try std.testing.expectEqual(@as(i64, 10), lag[2].int); +} + +test "lag/lead on a text field dupes independently per row, no shared pointers" { + 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" }}; + const lag = try compute(allocator, rows.items, &.{}, &order, .{ .field = "name", .kind = .lag }, .rows); + defer freeResults(allocator, lag); + try std.testing.expectEqualStrings("mango", lag[1].text); + try std.testing.expectEqualStrings("apple", lag[2].text); + try std.testing.expect(lag[1].text.ptr != names[0].ptr); +} + +test "lag/lead require a field, like sum/avg/min/max" { + 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 = .lag }, .rows)); + try std.testing.expectError(error.MissingField, compute(allocator, rows.items, &.{}, &.{}, .{ .field = "", .kind = .lead }, .rows)); +} + +test "lag/lead reject a RANGE frame outright, like the ranking family" { + 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 = "score", .kind = .lag }, .range)); + try std.testing.expectError(error.RangeNotApplicable, compute(allocator, rows.items, &.{}, &order, .{ .field = "score", .kind = .lead }, .range)); +} + +test "lag/lead 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 = .lag }, .rows)); +} +