diff --git a/src/index.zig b/src/index.zig index 4104a02..a8100b2 100644 --- a/src/index.zig +++ b/src/index.zig @@ -1110,3 +1110,161 @@ pub fn scanCompositeRangeCursor( break :blk endBuf.?; }; + return .{ .it = try db.scanRange(allocator, .{ + .start = start, + .end = end, + .startInclusive = true, + .endInclusive = false, + }) }; +} + +/// Lazy cursor over an equality index lookup: decodes one primary key at a +/// time from the underlying `Db.RangeIterator` instead of materializing the +/// whole match set up front. This is what lets `Table.selectPage` honour a +/// small `limit` without reading and decoding every index entry for a value +/// that may have thousands of matches. +pub const EqCursor = struct { + it: dbmod.RangeIterator, + + pub fn next(self: *EqCursor, allocator: Allocator) !?Value { + const entry = try self.it.next() orelse return null; + return try row.decodeValue(allocator, entry.value); + } + + pub fn deinit(self: *EqCursor) void { + self.it.deinit(); + } +}; + +/// Point lookup, streaming: opens the underlying scan but decodes nothing +/// until `EqCursor.next` is called. +pub fn scanEqCursor(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value) !EqCursor { + const prefix = try indexValueBound(allocator, table, field, value); + defer allocator.free(prefix); + return .{ .it = try db.scanPrefix(allocator, prefix) }; +} + +/// Point lookup: all primary keys currently indexed under `value`. +pub fn scanEq(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value) ![]Value { + var cursor = try scanEqCursor(db, allocator, table, field, value); + defer cursor.deinit(); + + var results = std.ArrayList(Value).init(allocator); + errdefer { + for (results.items) |v| v.free(allocator); + results.deinit(); + } + while (try cursor.next(allocator)) |pk| try results.append(pk); + return results.toOwnedSlice(); +} + +pub const Bound = struct { + value: Value, + inclusive: bool, +}; + +/// Lazy cursor over a range index lookup, same rationale as `EqCursor`. +pub const RangeCursor = struct { + it: dbmod.RangeIterator, + + pub fn next(self: *RangeCursor, allocator: Allocator) !?Value { + const entry = try self.it.next() orelse return null; + return try row.decodeValue(allocator, entry.value); + } + + pub fn deinit(self: *RangeCursor) void { + self.it.deinit(); + } +}; + +/// Range lookup over `table.field`'s index, streaming: primary keys whose +/// indexed value falls within [low, high] (either side may be open/null), +/// decoded one at a time as the caller consumes the cursor. +pub fn scanRangeCursor( + db: *Db, + allocator: Allocator, + table: []const u8, + field: []const u8, + low: ?Bound, + high: ?Bound, +) !RangeCursor { + const prefix = try indexPrefix(allocator, table, field); + defer allocator.free(prefix); + + var startBuf: ?[]u8 = null; + defer if (startBuf) |b| allocator.free(b); + var endBuf: ?[]u8 = null; + defer if (endBuf) |b| allocator.free(b); + + // Every real index-entry key is `indexValueBound(value) ++ orderedPk`, a + // strict byte-extension of the bare value bound (pk encodings are never + // empty). So a key with value == threshold always compares greater than + // `indexValueBound(threshold)` itself - inclusive/exclusive thresholds + // are therefore expressed by choosing which side of that gap to start + // or stop the scan at, not by the start/endInclusive flags. + const start = if (low) |b| blk: { + const bound = try indexValueBound(allocator, table, field, b.value); + if (b.inclusive) { + startBuf = bound; + } else { + defer allocator.free(bound); + startBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound); + } + break :blk startBuf.?; + } else prefix; + + const end = if (high) |b| blk: { + const bound = try indexValueBound(allocator, table, field, b.value); + if (b.inclusive) { + defer allocator.free(bound); + endBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound); + } else { + endBuf = bound; + } + break :blk endBuf.?; + } else blk: { + endBuf = try prefixUpperBound(allocator, prefix) orelse try allocator.dupe(u8, prefix); + break :blk endBuf.?; + }; + + return .{ .it = try db.scanRange(allocator, .{ + .start = start, + .end = end, + .startInclusive = true, + .endInclusive = false, + }) }; +} + +/// Range lookup over `table.field`'s index: all primary keys whose indexed +/// value falls within [low, high] (either side may be open/null). +pub fn scanRange( + db: *Db, + allocator: Allocator, + table: []const u8, + field: []const u8, + low: ?Bound, + high: ?Bound, +) ![]Value { + var cursor = try scanRangeCursor(db, allocator, table, field, low, high); + defer cursor.deinit(); + + var results = std.ArrayList(Value).init(allocator); + errdefer { + for (results.items) |v| v.free(allocator); + results.deinit(); + } + while (try cursor.next(allocator)) |pk| try results.append(pk); + return results.toOwnedSlice(); +} + +test "rowKey and indexEntryKey stay within their own namespace" { + const allocator = std.testing.allocator; + const rk = try rowKey(allocator, "users", .{ .int = 1 }); + defer allocator.free(rk); + const ik = try indexEntryKey(allocator, "users", "email", .{ .text = "a@b.c" }, .{ .int = 1 }); + defer allocator.free(ik); + + try std.testing.expectEqual(@as(u8, 'R'), rk[0]); + try std.testing.expectEqual(@as(u8, 'I'), ik[0]); +} +