diff --git a/src/query.zig b/src/query.zig index 87dd3d6..4aeaabd 100644 --- a/src/query.zig +++ b/src/query.zig @@ -1752,3 +1752,160 @@ test "selectPage with limit 0 matches unlimited select" { try std.testing.expectEqual(all.len, page.len); } +test "selectPage offset past the end returns nothing" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{}); + try table.put(.{ .int = 2 }, &.{}); + + const page = try table.selectPage(null, .{ .offset = 50 }); + defer freeRows(allocator, page); + try std.testing.expectEqual(@as(usize, 0), page.len); +} + +test "selectPage paginates through an index-narrowed predicate in pk order" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.createIndex("city"); + + var pk: i64 = 1; + while (pk <= 6) : (pk += 1) { + try table.put(.{ .int = pk }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + } + // Noise row indexed under a different value - must never leak into the + // paginated "warsaw" result regardless of offset/limit. + try table.put(.{ .int = 99 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }}); + + const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }; + + const first = try table.selectPage(pred, .{ .limit = 2 }); + defer freeRows(allocator, first); + try std.testing.expectEqual(@as(usize, 2), first.len); + try std.testing.expectEqual(@as(i64, 1), first[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 2), first[1].get(pkField).?.int); + + const second = try table.selectPage(pred, .{ .offset = 2, .limit = 2 }); + defer freeRows(allocator, second); + try std.testing.expectEqual(@as(usize, 2), second.len); + try std.testing.expectEqual(@as(i64, 3), second[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 4), second[1].get(pkField).?.int); + + const rest = try table.selectPage(pred, .{ .offset = 4 }); + defer freeRows(allocator, rest); + try std.testing.expectEqual(@as(usize, 2), rest.len); + try std.testing.expectEqual(@as(i64, 5), rest[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 6), rest[1].get(pkField).?.int); +} + +test "selectPage against a large indexed bucket stays correct with a small limit" { + // Exercises the streaming `cursorForLeaf` path (see index.zig's + // EqCursor) against a bucket far larger than the requested page: a + // regression that only materialized the first `limit` matches would + // silently pass this test too, but a regression that went back to + // materializing the whole 500-row match set first would still pass on + // correctness alone - the point of the streaming rewrite is that this + // scan touches roughly `offset + limit` index entries, not 500, which is + // asserted structurally in index.zig's cursor tests via early deinit. + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.createIndex("city"); + + var pk: i64 = 0; + while (pk < 500) : (pk += 1) { + try table.put(.{ .int = pk }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + } + + const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }; + + const page = try table.selectPage(pred, .{ .offset = 497, .limit = 10 }); + defer freeRows(allocator, page); + try std.testing.expectEqual(@as(usize, 3), page.len); + try std.testing.expectEqual(@as(i64, 497), page[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 498), page[1].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 499), page[2].get(pkField).?.int); +} + +test "selectPageOrdered sorts a full scan ascending and descending by an int field" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + // Insert pks out of score order so ascending pk (the natural full-scan + // order) is NOT already sorted by `score` - a regression that forgot to + // sort and just returned scan order would fail both assertions below. + const scores = [_]i64{ 30, 10, 50, 20, 40 }; + for (scores, 1..) |score, pk| { + try table.put(.{ .int = @intCast(pk) }, &.{.{ .name = "score", .value = .{ .int = score } }}); + } + + const asc = try table.selectPageOrdered(null, .{}, &.{.{ .field = "score" }}); + defer freeRows(allocator, asc); + var expected = [_]i64{ 10, 20, 30, 40, 50 }; + try std.testing.expectEqual(expected.len, asc.len); + for (asc, 0..) |r, i| try std.testing.expectEqual(expected[i], r.get("score").?.int); + + const desc = try table.selectPageOrdered(null, .{}, &.{.{ .field = "score", .descending = true }}); + defer freeRows(allocator, desc); + std.mem.reverse(i64, &expected); + for (desc, 0..) |r, i| try std.testing.expectEqual(expected[i], r.get("score").?.int); +} + +test "selectPageOrdered applies offset/limit after sorting, not before" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + var pk: i64 = 1; + while (pk <= 10) : (pk += 1) { + // Descending score as pk increases, so "top 3 by score desc" is the + // FIRST 3 pks, not the last 3 - a page-then-sort bug (paging the + // natural scan order before sorting) would return the wrong rows. + try table.put(.{ .int = pk }, &.{.{ .name = "score", .value = .{ .int = 100 - pk } }}); + } + + const top3 = try table.selectPageOrdered(null, .{ .limit = 3 }, &.{.{ .field = "score", .descending = true }}); + defer freeRows(allocator, top3); + try std.testing.expectEqual(@as(usize, 3), top3.len); + try std.testing.expectEqual(@as(i64, 1), top3[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 2), top3[1].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 3), top3[2].get(pkField).?.int); + + const nextPage = try table.selectPageOrdered(null, .{ .offset = 3, .limit = 3 }, &.{.{ .field = "score", .descending = true }}); + defer freeRows(allocator, nextPage); + try std.testing.expectEqual(@as(i64, 4), nextPage[0].get(pkField).?.int); + try std.testing.expectEqual(@as(i64, 6), nextPage[2].get(pkField).?.int); +} + +test "selectPageOrdered sorts text fields lexically and is stable on ties" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); +