feat: add EqCursor and next to index

This commit is contained in:
dualfroz committed 2026-09-04 20:59:22 +02:00
1 parent cfd2fd1f5d
commit 910afabd97
1 file changed
+158
+158
View File
@@ -1110,3 +1110,161 @@ pub fn scanCompositeRangeCursor(
break :blk endBuf.?; 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]);
}