index: secondary index core — entries, search, planner, _id fast path

Adds src/index.zig with the full secondary-index machinery: entry
generation mirroring field_matches (array value + elements), BSON-order
sorted entries with binary search, compound prefix and range lookups,
unique/sparse options, the query planner (longest equality/$in run +
optional range, $in cartesian cap, sparse/null bail), and the _id_ fast
path guarded against serialization-ambiguous values (numbers, strings,
symbols, codes, opaque payloads).

query.collect_values is now pub so entry generation can mirror it exactly.
storage.zig gains record_type_index_create/drop; lib.zig exports index.
This commit is contained in:
2026-08-02 12:21:22 +03:00
parent 662df9b121
commit a38ddc2f50
10 changed files with 1824 additions and 782 deletions

View File

@@ -57,16 +57,26 @@ fn match_top_level(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, do
return false;
}
/// True when every key is a `$`-prefixed operator. The one definition of
/// what an operator document looks like — filters, $elemMatch, $pull and
/// upsert-document construction all ask this question.
pub fn all_operator_keys(pairs: []const bson.Pair) bool {
if (pairs.len == 0) return false;
for (pairs) |p| {
if (p.key.len == 0 or p.key[0] != '$') return false;
}
return true;
}
/// The operator pairs of a `{$op: ...}` value, or null if it is not one. An
/// empty document counts as an (vacuously satisfied) operator document.
fn is_operator_doc(value: bson.Value) ?[]const bson.Pair {
return switch (value) {
.doc => |pairs| blk: {
for (pairs) |p| {
if (p.key.len == 0 or p.key[0] != '$') break :blk null;
}
break :blk pairs;
},
else => null,
const pairs = switch (value) {
.doc => |p| p,
else => return null,
};
if (pairs.len > 0 and !all_operator_keys(pairs)) return null;
return pairs;
}
fn field_matches(gpa: std.mem.Allocator, path: []const u8, expected: bson.Value, doc: *const bson.Document) QueryError!bool {
@@ -220,13 +230,7 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
.doc => |pairs| pairs,
else => return false,
};
var all_operators = operand.len > 0;
for (operand) |p| {
if (p.key.len == 0 or p.key[0] != '$') {
all_operators = false;
break;
}
}
const all_operators = all_operator_keys(operand);
for (actuals) |a| {
if (a != .array) continue;
for (a.array) |elem| {
@@ -258,8 +262,9 @@ fn match_operator(gpa: std.mem.Allocator, op: []const u8, value: bson.Value, act
/// Collect values reachable at `path` (dot-separated), descending into
/// documents and, per MongoDB multikey semantics, into arrays of documents.
/// Appends into `out`; on OOM, collection stops early (the engine is
/// already failing at that point).
fn collect_values(gpa: std.mem.Allocator, pairs: []const bson.Pair, path: []const u8, out: *std.ArrayListUnmanaged(bson.Value), depth: usize) QueryError!void {
/// already failing at that point). Public because index entry generation
/// must mirror field_matches exactly (src/index.zig).
pub fn collect_values(gpa: std.mem.Allocator, pairs: []const bson.Pair, path: []const u8, out: *std.ArrayListUnmanaged(bson.Value), depth: usize) QueryError!void {
var it = std.mem.splitScalar(u8, path, '.');
const first = it.next() orelse return;
@@ -550,7 +555,7 @@ pub const SortKey = struct {
/// Sort `docs` in place by `keys`. Candidate values are collected up front
/// (allocations happen before the sort), so the comparator itself is pure
/// and cannot fail — OOM during collection propagates as QueryError.
pub fn sort_docs(gpa: std.mem.Allocator, arena: std.mem.Allocator, docs: []*const bson.Document, keys: []const SortKey) QueryError!void {
pub fn sort_docs(arena: std.mem.Allocator, docs: []*const bson.Document, keys: []const SortKey) QueryError!void {
if (keys.len == 0 or docs.len < 2) return;
const SortedDoc = struct {
@@ -583,14 +588,13 @@ pub fn sort_docs(gpa: std.mem.Allocator, arena: std.mem.Allocator, docs: []*cons
std.mem.sort(SortedDoc, entries, Ctx{ .keys = keys }, Ctx.lessThan);
for (entries, 0..) |e, i| docs[i] = e.doc;
_ = gpa;
}
// ---------------------------------------------------------------------------
// Projection
// ---------------------------------------------------------------------------
pub const ProjectionError = error{ OutOfMemory, InvalidProjection };
pub const ProjectionError = std.mem.Allocator.Error;
/// Apply a projection document, writing resulting pairs into `out` (which
/// should use the caller's arena so strings are owned).
@@ -614,7 +618,7 @@ pub fn project(arena: std.mem.Allocator, doc: *const bson.Document, proj: *const
}
if (include_id) {
if (bson.get_pair(doc.pairs, "_id")) |idv| {
try out.append(arena, .{ .key = try arena.dupe(u8, "_id"), .value = try bson.copy_value(arena, idv) });
try out.append(arena, .{ .key = "_id", .value = try bson.copy_value(arena, idv) });
}
}
for (proj.pairs) |p| {
@@ -659,10 +663,7 @@ fn exclude_doc(arena: std.mem.Allocator, pairs: []const bson.Pair, proj: *const
}
fn is_excluded(proj: *const bson.Document, key: []const u8) bool {
for (proj.pairs) |pp| {
if (std.mem.eql(u8, pp.key, key)) return true;
}
return false;
return bson.get_pair(proj.pairs, key) != null;
}
fn has_deeper_exclusion(proj: *const bson.Document, key: []const u8) bool {
@@ -875,17 +876,17 @@ test "sort compares by BSON order" {
var docs = [_]*const bson.Document{ &b, &a };
const asc = [_]SortKey{.{ .path = "n", .descending = false }};
try sort_docs(testing.allocator, arena.allocator(), &docs, &asc);
try sort_docs(arena.allocator(), &docs, &asc);
try testing.expect(docs[0] == &a);
try testing.expect(docs[1] == &b);
var docs2 = [_]*const bson.Document{ &a, &b };
const desc = [_]SortKey{.{ .path = "n", .descending = true }};
try sort_docs(testing.allocator, arena.allocator(), &docs2, &desc);
try sort_docs(arena.allocator(), &docs2, &desc);
try testing.expect(docs2[0] == &b);
const missing = [_]SortKey{.{ .path = "zz", .descending = false }};
try sort_docs(testing.allocator, arena.allocator(), &docs2, &missing);
try sort_docs(arena.allocator(), &docs2, &missing);
try testing.expect(docs2[0] == &b); // stable-ish: order untouched by missing key
}