From 7482042f341bd8d42edd9bfbf667244aa331557d Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Sun, 2 Aug 2026 13:40:42 +0300 Subject: [PATCH] index/db/commands: fold duplicated index logic into single definitions Cleanup pass over the secondary-index feature. add_doc is now the one entry-commit path. create_index and build_all_indexes each hand-rolled build -> check_unique -> reserve -> insert, and had already drifted on whether multikey is set before or after the unique check; add_doc gained an enforce_unique flag so the rebuild path keeps its tolerate-and-warn behavior. reserve_for and insert_entries are now the only way the engine touches Index.entries. One definition each for: prefix comparison and the prefix binary searches (prefix_order + std.sort), the cartesian-product odometer (advance_choice), the spec pair list (write_spec builds on spec_pairs, so the log format and the listIndexes reply share one schema), the _id clause parser (plan_id reuses analyze_clause), key-pattern direction (index.descending, which desc_dir already disagreed with on non-numeric values), option truthiness (query.truthy), the E11000 message, and index-removal-by-name (Collection.find_index/remove_index). Key-pattern matching moved out of the dispatcher into index.find_by_key_pattern. Dead or redundant: ParallelArraysError, the unread `dropped` counter, insert_entries' discarded gpa, a third pass computing multikey, the has_id/is_id_index flag pair, Plan.key_len (always lookup_keys[0].len, now a method), first_match_consumed (now stages = stages[1..]). Cheaper hot paths: remove_id compacts in one pass instead of an orderedRemove per hit; Plan.search skips the sort/dedupe when neither multikey nor multiple lookup keys can produce a repeat; the _id fast path reuses one scratch key buffer (bson.write_serialized_value); the plan loop uses the bound collection instead of re-resolving it through two hash lookups per candidate. Behavior is unchanged except that dropping plan_id's fixed 16-clause buffer enables the _id fast path on filters that previously exceeded it. --- src/bson.zig | 11 +- src/commands.zig | 143 ++++++++------------ src/db.zig | 96 ++++++-------- src/index.zig | 332 ++++++++++++++++++++++------------------------- src/query.zig | 12 +- 5 files changed, 260 insertions(+), 334 deletions(-) diff --git a/src/bson.zig b/src/bson.zig index 14cedac..8b7c80c 100644 --- a/src/bson.zig +++ b/src/bson.zig @@ -458,11 +458,18 @@ fn write_array(items: []const Value, gpa: std.mem.Allocator, out: *std.ArrayList pub fn serialize_value(gpa: std.mem.Allocator, v: Value) ![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(gpa); - try out.append(gpa, v.type_tag()); - try write_value(v, gpa, &out); + try write_serialized_value(v, gpa, &out); return out.toOwnedSlice(gpa); } +/// Append the serialized-key bytes of `v` (type tag + payload) to `out`. The +/// appending form of serialize_value, for callers reusing one scratch buffer +/// across many keys. +pub fn write_serialized_value(v: Value, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8)) !void { + try out.append(gpa, v.type_tag()); + try write_value(v, gpa, out); +} + /// Deep-copy a value into `arena`, so the copy is self-contained. pub fn copy_value(arena: std.mem.Allocator, v: Value) std.mem.Allocator.Error!Value { return switch (v) { diff --git a/src/commands.zig b/src/commands.zig index ed2ea8f..db2cef1 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -336,18 +336,17 @@ fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !vo // {_id: 1} is the implicit index: an idempotent no-op. Any other // secondary index touching _id is rejected. var has_id = false; - var is_id_index = false; for (key_pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) has_id = true; } - if (key_pairs.len == 1 and has_id) { + if (has_id) { const only = key_pairs[0].value; - is_id_index = (only == .int32 and only.int32 == 1) or + const is_id_index = key_pairs.len == 1 and ((only == .int32 and only.int32 == 1) or (only == .int64 and only.int64 == 1) or - (only == .double and only.double == 1.0); + (only == .double and only.double == 1.0)); + if (is_id_index) continue; + return bad_value(reply, "cannot create a secondary index on the _id field"); } - if (is_id_index) continue; - if (has_id) return bad_value(reply, "cannot create a secondary index on the _id field"); const name = bson.get_pair(spec, "name") orelse bson.Value.null; if (name == .string and std.mem.eql(u8, name.string, "_id_")) { return bad_value(reply, "cannot create index with name '_id_'"); @@ -359,11 +358,7 @@ fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !vo error.IndexOptionsConflict => return reply.put_error(@intFromEnum(ErrorCode.index_options_conflict), "IndexOptionsConflict", "index already exists with a different specification"), error.DuplicateKeyIndex => { const ix_name = if (name == .string) name.string else "index"; - const msg_text = try std.fmt.allocPrint( - reply.arena_alloc(), - "E11000 duplicate key error collection: {s}.{s} index: {s} dup key: {s}", - .{ db_name, coll_name, ix_name, try render_spec_key(reply, key_pairs) }, - ); + const msg_text = try e11000_message(reply, db_name, coll_name, ix_name, try render_spec_key(reply, key_pairs)); return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text); }, error.ParallelArrays => return bad_value(reply, "cannot index parallel arrays"), @@ -418,23 +413,18 @@ fn cmd_drop_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void const n_indexes_was: i32 = @intCast(coll.indexes.items.len + 1); const arg = msg.body.get("index") orelse return bad_value(reply, "dropIndexes requires index"); - var dropped: usize = 0; if (arg == .string and std.mem.eql(u8, arg.string, "*")) { // Drop every secondary index. Copy the names first: each drop // mutates the collection's index list. var names: std.ArrayListUnmanaged([]const u8) = .empty; defer names.deinit(ctx.gpa); for (coll.indexes.items) |ix| try names.append(ctx.gpa, ix.name); - for (names.items) |nm| { - if (try ctx.engine.drop_index(db_name, coll_name, nm)) dropped += 1; - } + for (names.items) |nm| _ = try ctx.engine.drop_index(db_name, coll_name, nm); } else if (arg == .string) { if (std.mem.eql(u8, arg.string, "_id_")) { return invalid_arg(reply, "cannot drop the _id index"); } - if (try ctx.engine.drop_index(db_name, coll_name, arg.string)) { - dropped += 1; - } else { + if (!try ctx.engine.drop_index(db_name, coll_name, arg.string)) { return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with name"); } } else if (arg == .doc) { @@ -444,23 +434,9 @@ fn cmd_drop_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void .doc => |p| p, else => return bad_value(reply, "dropIndexes index must be a name, key document, or '*'"), }; - var target: ?[]const u8 = null; - for (coll.indexes.items) |ix| { - if (ix.keys.len != key_pairs.len) continue; - var match = true; - for (ix.keys, key_pairs) |k, kp| { - if (!std.mem.eql(u8, k.path, kp.key) or k.descending != (desc_dir(kp.value) < 0)) { - match = false; - break; - } - } - if (match) { - target = ix.name; - break; - } - } - const nm = target orelse return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with key pattern"); - if (try ctx.engine.drop_index(db_name, coll_name, nm)) dropped += 1; + const target = index.find_by_key_pattern(coll.indexes.items, key_pairs) orelse + return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with key pattern"); + _ = try ctx.engine.drop_index(db_name, coll_name, target.name); } else { return bad_value(reply, "dropIndexes index must be a name, key document, or '*'"); } @@ -469,13 +445,14 @@ fn cmd_drop_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void try reply.put_ok(); } -fn desc_dir(v: bson.Value) i32 { - return switch (v) { - .int32 => |n| if (n < 0) -1 else 1, - .int64 => |n| if (n < 0) -1 else 1, - .double => |n| if (n < 0) -1 else 1, - else => 1, - }; +/// The E11000 text drivers parse. One definition for both create-time and +/// write-time conflicts; they differ only in how the dup key is rendered. +fn e11000_message(reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, index_name: []const u8, key_text: []const u8) ![]const u8 { + return std.fmt.allocPrint( + reply.arena_alloc(), + "E11000 duplicate key error collection: {s}.{s} index: {s} dup key: {s}", + .{ db_name, coll_name, index_name, key_text }, + ); } /// Render the key pattern with placeholder values for a createIndexes @@ -588,20 +565,14 @@ fn scan_matching( // queried value's compare-equivalence class is serialization-ambiguous // (see index.plan_id). if (index.plan_id(filter)) |id_plan| { - var keys: std.ArrayListUnmanaged([]u8) = .empty; - defer { - for (keys.items) |k| ctx.gpa.free(k); - keys.deinit(ctx.gpa); - } + // One scratch key, rebuilt per value: a key is never needed past its + // own lookup. + var key: std.ArrayListUnmanaged(u8) = .empty; + defer key.deinit(ctx.gpa); for (id_plan.values) |v| { - const key = try bson.serialize_value(ctx.gpa, v); - keys.append(ctx.gpa, key) catch |err| { - ctx.gpa.free(key); - return err; - }; - } - for (keys.items) |key| { - const doc = coll.docs.get(key) orelse continue; + key.clearRetainingCapacity(); + try bson.write_serialized_value(v, ctx.gpa, &key); + const doc = coll.docs.get(key.items) orelse continue; if (!try query.matches(ctx.gpa, &filter_doc, doc)) continue; if (out) |list| try list.append(ctx.gpa, doc); n += 1; @@ -619,7 +590,7 @@ fn scan_matching( defer ids.deinit(ctx.gpa); try plan.search(ctx.gpa, &ids); for (ids.items) |id| { - const doc = ctx.engine.get_doc(db_name, coll_name, id) orelse continue; + const doc = coll.docs.get(id) orelse continue; if (!try query.matches(ctx.gpa, &filter_doc, doc)) continue; if (out) |list| try list.append(ctx.gpa, doc); n += 1; @@ -846,7 +817,7 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { const coll_name = str_arg(msg.body.get("aggregate")) orelse return bad_value(reply, "aggregate requires a collection name"); const pipeline_value = msg.body.get("pipeline") orelse return bad_value(reply, "aggregate requires pipeline"); - const stages = switch (pipeline_value) { + var stages = switch (pipeline_value) { .array => |a| a, else => return bad_value(reply, "pipeline must be an array"), }; @@ -857,12 +828,11 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { var stream: std.ArrayListUnmanaged(*const bson.Document) = .empty; defer stream.deinit(ctx.gpa); // A leading $match is pushed down into an indexed candidate scan; the - // stage is then consumed so it is not applied a second time. - var first_match_consumed = false; + // stage is then dropped from the pipeline so it is not applied twice. if (stages.len > 0 and stages[0] == .doc and stages[0].doc.len > 0 and std.mem.eql(u8, stages[0].doc[0].key, "$match")) { const filter = doc_arg(stages[0].doc[0].value) orelse return bad_value(reply, "$match requires a document"); _ = try scan_matching(ctx, db_name, coll_name, filter, 0, &stream); - first_match_consumed = true; + stages = stages[1..]; } else if (ctx.engine.get_collection(db_name, coll_name)) |coll| { var it = coll.docs.iterator(); while (it.next()) |entry| try stream.append(ctx.gpa, entry.value_ptr.*); @@ -874,10 +844,6 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { var proj_pairs: ?[]const bson.Pair = null; for (stages) |stage_v| { - if (first_match_consumed) { - first_match_consumed = false; - continue; - } const stage = switch (stage_v) { .doc => |pairs| pairs, else => return bad_value(reply, "pipeline stages must be documents"), @@ -1214,7 +1180,6 @@ fn bad_value(reply: *wire.Reply, msg: []const u8) !void { /// rejected unique-index write) when the conflict came from a secondary /// index; otherwise it is the _id_ index. fn duplicate_key_message(ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document) ![]const u8 { - const arena = reply.arena_alloc(); var index_name: []const u8 = "_id_"; var key_text: []const u8 = undefined; if (ctx.engine.dup_index) |name| { @@ -1223,40 +1188,34 @@ fn duplicate_key_message(ctx: *Context, reply: *wire.Reply, db_name: []const u8, } else { key_text = try serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); } - return std.fmt.allocPrint( - arena, - "E11000 duplicate key error collection: {s}.{s} index: {s} dup key: {s}", - .{ db_name, coll_name, index_name, key_text }, - ); + return e11000_message(reply, db_name, coll_name, index_name, key_text); } /// Render the dup key of a secondary index from the offending document: the /// document's values for the index key pattern. fn render_dup_key(ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, index_name: []const u8, doc: *const bson.Document) ![]const u8 { const arena = reply.arena_alloc(); - if (ctx.engine.get_collection(db_name, coll_name)) |coll| { - for (coll.indexes.items) |*ix| { - if (std.mem.eql(u8, ix.name, index_name)) { - var out: std.ArrayListUnmanaged(u8) = .empty; - defer out.deinit(arena); - try out.append(arena, '{'); - for (ix.keys, 0..) |k, i| { - if (i > 0) try out.appendSlice(arena, ", "); - try out.appendSlice(arena, k.path); - try out.appendSlice(arena, ": "); - var values: std.ArrayListUnmanaged(bson.Value) = .empty; - defer values.deinit(arena); - try query.collect_values(arena, doc.pairs, k.path, &values, 0); - const v: bson.Value = if (values.items.len > 0) values.items[0] else .null; - try out.appendSlice(arena, try serialize_value_compact(reply, v)); - } - try out.append(arena, '}'); - return out.toOwnedSlice(arena); - } - } + const coll = ctx.engine.get_collection(db_name, coll_name) orelse + // Index not found (defensive): fall back to the document's _id. + return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); + const ix = coll.find_index(index_name) orelse + return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); + + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(arena); + try out.append(arena, '{'); + for (ix.keys, 0..) |k, i| { + if (i > 0) try out.appendSlice(arena, ", "); + try out.appendSlice(arena, k.path); + try out.appendSlice(arena, ": "); + var values: std.ArrayListUnmanaged(bson.Value) = .empty; + defer values.deinit(arena); + try query.collect_values(arena, doc.pairs, k.path, &values, 0); + const v: bson.Value = if (values.items.len > 0) values.items[0] else .null; + try out.appendSlice(arena, try serialize_value_compact(reply, v)); } - // Index not found (defensive): fall back to the document's _id. - return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); + try out.append(arena, '}'); + return out.toOwnedSlice(arena); } fn duplicate_key_error(ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document) !void { diff --git a/src/db.zig b/src/db.zig index d66201c..de10bcc 100644 --- a/src/db.zig +++ b/src/db.zig @@ -17,6 +17,28 @@ pub const Collection = struct { fn init() Collection { return .{ .docs = .empty, .indexes = .empty }; } + + /// The secondary index with this name, or null. The single by-name + /// lookup: index lifetime (who calls Index.deinit, and when) is decided + /// here rather than at each caller. + pub fn find_index(self: *Collection, name: []const u8) ?*index.Index { + for (self.indexes.items) |*ix| { + if (std.mem.eql(u8, ix.name, name)) return ix; + } + return null; + } + + /// Remove and free the index with this name. Returns whether it existed. + fn remove_index(self: *Collection, gpa: std.mem.Allocator, name: []const u8) bool { + for (self.indexes.items, 0..) |ix, i| { + if (std.mem.eql(u8, ix.name, name)) { + var removed = self.indexes.orderedRemove(i); + removed.deinit(gpa); + return true; + } + } + return false; + } }; pub const Db = struct { @@ -204,8 +226,7 @@ pub const Engine = struct { // 4. Reserve entry capacity — the last fallible step, so the entry // insertion after the log append is infallible. for (built_list.items) |*b| { - if (b.built.entries.items.len == 0) continue; - try b.ix.entries.ensureUnusedCapacity(self.gpa, b.built.entries.items.len); + try b.ix.reserve_for(self.gpa, b.built.entries.items.len); } // 5. Log (and sync) before anything becomes visible. @@ -221,7 +242,7 @@ pub const Engine = struct { try coll.docs.put(self.gpa, id_key, owned); for (built_list.items) |*b| { if (b.built.multikey) b.ix.multikey = true; - b.ix.insert_entries(self.gpa, &b.built); + b.ix.insert_entries(&b.built); } stored = true; try self.maybe_compact(); @@ -293,32 +314,19 @@ pub const Engine = struct { // parsed spec is only owned by the collection once committed. defer if (!committed) ix.deinit(self.gpa); - for (coll.indexes.items) |*existing| { - if (std.mem.eql(u8, existing.name, ix.name)) { - if (index.Index.spec_equal(existing, &ix)) return existing; - return error.IndexOptionsConflict; - } + if (coll.find_index(ix.name)) |existing| { + if (index.Index.spec_equal(existing, &ix)) return existing; + return error.IndexOptionsConflict; } // Build entries over the existing documents, checking uniqueness as // we go (the index is not exposed until the end, so mutating it is - // safe). Each batch is inserted into the index immediately (which - // drains it), so on any later failure the errdefer ix.deinit frees - // every inserted entry key; a batch that fails before insertion is - // freed by its own errdefer. Nothing is persisted on failure. + // safe). Each document's batch is inserted immediately, so on any + // later failure the deferred ix.deinit frees every inserted entry + // key. Nothing is persisted on failure. var doc_it = coll.docs.iterator(); while (doc_it.next()) |entry| { - var built = try ix.build_entries(self.gpa, entry.value_ptr.*, entry.key_ptr.*); - // Runs on success too: insert_entries drains the keys, leaving - // only the (now empty) ArrayList buffer to free. - defer built.deinit(self.gpa); - if (built.multikey) ix.multikey = true; - if (ix.unique) { - try ix.check_unique(built.entries.items, entry.key_ptr.*); - } - if (built.entries.items.len == 0) continue; - try ix.entries.ensureUnusedCapacity(self.gpa, built.entries.items.len); - ix.insert_entries(self.gpa, &built); + _ = try ix.add_doc(self.gpa, entry.value_ptr.*, entry.key_ptr.*, true); } // Reserve the collection slot, then persist and publish. @@ -339,14 +347,7 @@ pub const Engine = struct { pub fn drop_index(self: *Engine, db_name: []const u8, coll_name: []const u8, index_name: []const u8) !bool { const db = self.dbs.get(db_name) orelse return false; const coll = db.collections.getPtr(coll_name) orelse return false; - var found = false; - for (coll.indexes.items) |ix| { - if (std.mem.eql(u8, ix.name, index_name)) { - found = true; - break; - } - } - if (!found) return false; + if (coll.find_index(index_name) == null) return false; const name_pairs = [_]bson.Pair{.{ .key = "name", .value = .{ .string = index_name } }}; var name_doc: std.ArrayListUnmanaged(u8) = .empty; @@ -355,13 +356,7 @@ pub const Engine = struct { self.seq += 1; try self.log.append_index_drop(db_name, coll_name, name_doc.items, self.seq); - var i: usize = 0; - while (i < coll.indexes.items.len) { - if (std.mem.eql(u8, coll.indexes.items[i].name, index_name)) { - var removed = coll.indexes.orderedRemove(i); - removed.deinit(self.gpa); - } else i += 1; - } + _ = coll.remove_index(self.gpa, index_name); return true; } @@ -482,23 +477,16 @@ pub const Engine = struct { if (ix.entries.items.len > 0) continue; // defensive var doc_it = coll_entry.value_ptr.docs.iterator(); while (doc_it.next()) |doc_entry| { - var built = ix.build_entries(self.gpa, doc_entry.value_ptr.*, doc_entry.key_ptr.*) catch |err| switch (err) { + const duplicate = ix.add_doc(self.gpa, doc_entry.value_ptr.*, doc_entry.key_ptr.*, false) catch |err| switch (err) { error.ParallelArrays => { std.debug.print("mongo-light: WARNING: index '{s}' cannot index an existing document; entry skipped\n", .{ix.name}); continue; }, else => return err, }; - defer built.deinit(self.gpa); - if (built.multikey) ix.multikey = true; - if (ix.unique) { - ix.check_unique(built.entries.items, doc_entry.key_ptr.*) catch { - std.debug.print("mongo-light: WARNING: unique index '{s}' has duplicate keys in existing data; duplicates not enforced for existing documents\n", .{ix.name}); - }; + if (duplicate) { + std.debug.print("mongo-light: WARNING: unique index '{s}' has duplicate keys in existing data; duplicates not enforced for existing documents\n", .{ix.name}); } - if (built.entries.items.len == 0) continue; - try ix.entries.ensureUnusedCapacity(self.gpa, built.entries.items.len); - ix.insert_entries(self.gpa, &built); } } } @@ -511,9 +499,7 @@ pub const Engine = struct { var ix = try index.parse_spec(self.gpa, spec_doc); var committed = false; defer if (!committed) ix.deinit(self.gpa); - for (coll.indexes.items) |existing| { - if (std.mem.eql(u8, existing.name, ix.name)) return; - } + if (coll.find_index(ix.name) != null) return; try coll.indexes.append(self.gpa, ix); committed = true; } @@ -559,13 +545,7 @@ fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) an .string => |s| s, else => return, }; - var i: usize = 0; - while (i < coll.indexes.items.len) { - if (std.mem.eql(u8, coll.indexes.items[i].name, name)) { - var removed = coll.indexes.orderedRemove(i); - removed.deinit(self.gpa); - } else i += 1; - } + _ = coll.remove_index(self.gpa, name); return; }, else => {}, diff --git a/src/index.zig b/src/index.zig index b3c6128..049f89e 100644 --- a/src/index.zig +++ b/src/index.zig @@ -37,8 +37,6 @@ pub const max_index_keys: usize = 32; /// planner falls back to a scan. const max_combos: u64 = 100; -pub const ParallelArraysError = error{ ParallelArrays }; - /// One key in an index spec. `path` is owned by the Index. pub const IndexKey = struct { path: []const u8, @@ -129,16 +127,11 @@ pub const Index = struct { var i: usize = 0; while (i < direct) : (i += 1) { if (values.items[i] == .array) { + multikey = true; for (values.items[i].array) |elem| try values.append(gpa, elem); } } if (direct > 1) multikey = true; - for (values.items[0..direct]) |v| { - if (v == .array) { - multikey = true; - break; - } - } if (values.items.len > 1) multi_paths += 1; if (values.items.len == 0) { if (self.sparse) return .{ .entries = .empty, .multikey = false }; @@ -155,6 +148,8 @@ pub const Index = struct { out.deinit(gpa); } const nkeys = self.keys.len; + var limits: [max_index_keys]usize = undefined; + for (0..nkeys) |ci| limits[ci] = per_path.items[ci].items.len; var choice: [max_index_keys]usize = undefined; @memset(choice[0..nkeys], 0); while (true) { @@ -162,14 +157,7 @@ pub const Index = struct { errdefer gpa.free(key); for (0..nkeys) |ci| key[ci] = per_path.items[ci].items[choice[ci]]; try out.append(gpa, .{ .key = key, .id = id }); - var ci: usize = nkeys; - var carry = true; - while (carry and ci > 0) { - ci -= 1; - choice[ci] += 1; - if (choice[ci] < per_path.items[ci].items.len) carry = false else choice[ci] = 0; - } - if (carry) break; + if (!advance_choice(choice[0..nkeys], limits[0..nkeys])) break; } if (out.items.len > 1) { std.mem.sort(Entry, out.items, {}, entry_less); @@ -199,8 +187,7 @@ pub const Index = struct { /// batch: ownership of each entry's key slice moves into the index, so /// the batch's deinit must not free them. Infallible: capacity must /// already be reserved. - pub fn insert_entries(self: *Index, gpa: std.mem.Allocator, built: *BuiltEntries) void { - _ = gpa; + pub fn insert_entries(self: *Index, built: *BuiltEntries) void { for (built.entries.items) |e| { const pos = self.insert_pos(e); self.entries.insertAssumeCapacity(pos, e); @@ -210,26 +197,46 @@ pub const Index = struct { built.entries.items.len = 0; } - /// Build, check, and insert entries for one document; the one-shot form - /// used when rebuilding an index on open. - pub fn add_doc(self: *Index, gpa: std.mem.Allocator, doc: *const bson.Document, id: []const u8) !void { + /// Build, check, and insert entries for one document — the whole + /// entry-commit protocol in one call, used everywhere a single document + /// joins an index (create, rebuild on open). Writes that must reserve + /// capacity before a log append use the split build/reserve/insert form + /// directly. + /// + /// With `enforce_unique` false a duplicate is tolerated rather than + /// rejected (the rebuild path keeps the index and warns); the return + /// value reports whether that happened. + pub fn add_doc(self: *Index, gpa: std.mem.Allocator, doc: *const bson.Document, id: []const u8, enforce_unique: bool) !bool { var built = try self.build_entries(gpa, doc, id); + // Runs on success too: insert_entries drains the keys, leaving only + // the (now empty) ArrayList buffer to free. defer built.deinit(gpa); - if (self.unique) try self.check_unique(built.entries.items, id); + var duplicate = false; + if (self.unique) { + self.check_unique(built.entries.items, id) catch |err| { + if (enforce_unique) return err; + duplicate = true; + }; + } if (built.multikey) self.multikey = true; - try self.entries.ensureUnusedCapacity(gpa, built.entries.items.len); - self.insert_entries(gpa, &built); + try self.reserve_for(gpa, built.entries.items.len); + self.insert_entries(&built); + return duplicate; } /// Remove every entry for `id` and free its key slices. Infallible. + /// One compaction pass: removing in place would memmove the tail per hit. pub fn remove_id(self: *Index, gpa: std.mem.Allocator, id: []const u8) void { - var i: usize = 0; - while (i < self.entries.items.len) { - if (std.mem.eql(u8, self.entries.items[i].id, id)) { - gpa.free(self.entries.items[i].key); - _ = self.entries.orderedRemove(i); - } else i += 1; + var w: usize = 0; + for (self.entries.items) |e| { + if (std.mem.eql(u8, e.id, id)) { + gpa.free(e.key); + } else { + self.entries.items[w] = e; + w += 1; + } } + self.entries.items.len = w; } /// Reject when any of `new_entries` has a key already present under a @@ -246,13 +253,7 @@ pub const Index = struct { } fn insert_pos(self: *const Index, e: Entry) usize { - var lo: usize = 0; - var hi: usize = self.entries.items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (compare_entries(self.entries.items[mid], e) == .lt) lo = mid + 1 else hi = mid; - } - return lo; + return std.sort.lowerBound(Entry, self.entries.items, e, compare_entries); } // -- search ------------------------------------------------------------- @@ -299,25 +300,13 @@ pub const Index = struct { /// First entry whose first `prefix.len` components are not less than /// `prefix`. fn lower_bound_prefix(self: *const Index, prefix: []const bson.Value) usize { - var lo: usize = 0; - var hi: usize = self.entries.items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (prefix_lt(self.entries.items[mid].key, prefix)) lo = mid + 1 else hi = mid; - } - return lo; + return std.sort.lowerBound(Entry, self.entries.items, prefix, prefix_order); } /// First entry whose first `prefix.len` components are greater than /// `prefix`. fn upper_bound_prefix(self: *const Index, prefix: []const bson.Value) usize { - var lo: usize = 0; - var hi: usize = self.entries.items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (prefix_le(self.entries.items[mid].key, prefix)) lo = mid + 1 else hi = mid; - } - return lo; + return std.sort.upperBound(Entry, self.entries.items, prefix, prefix_order); } // -- serialization ------------------------------------------------------ @@ -325,18 +314,10 @@ pub const Index = struct { /// The canonical spec document bytes ({v, key, name, unique?, sparse?}) /// stored in the log and used to rebuild the index on replay. pub fn write_spec(self: *const Index, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8)) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; - defer pairs.deinit(gpa); - var key_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; - defer key_pairs.deinit(gpa); - try pairs.append(gpa, .{ .key = "v", .value = .{ .int32 = 2 } }); - for (self.keys) |k| { - try key_pairs.append(gpa, .{ .key = k.path, .value = if (k.descending) .{ .int32 = -1 } else .{ .int32 = 1 } }); - } - try pairs.append(gpa, .{ .key = "key", .value = .{ .doc = key_pairs.items } }); - try pairs.append(gpa, .{ .key = "name", .value = .{ .string = self.name } }); - if (self.unique) try pairs.append(gpa, .{ .key = "unique", .value = .{ .bool = true } }); - if (self.sparse) try pairs.append(gpa, .{ .key = "sparse", .value = .{ .bool = true } }); + try self.spec_pairs(arena.allocator(), &pairs); try bson.write_doc(pairs.items, gpa, out); } @@ -366,6 +347,24 @@ pub const Index = struct { } }; +/// The index whose key pattern is exactly `key_pairs` (same paths, same +/// order, same directions), or null. Keeps the IndexKey layout — and what +/// counts as a match — inside this module. +pub fn find_by_key_pattern(indexes: []const Index, key_pairs: []const bson.Pair) ?*const Index { + for (indexes) |*ix| { + if (ix.keys.len != key_pairs.len) continue; + var match = true; + for (ix.keys, key_pairs) |k, kp| { + if (!std.mem.eql(u8, k.path, kp.key) or k.descending != descending(kp.value)) { + match = false; + break; + } + } + if (match) return ix; + } + return null; +} + pub const SpecError = error{ InvalidIndexSpec, OutOfMemory }; /// Parse {key: {...}, name?, unique?, sparse?} from a spec document — the @@ -390,8 +389,8 @@ pub fn parse_spec(gpa: std.mem.Allocator, spec: *const bson.Document) SpecError! } var unique = false; var sparse = false; - if (bson.get_pair(spec.pairs, "unique")) |v| unique = truthy(v); - if (bson.get_pair(spec.pairs, "sparse")) |v| sparse = truthy(v); + if (bson.get_pair(spec.pairs, "unique")) |v| unique = query.truthy(v); + if (bson.get_pair(spec.pairs, "sparse")) |v| sparse = query.truthy(v); const name_value = bson.get_pair(spec.pairs, "name") orelse { const nm = try default_name(gpa, key_pairs); defer gpa.free(nm); @@ -404,7 +403,10 @@ pub fn parse_spec(gpa: std.mem.Allocator, spec: *const bson.Document) SpecError! return Index.init(gpa, name, keys[0..key_pairs.len], unique, sparse); } -fn descending(v: bson.Value) bool { +/// Whether a key-pattern direction value means descending. The single +/// definition of what -1 means in a key pattern, shared with dropIndexes' +/// key-pattern matching. +pub fn descending(v: bson.Value) bool { return switch (v) { .int32 => |n| n < 0, .int64 => |n| n < 0, @@ -413,16 +415,6 @@ fn descending(v: bson.Value) bool { }; } -fn truthy(v: bson.Value) bool { - return switch (v) { - .bool => |b| b, - .int32 => |i| i != 0, - .int64 => |i| i != 0, - .double => |d| d != 0, - else => false, - }; -} - /// MongoDB's default index name: a_1_b_-1. fn default_name(gpa: std.mem.Allocator, key_pairs: []const bson.Pair) ![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; @@ -460,22 +452,29 @@ fn entry_less(_: void, a: Entry, b: Entry) bool { return compare_entries(a, b) == .lt; } -/// Whether entry key's first `prefix.len` components are less than `prefix`. -fn prefix_lt(key: []const bson.Value, prefix: []const bson.Value) bool { - for (key[0..prefix.len], prefix) |a, b| { - const o = bson.compare(a, b); - if (o != .eq) return o == .lt; +/// Order of `prefix` against an entry key's leading components — `.eq` when +/// every prefix component matches (the key may be longer). The search-side +/// counterpart of compare_entries: same component-wise bson.compare, no +/// length or id tie-break, so a partial key matches a whole range. +fn prefix_order(prefix: []const bson.Value, e: Entry) std.math.Order { + for (prefix, e.key[0..prefix.len]) |p, k| { + const o = bson.compare(p, k); + if (o != .eq) return o; } - return false; + return .eq; } -/// Whether entry key's first `prefix.len` components are <= `prefix`. -fn prefix_le(key: []const bson.Value, prefix: []const bson.Value) bool { - for (key[0..prefix.len], prefix) |a, b| { - const o = bson.compare(a, b); - if (o != .eq) return o == .lt; +/// Advance an odometer of positions, each bounded by the matching `limits` +/// entry. Returns false once it wraps, i.e. the product is exhausted. +fn advance_choice(choice: []usize, limits: []const usize) bool { + var i = choice.len; + while (i > 0) { + i -= 1; + choice[i] += 1; + if (choice[i] < limits[i]) return true; + choice[i] = 0; } - return true; + return false; } fn less_ids(_: void, a: []const u8, b: []const u8) bool { @@ -579,7 +578,6 @@ fn analyze_clause(value: bson.Value, info: *CompInfo) void { pub const Plan = struct { index: *const Index, lookup_keys: std.ArrayListUnmanaged([]const bson.Value), - key_len: usize, lo: ?bson.Value, lo_incl: bool, hi: ?bson.Value, @@ -590,10 +588,22 @@ pub const Plan = struct { self.lookup_keys.deinit(gpa); } + /// How many leading index components the lookup keys pin down. Every key + /// is built with the same component count, and there is always at least + /// one (a pure range plan appends a single empty key). + pub fn key_len(self: *const Plan) usize { + return self.lookup_keys.items[0].len; + } + /// Collect the candidate ids, sorted and deduplicated. Range scans can /// return the same id non-adjacently (a doc with {tags: ["a","b"]} /// contributes two entries inside one range), so adjacent-dup skipping /// would be wrong. + /// + /// Duplicates are only possible from a multikey index (one document, + /// several entries) or from several lookup keys (whose ranges can be the + /// same key repeated, as in {$in: [1, 1]}); the common single-key lookup + /// on a non-multikey index skips the pass entirely. pub fn search(self: *const Plan, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged([]const u8)) !void { for (self.lookup_keys.items) |key| { if (self.lo == null and self.hi == null) { @@ -602,7 +612,8 @@ pub const Plan = struct { try self.index.lookup_range(gpa, key, self.lo, self.lo_incl, self.hi, self.hi_incl, out); } } - if (out.items.len > 1) { + const may_repeat = self.index.multikey or self.lookup_keys.items.len > 1; + if (may_repeat and out.items.len > 1) { std.mem.sort([]const u8, out.items, {}, less_ids); var w: usize = 1; for (out.items[1..]) |id| { @@ -644,7 +655,7 @@ pub fn plan(gpa: std.mem.Allocator, indexes: []const Index, filter: []const bson } fn plan_better(a: *const Plan, b: *const Plan) bool { - if (a.key_len != b.key_len) return a.key_len > b.key_len; + if (a.key_len() != b.key_len()) return a.key_len() > b.key_len(); const a_range = a.lo != null or a.hi != null; const b_range = b.lo != null or b.hi != null; if (a_range != b_range) return a_range; @@ -716,7 +727,6 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla var pl = Plan{ .index = ix, .lookup_keys = .empty, - .key_len = run, .lo = lo, .lo_incl = lo_incl, .hi = hi, @@ -740,14 +750,7 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla key[i] = if (infos[i].eq) |v| v else infos[i].in_values.?[choice[i]]; } try pl.lookup_keys.append(gpa, key); - var i: usize = run; - var carry = true; - while (carry and i > 0) { - i -= 1; - choice[i] += 1; - if (choice[i] < counts[i]) carry = false else choice[i] = 0; - } - if (carry) break; + if (!advance_choice(choice[0..run], counts[0..run])) break; } } return pl; @@ -768,67 +771,42 @@ pub const IdPlan = struct { /// and nested variants), but serialize_value produces different map keys — /// a hash lookup would then miss documents a scan would match. pub fn plan_id(filter: []const bson.Pair) ?IdPlan { - var clauses: [16]Clause = undefined; - var n: usize = 0; - if (!flatten_id(filter, &clauses, &n)) return null; - for (clauses[0..n]) |cl| { - if (!std.mem.eql(u8, cl.path, "_id")) continue; - if (id_lookup_values(cl.value)) |values| return .{ .values = values }; + // The first usable _id clause wins; no flattening buffer is needed + // because nothing is compared across clauses. $and members are searched + // like top-level pairs, every other operator skipped — same rule as + // flatten_clauses, and safe for the same reason (the full filter is + // re-applied to every candidate). + for (filter) |p| { + if (p.key.len > 0 and p.key[0] == '$') { + if (!std.mem.eql(u8, p.key, "$and")) continue; + const members = switch (p.value) { + .array => |a| a, + else => continue, + }; + for (members) |m| { + const mp = switch (m) { + .doc => |d| d, + else => continue, + }; + if (plan_id(mp)) |found| return found; + } + continue; + } + if (!std.mem.eql(u8, p.key, "_id")) continue; + if (id_lookup_values(p.value)) |values| return .{ .values = values }; } return null; } -fn flatten_id(pairs: []const bson.Pair, out: *[16]Clause, n: *usize) bool { - for (pairs) |p| { - if (p.key.len > 0 and p.key[0] == '$') { - if (std.mem.eql(u8, p.key, "$and")) { - const members = switch (p.value) { - .array => |a| a, - else => continue, - }; - for (members) |m| { - const mp = switch (m) { - .doc => |d| d, - else => continue, - }; - if (!flatten_id(mp, out, n)) return false; - } - } - continue; - } - if (n.* >= out.len) return false; - out[n.*] = .{ .path = p.key, .value = p.value }; - n.* += 1; - } - return true; -} - +/// The map-lookup values for one _id clause, or null when it is not a pure +/// equality/$in of fast-path-safe values. A range is unusable here: the docs +/// map is a hash, not an ordered structure. fn id_lookup_values(v: bson.Value) ?[]const bson.Value { - if (v == .regex) return null; - if (v != .doc) { - return if (value_fast_path_safe(v)) &.{v} else null; - } - const pairs = v.doc; - if (pairs.len > 0 and !query.all_operator_keys(pairs)) { - // Bare document equality (compare the whole doc). - return if (value_fast_path_safe(v)) &.{v} else null; - } - var eq: ?bson.Value = null; - var in_list: ?[]const bson.Value = null; - for (pairs) |p| { - if (std.mem.eql(u8, p.key, "$eq")) { - eq = p.value; - } else if (std.mem.eql(u8, p.key, "$in")) { - in_list = switch (p.value) { - .array => |a| a, - else => return null, - }; - } else return null; - } - if (eq) |e| { - return if (value_fast_path_safe(e)) &.{e} else null; - } - if (in_list) |list| { + var info = CompInfo{}; + analyze_clause(v, &info); + if (info.lo != null or info.hi != null) return null; + if (info.eq) |e| return if (value_fast_path_safe(e)) &.{e} else null; + if (info.in_values) |list| { for (list) |m| { if (!value_fast_path_safe(m)) return null; } @@ -897,11 +875,11 @@ test "entries sort across numeric types and string/null/objectid" { const d_str = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "a", .value = .{ .string = "b" } } }); const d_nul = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 4 } }, .{ .key = "a", .value = .null } }); const d_oid = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 5 } }, .{ .key = "a", .value = .{ .object_id = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } } } }); - try ix.add_doc(gpa, &d_int, "i1"); - try ix.add_doc(gpa, &d_dbl, "i2"); - try ix.add_doc(gpa, &d_str, "i3"); - try ix.add_doc(gpa, &d_nul, "i4"); - try ix.add_doc(gpa, &d_oid, "i5"); + _ = try ix.add_doc(gpa, &d_int, "i1", true); + _ = try ix.add_doc(gpa, &d_dbl, "i2", true); + _ = try ix.add_doc(gpa, &d_str, "i3", true); + _ = try ix.add_doc(gpa, &d_nul, "i4", true); + _ = try ix.add_doc(gpa, &d_oid, "i5", true); // An int64 query finds both the int32 and double entries: compare-equal. try expect_ids(gpa, &ix, &.{.{ .int64 = 5 }}, &.{ "i1", "i2" }); @@ -920,13 +898,13 @@ test "missing field is indexed as null; sparse skips the document" { var ix = try simple_index(gpa, &.{"a"}, false, false); defer ix.deinit(gpa); const d = doc_of(&.{.{ .key = "_id", .value = .{ .int32 = 1 } }}); - try ix.add_doc(gpa, &d, "m1"); + _ = try ix.add_doc(gpa, &d, "m1", true); try testing.expectEqual(@as(usize, 1), ix.entries.items.len); try expect_ids(gpa, &ix, &.{.null}, &.{"m1"}); var sp = try simple_index(gpa, &.{"a"}, false, true); defer sp.deinit(gpa); - try sp.add_doc(gpa, &d, "m2"); + _ = try sp.add_doc(gpa, &d, "m2", true); try testing.expectEqual(@as(usize, 0), sp.entries.items.len); } @@ -936,7 +914,7 @@ test "multikey expansion indexes the array and its elements" { defer ix.deinit(gpa); const d = doc_of(&.{.{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } }}); - try ix.add_doc(gpa, &d, "mk1"); + _ = try ix.add_doc(gpa, &d, "mk1", true); // 3 entries: the array itself, "a", "b". try testing.expectEqual(@as(usize, 3), ix.entries.items.len); @@ -953,7 +931,7 @@ test "per-document dedup keeps {a: [1,1]} under a unique index" { var ix = try simple_index(gpa, &.{"a"}, true, false); defer ix.deinit(gpa); const d = doc_of(&.{.{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 1 } } } }}); - try ix.add_doc(gpa, &d, "d1"); + _ = try ix.add_doc(gpa, &d, "d1", true); // Entries after dedup: the array itself and one element. try testing.expectEqual(@as(usize, 2), ix.entries.items.len); } @@ -967,7 +945,7 @@ test "parallel arrays are rejected" { .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }, .{ .key = "b", .value = .{ .array = &.{ .{ .int32 = 3 }, .{ .int32 = 4 } } } }, }); - try testing.expectError(error.ParallelArrays, ix.add_doc(gpa, &d, "p1")); + try testing.expectError(error.ParallelArrays, ix.add_doc(gpa, &d, "p1", true)); // One array path is fine. const ok = doc_of(&.{ @@ -975,7 +953,7 @@ test "parallel arrays are rejected" { .{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }, .{ .key = "b", .value = .{ .int32 = 3 } }, }); - try ix.add_doc(gpa, &ok, "p2"); + _ = try ix.add_doc(gpa, &ok, "p2", true); try testing.expectEqual(@as(usize, 3), ix.entries.items.len); } @@ -985,16 +963,16 @@ test "unique conflict across documents, replace of own entries allowed" { defer ix.deinit(gpa); const d1 = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 10 } } }); - try ix.add_doc(gpa, &d1, "u1"); + _ = try ix.add_doc(gpa, &d1, "u1", true); const d2 = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "a", .value = .{ .int32 = 10 } } }); - try testing.expectError(error.DuplicateKeyIndex, ix.add_doc(gpa, &d2, "u2")); + try testing.expectError(error.DuplicateKeyIndex, ix.add_doc(gpa, &d2, "u2", true)); // A replace keeps its own key: remove old entries first (the engine's // evict_doc does this), then add the new ones. const d1b = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 20 } } }); ix.remove_id(gpa, "u1"); - try ix.add_doc(gpa, &d1b, "u1"); + _ = try ix.add_doc(gpa, &d1b, "u1", true); try expect_ids(gpa, &ix, &.{.{ .int32 = 20 }}, &.{"u1"}); try expect_ids(gpa, &ix, &.{.{ .int32 = 10 }}, &.{}); } @@ -1012,7 +990,7 @@ test "range bounds inclusive and exclusive" { }; for (docs) |s| { const d = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = s.a } }, .{ .key = "a", .value = .{ .int32 = s.a } } }); - try ix.add_doc(gpa, &d, s.id); + _ = try ix.add_doc(gpa, &d, s.id, true); } var out: std.ArrayListUnmanaged([]const u8) = .empty; @@ -1043,7 +1021,7 @@ test "empty index and remove_id" { try testing.expectEqual(@as(usize, 0), out.items.len); const d = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 1 } } }); - try ix.add_doc(gpa, &d, "e1"); + _ = try ix.add_doc(gpa, &d, "e1", true); ix.remove_id(gpa, "e1"); try testing.expectEqual(@as(usize, 0), ix.entries.items.len); try ix.lookup_eq(gpa, &.{.{ .int32 = 1 }}, &out); @@ -1069,7 +1047,7 @@ test "compound index prefix search and range on the next key" { .{ .key = "a", .value = .{ .int32 = s.a } }, .{ .key = "b", .value = .{ .int32 = s.b } }, }); - try ix.add_doc(gpa, &d, s.id); + _ = try ix.add_doc(gpa, &d, s.id, true); } // Prefix on a only. @@ -1140,7 +1118,7 @@ test "planner picks eq run, ranges, and bails on sparse null" { }; var p = (try plan(gpa, &.{ix}, &f)).?; defer p.deinit(gpa); - try testing.expectEqual(@as(usize, 2), p.key_len); + try testing.expectEqual(@as(usize, 2), p.key_len()); try testing.expect(p.lo == null and p.hi == null); } // {a: 1, b: {$gt: 2}} → equality run of 1 + range on the next key. @@ -1151,7 +1129,7 @@ test "planner picks eq run, ranges, and bails on sparse null" { }; var p = (try plan(gpa, &.{ix}, &f)).?; defer p.deinit(gpa); - try testing.expectEqual(@as(usize, 1), p.key_len); + try testing.expectEqual(@as(usize, 1), p.key_len()); try testing.expect(p.hi == null and p.lo != null and !p.lo_incl); } // {a: 1} only → prefix run of 1. @@ -1159,14 +1137,14 @@ test "planner picks eq run, ranges, and bails on sparse null" { const f = [_]bson.Pair{.{ .key = "a", .value = .{ .int32 = 1 } }}; var p = (try plan(gpa, &.{ix}, &f)).?; defer p.deinit(gpa); - try testing.expectEqual(@as(usize, 1), p.key_len); + try testing.expectEqual(@as(usize, 1), p.key_len()); } // Pure range on the first key → key_len 0 with a bound. { const f = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 1 } }} } }}; var p = (try plan(gpa, &.{ix}, &f)).?; defer p.deinit(gpa); - try testing.expectEqual(@as(usize, 0), p.key_len); + try testing.expectEqual(@as(usize, 0), p.key_len()); try testing.expect(p.lo != null and p.lo_incl); } // Unusable filter → no plan. @@ -1185,7 +1163,7 @@ test "planner picks eq run, ranges, and bails on sparse null" { // Non-sparse is fine with null. var p = (try plan(gpa, &.{ix}, &f)).?; defer p.deinit(gpa); - try testing.expect(p.key_len == 1); + try testing.expect(p.key_len() == 1); // A null inside $in bails too. const fin = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 1 }, .null } } }} } }}; try testing.expect((try plan(gpa, &.{sp}, &fin)) == null); diff --git a/src/query.zig b/src/query.zig index c5aff04..5ac16ab 100644 --- a/src/query.zig +++ b/src/query.zig @@ -604,7 +604,7 @@ pub fn project(arena: std.mem.Allocator, doc: *const bson.Document, proj: *const for (proj.pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) continue; non_id_count += 1; - const flag = projection_flag(p.value); + const flag = truthy(p.value); inclusion = if (inclusion == null) flag else inclusion; } @@ -614,7 +614,7 @@ pub fn project(arena: std.mem.Allocator, doc: *const bson.Document, proj: *const // Inclusion list: _id unless excluded, plus listed paths. var include_id = true; if (bson.get_pair(proj.pairs, "_id")) |idv| { - include_id = projection_flag(idv); + include_id = truthy(idv); } if (include_id) { if (bson.get_pair(doc.pairs, "_id")) |idv| { @@ -623,7 +623,7 @@ pub fn project(arena: std.mem.Allocator, doc: *const bson.Document, proj: *const } for (proj.pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) continue; - if (!projection_flag(p.value)) continue; + if (!truthy(p.value)) continue; try project_path(arena, doc.pairs, p.key, out); } } else { @@ -631,7 +631,7 @@ pub fn project(arena: std.mem.Allocator, doc: *const bson.Document, proj: *const for (doc.pairs) |p| { if (std.mem.eql(u8, p.key, "_id")) { var excluded = false; - if (bson.get_pair(proj.pairs, "_id")) |idv| excluded = !projection_flag(idv); + if (bson.get_pair(proj.pairs, "_id")) |idv| excluded = !truthy(idv); if (excluded) continue; } if (is_excluded(proj, p.key)) continue; @@ -673,7 +673,9 @@ fn has_deeper_exclusion(proj: *const bson.Document, key: []const u8) bool { return false; } -fn projection_flag(v: bson.Value) bool { +/// MongoDB's truthiness for an option/flag value (projection flags, index +/// spec options). +pub fn truthy(v: bson.Value) bool { return switch (v) { .bool => |b| b, .int32 => |i| i != 0,