commands: createIndexes/listIndexes/dropIndexes + planner wiring

Adds the three driver commands, parameterized E11000 messages (engine
dup_index carries the index name into writeErrors), the scan_matching
planner wiring (_id fast path → index plan → scan) and the first-$match
aggregate pushdown. The equivalence test (mixed-type corpus x 27 filters,
non-sparse and sparse indexes) drove out three real bugs: the two-bound
range under-approximation on multikey indexes (fall back to scan), a
dangling single-value option array in the planner, and update-time
unique violations now reporting writeErrors instead of corrupting state.
This commit is contained in:
2026-08-02 12:38:25 +03:00
parent a733fc1993
commit 7f2f7c6977
3 changed files with 750 additions and 34 deletions

View File

@@ -8,6 +8,7 @@ const wire = @import("wire.zig");
const db = @import("db.zig"); const db = @import("db.zig");
const query = @import("query.zig"); const query = @import("query.zig");
const update = @import("update.zig"); const update = @import("update.zig");
const index = @import("index.zig");
pub const Context = struct { pub const Context = struct {
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
@@ -24,11 +25,13 @@ pub const ErrorCode = enum(i32) {
bad_value = 2, bad_value = 2,
invalid_argument = 72, invalid_argument = 72,
namespace_not_found = 26, namespace_not_found = 26,
index_not_found = 27,
duplicate_key = 11000, duplicate_key = 11000,
namespace_exists = 48, namespace_exists = 48,
failed_to_parse = 9, failed_to_parse = 9,
internal_error = 1, internal_error = 1,
invalid_pipeline_operator = 40324, invalid_pipeline_operator = 40324,
index_options_conflict = 85,
}; };
/// Which lock (if any) a command needs on the engine. Contract: only /// Which lock (if any) a command needs on the engine. Contract: only
@@ -72,10 +75,13 @@ const command_table = [_]Command{
.{ .name = "create", .kind = .write, .handler = cmd_create }, .{ .name = "create", .kind = .write, .handler = cmd_create },
.{ .name = "drop", .kind = .write, .handler = cmd_drop }, .{ .name = "drop", .kind = .write, .handler = cmd_drop },
.{ .name = "dropDatabase", .kind = .write, .handler = cmd_drop_database }, .{ .name = "dropDatabase", .kind = .write, .handler = cmd_drop_database },
.{ .name = "createIndexes", .kind = .write, .handler = cmd_create_indexes },
.{ .name = "dropIndexes", .kind = .write, .handler = cmd_drop_indexes },
.{ .name = "insert", .kind = .write, .handler = cmd_insert }, .{ .name = "insert", .kind = .write, .handler = cmd_insert },
.{ .name = "update", .kind = .write, .handler = cmd_update }, .{ .name = "update", .kind = .write, .handler = cmd_update },
.{ .name = "delete", .kind = .write, .handler = cmd_delete }, .{ .name = "delete", .kind = .write, .handler = cmd_delete },
.{ .name = "findAndModify", .kind = .write, .handler = cmd_find_and_modify }, .{ .name = "findAndModify", .kind = .write, .handler = cmd_find_and_modify },
.{ .name = "listIndexes", .kind = .read, .handler = cmd_list_indexes },
}; };
pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
@@ -299,6 +305,195 @@ fn cmd_drop_database(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !voi
try reply.put_ok(); try reply.put_ok();
} }
// ---------------------------------------------------------------------------
// Indexes
// ---------------------------------------------------------------------------
fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
const db_name = msg.db_name() orelse return invalid_arg(reply, "createIndexes requires $db");
const coll_name = str_arg(msg.body.get("createIndexes")) orelse return bad_value(reply, "createIndexes requires a collection name");
const indexes = switch (msg.body.get("indexes") orelse return bad_value(reply, "createIndexes requires indexes")) {
.array => |a| a,
else => return bad_value(reply, "indexes must be an array"),
};
const existed = ctx.engine.get_collection(db_name, coll_name) != null;
const coll = try ctx.engine.get_or_create_collection(db_name, coll_name);
const num_before: i32 = @intCast(coll.indexes.items.len + 1); // + the _id_ index
for (indexes) |spec_v| {
const spec = switch (spec_v) {
.doc => |p| p,
else => return bad_value(reply, "indexes must be documents"),
};
const key_value = bson.get_pair(spec, "key") orelse return bad_value(reply, "index spec requires key");
const key_pairs = switch (key_value) {
.doc => |p| p,
else => return bad_value(reply, "key must be a document"),
};
if (key_pairs.len == 0) return bad_value(reply, "cannot create index with an empty key");
// {_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) {
const only = key_pairs[0].value;
is_id_index = (only == .int32 and only.int32 == 1) or
(only == .int64 and only.int64 == 1) or
(only == .double and only.double == 1.0);
}
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_'");
}
const spec_doc = bson.Document{ .arena = undefined, .pairs = spec };
_ = ctx.engine.create_index(db_name, coll_name, &spec_doc) catch |err| switch (err) {
error.InvalidIndexSpec => return bad_value(reply, "invalid index spec"),
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) },
);
return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text);
},
error.ParallelArrays => return bad_value(reply, "cannot index parallel arrays"),
else => return err,
};
}
try reply.put("createdCollectionAutomatically", .{ .bool = !existed });
try reply.put("numIndexesBefore", .{ .int32 = num_before });
try reply.put("numIndexesAfter", .{ .int32 = @intCast(coll.indexes.items.len + 1) });
try reply.put_ok();
}
fn cmd_list_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
const db_name = msg.db_name() orelse return invalid_arg(reply, "listIndexes requires $db");
const coll_name = str_arg(msg.body.get("listIndexes")) orelse return bad_value(reply, "listIndexes requires a collection name");
const coll = ctx.engine.get_collection(db_name, coll_name) orelse
return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found");
// The _id_ index first, then the secondaries.
const n = coll.indexes.items.len + 1;
const values = try reply.arena_alloc().alloc(bson.Value, n);
const id_pairs = try reply.arena_alloc().alloc(bson.Pair, 2);
id_pairs[0] = .{ .key = "v", .value = .{ .int32 = 2 } };
id_pairs[1] = .{ .key = "key", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} } };
values[0] = .{ .doc = try index_pairs_append(reply, id_pairs, "_id_") };
for (coll.indexes.items, 0..) |*ix, i| {
// The pairs live in the reply arena (freed with it); the values
// array below references them.
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
try ix.spec_pairs(reply.arena_alloc(), &pairs);
values[1 + i] = .{ .doc = pairs.items };
}
try reply.put("cursor", .{ .doc = try cursor_doc(reply, 0, try format_namespace(reply, db_name, coll_name), "firstBatch", values) });
try reply.put_ok();
}
/// The _id_ index entry: {v, key: {_id: 1}, name: "_id_"}.
fn index_pairs_append(reply: *wire.Reply, pairs: []const bson.Pair, name: []const u8) ![]const bson.Pair {
const arena = reply.arena_alloc();
const with_name = try arena.alloc(bson.Pair, pairs.len + 1);
@memcpy(with_name[0..pairs.len], pairs);
with_name[pairs.len] = .{ .key = "name", .value = .{ .string = try arena.dupe(u8, name) } };
return with_name;
}
fn cmd_drop_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
const db_name = msg.db_name() orelse return invalid_arg(reply, "dropIndexes requires $db");
const coll_name = str_arg(msg.body.get("dropIndexes")) orelse return bad_value(reply, "dropIndexes requires a collection name");
const coll = ctx.engine.get_collection(db_name, coll_name) orelse
return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found");
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;
}
} 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 {
return reply.put_error(@intFromEnum(ErrorCode.index_not_found), "IndexNotFound", "index not found with name");
}
} else if (arg == .doc) {
// A key document: drop the index with a matching key pattern.
const key_value = bson.get_pair(arg.doc, "key") orelse bson.Value{ .doc = arg.doc };
const key_pairs = switch (key_value) {
.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;
} else {
return bad_value(reply, "dropIndexes index must be a name, key document, or '*'");
}
try reply.put("nIndexesWas", .{ .int32 = n_indexes_was });
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,
};
}
/// Render the key pattern with placeholder values for a createIndexes
/// duplicate-key error (no specific document is involved).
fn render_spec_key(reply: *wire.Reply, key_pairs: []const bson.Pair) ![]const u8 {
const arena = reply.arena_alloc();
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(arena);
try out.append(arena, '{');
for (key_pairs, 0..) |p, i| {
if (i > 0) try out.appendSlice(arena, ", ");
try out.appendSlice(arena, p.key);
try out.appendSlice(arena, ": ?");
}
try out.append(arena, '}');
return out.toOwnedSlice(arena);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CRUD // CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -318,11 +513,11 @@ fn cmd_insert(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
inserted += 1; inserted += 1;
} else |err| { } else |err| {
switch (err) { switch (err) {
error.DuplicateKey => { error.DuplicateKey, error.DuplicateKeyIndex => {
const e = try reply.arena_alloc().alloc(bson.Pair, 3); const e = try reply.arena_alloc().alloc(bson.Pair, 3);
e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(i) } }; e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(i) } };
e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.duplicate_key) } }; e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.duplicate_key) } };
e[2] = .{ .key = "errmsg", .value = .{ .string = try duplicate_key_message(reply, db_name, coll_name, doc) } }; e[2] = .{ .key = "errmsg", .value = .{ .string = try duplicate_key_message(ctx, reply, db_name, coll_name, doc) } };
try write_errors.append(reply.arena_alloc(), .{ .doc = e }); try write_errors.append(reply.arena_alloc(), .{ .doc = e });
}, },
else => return err, else => return err,
@@ -370,7 +565,13 @@ fn cmd_find(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
/// Collect the documents in `db_name.coll_name` matching `filter`, stopping /// Collect the documents in `db_name.coll_name` matching `filter`, stopping
/// after `limit` matches (0 = unlimited). Returns the number matched; `out` /// after `limit` matches (0 = unlimited). Returns the number matched; `out`
/// may be null when only the count is wanted. Every command that scans a /// may be null when only the count is wanted. Every command that scans a
/// collection goes through here, so an index would only need one call site. /// collection goes through here, so an index only needs this one call site.
///
/// Candidate generation order: the _id_ fast path (docs map lookup), then a
/// secondary-index plan, then a plain scan. Every candidate is re-checked
/// with the unchanged filter, so an index that over-approximates is merely
/// slow — never wrong. With no index created and no usable _id clause, the
/// plain-scan path is the only one reached.
fn scan_matching( fn scan_matching(
ctx: *Context, ctx: *Context,
db_name: []const u8, db_name: []const u8,
@@ -382,6 +583,51 @@ fn scan_matching(
const coll = ctx.engine.get_collection(db_name, coll_name) orelse return 0; const coll = ctx.engine.get_collection(db_name, coll_name) orelse return 0;
const filter_doc = bson.Document{ .arena = undefined, .pairs = filter }; const filter_doc = bson.Document{ .arena = undefined, .pairs = filter };
var n: usize = 0; var n: usize = 0;
// _id_ fast path: the docs map is the _id index. Skipped when the
// 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);
}
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;
if (!try query.matches(ctx.gpa, &filter_doc, doc)) continue;
if (out) |list| try list.append(ctx.gpa, doc);
n += 1;
if (limit != 0 and n >= limit) break;
}
return n;
}
// Secondary-index plan: candidates in index order, re-filtered. The
// returned ids alias the docs map keys, valid under the read lock.
if (try index.plan(ctx.gpa, coll.indexes.items, filter)) |p| {
var plan = p;
defer plan.deinit(ctx.gpa);
var ids: std.ArrayListUnmanaged([]const u8) = .empty;
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;
if (!try query.matches(ctx.gpa, &filter_doc, doc)) continue;
if (out) |list| try list.append(ctx.gpa, doc);
n += 1;
if (limit != 0 and n >= limit) break;
}
return n;
}
var it = coll.docs.iterator(); var it = coll.docs.iterator();
while (it.next()) |entry| { while (it.next()) |entry| {
if (!try query.matches(ctx.gpa, &filter_doc, entry.value_ptr.*)) continue; if (!try query.matches(ctx.gpa, &filter_doc, entry.value_ptr.*)) continue;
@@ -421,6 +667,8 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
var n_modified: i64 = 0; var n_modified: i64 = 0;
var upserted: std.ArrayListUnmanaged(bson.Pair) = .empty; var upserted: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer upserted.deinit(reply.arena_alloc()); defer upserted.deinit(reply.arena_alloc());
var write_errors: std.ArrayListUnmanaged(bson.Value) = .empty;
defer write_errors.deinit(reply.arena_alloc());
for (specs, 0..) |*spec, si| { for (specs, 0..) |*spec, si| {
const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "update spec requires q"); const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "update spec requires q");
@@ -436,7 +684,7 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
if (upsert) { if (upsert) {
const new_doc = try build_upsert_doc(reply, q, u_doc); const new_doc = try build_upsert_doc(reply, q, u_doc);
ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey => return duplicate_key_error(reply, db_name, coll_name, new_doc), error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
else => return err, else => return err,
}; };
const id = new_doc.get("_id") orelse bson.Value.null; const id = new_doc.get("_id") orelse bson.Value.null;
@@ -458,13 +706,28 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"), error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"),
else => return err, else => return err,
}; };
try ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen); ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey, error.DuplicateKeyIndex => {
const e = try reply.arena_alloc().alloc(bson.Pair, 3);
e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(si) } };
e[1] = .{ .key = "code", .value = .{ .int32 = @intFromEnum(ErrorCode.duplicate_key) } };
e[2] = .{ .key = "errmsg", .value = .{ .string = try duplicate_key_message(ctx, reply, db_name, coll_name, copy) } };
try write_errors.append(reply.arena_alloc(), .{ .doc = e });
continue;
},
else => return err,
};
n_modified += 1; n_modified += 1;
} }
} }
try reply.put("n", .{ .int32 = @intCast(n_matched) }); try reply.put("n", .{ .int32 = @intCast(n_matched) });
try reply.put("nModified", .{ .int32 = @intCast(n_modified) }); try reply.put("nModified", .{ .int32 = @intCast(n_modified) });
if (write_errors.items.len > 0) {
const arr = try reply.arena_alloc().alloc(bson.Value, write_errors.items.len);
@memcpy(arr, write_errors.items);
try reply.put("writeErrors", .{ .array = arr });
}
if (upserted.items.len > 0) { if (upserted.items.len > 0) {
const arr = try reply.arena_alloc().alloc(bson.Value, upserted.items.len); const arr = try reply.arena_alloc().alloc(bson.Value, upserted.items.len);
for (upserted.items, 0..) |u, i| arr[i] = u.value; for (upserted.items, 0..) |u, i| arr[i] = u.value;
@@ -533,7 +796,7 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document"); const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document");
const new_doc = try build_upsert_doc(reply, q, u_doc); const new_doc = try build_upsert_doc(reply, q, u_doc);
ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey => return duplicate_key_error(reply, db_name, coll_name, new_doc), error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
else => return err, else => return err,
}; };
n = 1; n = 1;
@@ -593,7 +856,14 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
// stream entirely (so $sort/$limit after it apply to the groups). // stream entirely (so $sort/$limit after it apply to the groups).
var stream: std.ArrayListUnmanaged(*const bson.Document) = .empty; var stream: std.ArrayListUnmanaged(*const bson.Document) = .empty;
defer stream.deinit(ctx.gpa); defer stream.deinit(ctx.gpa);
if (ctx.engine.get_collection(db_name, coll_name)) |coll| { // 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;
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;
} else if (ctx.engine.get_collection(db_name, coll_name)) |coll| {
var it = coll.docs.iterator(); var it = coll.docs.iterator();
while (it.next()) |entry| try stream.append(ctx.gpa, entry.value_ptr.*); while (it.next()) |entry| try stream.append(ctx.gpa, entry.value_ptr.*);
} }
@@ -604,6 +874,10 @@ fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
var proj_pairs: ?[]const bson.Pair = null; var proj_pairs: ?[]const bson.Pair = null;
for (stages) |stage_v| { for (stages) |stage_v| {
if (first_match_consumed) {
first_match_consumed = false;
continue;
}
const stage = switch (stage_v) { const stage = switch (stage_v) {
.doc => |pairs| pairs, .doc => |pairs| pairs,
else => return bad_value(reply, "pipeline stages must be documents"), else => return bad_value(reply, "pipeline stages must be documents"),
@@ -936,18 +1210,57 @@ fn bad_value(reply: *wire.Reply, msg: []const u8) !void {
} }
/// The E11000 text, shared by the top-level error reply and the per-document /// The E11000 text, shared by the top-level error reply and the per-document
/// `writeErrors` entries of a batch insert. /// `writeErrors` entries of a batch insert. Uses engine.dup_index (set by a
fn duplicate_key_message(reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document) ![]const u8 { /// rejected unique-index write) when the conflict came from a secondary
const key = try serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null); /// 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| {
index_name = name;
key_text = try render_dup_key(ctx, reply, db_name, coll_name, name, doc);
} else {
key_text = try serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null);
}
return std.fmt.allocPrint( return std.fmt.allocPrint(
reply.arena_alloc(), arena,
"E11000 duplicate key error collection: {s}.{s} index: _id_ dup key: {s}", "E11000 duplicate key error collection: {s}.{s} index: {s} dup key: {s}",
.{ db_name, coll_name, key }, .{ db_name, coll_name, index_name, key_text },
); );
} }
fn duplicate_key_error(reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document) !void { /// Render the dup key of a secondary index from the offending document: the
const msg_text = try duplicate_key_message(reply, db_name, coll_name, doc); /// 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);
}
}
}
// Index not found (defensive): fall back to the document's _id.
return serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null);
}
fn duplicate_key_error(ctx: *Context, reply: *wire.Reply, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document) !void {
const msg_text = try duplicate_key_message(ctx, reply, db_name, coll_name, doc);
return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text); return reply.put_error(@intFromEnum(ErrorCode.duplicate_key), "DuplicateKey", msg_text);
} }
@@ -1223,3 +1536,397 @@ test "concurrent insert/find commands on a threaded Io" {
try dispatch(&ctx, &msg, &reply); try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(total, bson.get_pair(reply.pairs.items, "n").?.int32); try testing.expectEqual(total, bson.get_pair(reply.pairs.items, "n").?.int32);
} }
// -- index command tests ----------------------------------------------------
/// Dispatch an insert of the given documents (each a bson.Value .doc).
fn dispatch_insert(tdb: *TestDb, io: std.Io, coll_name: []const u8, docs: []const bson.Value) !void {
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("insert", .{ .string = coll_name }, &.{
.{ .key = "documents", .value = .{ .array = docs } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
}
/// Dispatch createIndexes for one spec.
fn dispatch_create_index(tdb: *TestDb, io: std.Io, coll_name: []const u8, spec: bson.Value) !void {
var ctx = tdb.ctx(io);
const specs = [_]bson.Value{spec};
var msg = try parse_fake_msg("createIndexes", .{ .string = coll_name }, &.{
.{ .key = "indexes", .value = .{ .array = &specs } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
}
/// Dispatch find and append the serialized _id of every result to `out`.
fn dispatch_find_ids(tdb: *TestDb, io: std.Io, coll_name: []const u8, filter: []const bson.Pair, out: *std.ArrayListUnmanaged([]u8)) !void {
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("find", .{ .string = coll_name }, &.{
.{ .key = "filter", .value = .{ .doc = filter } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
const cursor = bson.get_pair(reply.pairs.items, "cursor") orelse return error.TestUnexpectedResult;
const batch = switch (bson.get_pair(cursor.doc, "firstBatch") orelse return error.TestUnexpectedResult) {
.array => |a| a,
else => return error.TestUnexpectedResult,
};
for (batch) |d| {
const idv = bson.get_pair(d.doc, "_id") orelse continue;
try out.append(testing.allocator, try bson.serialize_value(testing.allocator, idv));
}
std.mem.sort([]u8, out.items, {}, less_u8);
}
fn less_u8(_: void, a: []u8, b: []u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
test "createIndexes, listIndexes, dropIndexes, and idempotent re-create" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
// createIndexes builds the index immediately.
{
var ctx = tdb.ctx(io);
const specs = [_]bson.Value{.{ .doc = &.{
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } },
.{ .key = "name", .value = .{ .string = "email_1" } },
} }};
var msg = try parse_fake_msg("createIndexes", .{ .string = "users" }, &.{
.{ .key = "indexes", .value = .{ .array = &specs } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
try testing.expectEqual(@as(i64, 1), bson.get_pair(reply.pairs.items, "numIndexesBefore").?.int32);
try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "numIndexesAfter").?.int32);
}
// Idempotent re-create of the same spec.
try dispatch_create_index(&tdb, io, "users", .{ .doc = &.{
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } },
.{ .key = "name", .value = .{ .string = "email_1" } },
} });
// listIndexes: _id_ first, then the secondary.
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("listIndexes", .{ .string = "users" }, &.{});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
const cursor = bson.get_pair(reply.pairs.items, "cursor").?;
const batch = bson.get_pair(cursor.doc, "firstBatch").?.array;
try testing.expectEqual(@as(usize, 2), batch.len);
try testing.expectEqualStrings("_id_", bson.get_pair(batch[0].doc, "name").?.string);
try testing.expectEqualStrings("email_1", bson.get_pair(batch[1].doc, "name").?.string);
const key_pairs = bson.get_pair(batch[1].doc, "key").?.doc;
try testing.expect(bson.get_pair(key_pairs, "email") != null);
}
// listIndexes on a missing collection: NamespaceNotFound.
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("listIndexes", .{ .string = "nope" }, &.{});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(i32, 26), bson.get_pair(reply.pairs.items, "code").?.int32);
}
// dropIndexes("*") removes the secondary but keeps _id_.
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("dropIndexes", .{ .string = "users" }, &.{
.{ .key = "index", .value = .{ .string = "*" } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "nIndexesWas").?.int32);
}
try testing.expectEqual(@as(usize, 0), tdb.engine.get_collection("test", "users").?.indexes.items.len);
// dropIndexes("_id_") errors.
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("dropIndexes", .{ .string = "users" }, &.{
.{ .key = "index", .value = .{ .string = "_id_" } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
try testing.expectEqual(@as(i32, 72), bson.get_pair(reply.pairs.items, "code").?.int32);
}
}
test "unique index constraint returns 11000 through insert and update" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
try dispatch_create_index(&tdb, io, "users", .{ .doc = &.{
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .int32 = 1 } }} } },
.{ .key = "name", .value = .{ .string = "email_1" } },
.{ .key = "unique", .value = .{ .bool = true } },
} });
const docs = [_]bson.Value{
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "email", .value = .{ .string = "a@x.io" } } } },
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "email", .value = .{ .string = "a@x.io" } } } },
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "email", .value = .{ .string = "b@x.io" } } } },
};
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("insert", .{ .string = "users" }, &.{
.{ .key = "documents", .value = .{ .array = &docs } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
// 2 inserted, 1 writeError with code 11000 naming the index.
try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "n").?.int32);
const errors = bson.get_pair(reply.pairs.items, "writeErrors").?.array;
try testing.expectEqual(@as(usize, 1), errors.len);
try testing.expectEqual(@as(i64, 11000), bson.get_pair(errors[0].doc, "code").?.int32);
const errmsg = bson.get_pair(errors[0].doc, "errmsg").?.string;
try testing.expect(std.mem.indexOf(u8, errmsg, "email_1") != null);
try testing.expect(std.mem.indexOf(u8, errmsg, "E11000") != null);
// An update that collides is reported as a writeError with code 11000.
const updates = [_]bson.Value{.{ .doc = &.{
.{ .key = "q", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 3 } }} } },
.{ .key = "u", .value = .{ .doc = &.{.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "email", .value = .{ .string = "a@x.io" } }} } }} } },
} }};
var msg2 = try parse_fake_msg("update", .{ .string = "users" }, &.{
.{ .key = "updates", .value = .{ .array = &updates } },
});
defer msg2.deinit();
var reply2 = wire.Reply.init(testing.allocator);
defer reply2.deinit();
try dispatch(&ctx, &msg2, &reply2);
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply2.pairs.items, "ok").?.double);
try testing.expectEqual(@as(i64, 0), bson.get_pair(reply2.pairs.items, "nModified").?.int32);
const up_errs = bson.get_pair(reply2.pairs.items, "writeErrors").?.array;
try testing.expectEqual(@as(usize, 1), up_errs.len);
try testing.expectEqual(@as(i64, 11000), bson.get_pair(up_errs[0].doc, "code").?.int32);
}
/// Free a list of serialized ids (each element is gpa-owned).
fn free_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) void {
for (list.items) |id| gpa.free(id);
list.deinit(gpa);
}
/// Free the serialized ids and reset the list, keeping capacity.
fn clear_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) void {
for (list.items) |id| gpa.free(id);
list.clearRetainingCapacity();
}
test "indexed queries are equivalent to scans over a mixed corpus" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
// Corpus deliberately mixes numeric _id encodings (int32 1 and int64 1
// compare equal but hash differently), arrays, nested docs, missing
// fields, explicit nulls, and duplicate values.
const corpus = [_]bson.Value{
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 1 } },
.{ .key = "a", .value = .{ .int32 = 10 } },
.{ .key = "b", .value = .{ .string = "x" } },
.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int64 = 1 } },
.{ .key = "a", .value = .{ .int32 = 20 } },
.{ .key = "b", .value = .{ .string = "y" } },
.{ .key = "tags", .value = .{ .array = &.{} } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 3 } },
.{ .key = "a", .value = .{ .double = 30.0 } },
.{ .key = "b", .value = .null },
.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" } } } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .string = "s4" } },
.{ .key = "a", .value = .{ .int32 = 10 } },
.{ .key = "b", .value = .{ .string = "z" } },
.{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 10 }, .{ .int32 = 20 } } } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 5 } },
.{ .key = "a", .value = .null },
.{ .key = "b", .value = .{ .string = "x" } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 6 } },
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "n", .value = .{ .int32 = 1 } }} } },
.{ .key = "b", .value = .{ .string = "w" } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 7 } },
.{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } },
.{ .key = "b", .value = .{ .string = "q" } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 8 } },
.{ .key = "c", .value = .{ .int32 = 99 } },
} },
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 9 } },
.{ .key = "a", .value = .{ .int32 = 40 } },
.{ .key = "b", .value = .{ .array = &.{ .{ .int32 = 5 }, .{ .int32 = 6 } } } },
} },
};
try dispatch_insert(&tdb, io, "eq", &corpus);
const filters = [_]struct { pairs: []const bson.Pair }{
.{ .pairs = &.{} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .int32 = 10 } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$eq", .value = .{ .int32 = 20 } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 15 } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 20 } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lt", .value = .{ .int32 = 30 } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lte", .value = .{ .int32 = 30 } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 10 }, .{ .int32 = 30 } } } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{ .{ .key = "$gt", .value = .{ .int32 = 5 } }, .{ .key = "$lt", .value = .{ .int32 = 25 } } } }} } },
.{ .pairs = &.{.{ .key = "b", .value = .{ .string = "x" } }} },
.{ .pairs = &.{.{ .key = "b", .value = .null }} },
.{ .pairs = &.{ .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "b", .value = .{ .string = "x" } } } },
.{ .pairs = &.{.{ .key = "tags", .value = .{ .string = "a" } }} },
.{ .pairs = &.{.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } }} },
.{ .pairs = &.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$all", .value = .{ .array = &.{ .{ .string = "a" } } } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "^1" } }} } }} },
.{ .pairs = &.{.{ .key = "c", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .null }} },
.{ .pairs = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
.{ .pairs = &.{.{ .key = "_id", .value = .{ .string = "s4" } }} },
.{ .pairs = &.{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .string = "s4" } } } }} } }} },
.{ .pairs = &.{.{ .key = "$and", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 10 } }} } }} },
.{ .doc = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lte", .value = .{ .int32 = 30 } }} } }} },
} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 } } } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 100 } }} } }} },
.{ .pairs = &.{.{ .key = "$or", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 10 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .string = "y" } }} },
} } }} },
.{ .pairs = &.{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gt", .value = .null }} } }} },
.{ .pairs = &.{ .{ .key = "a", .value = .{ .int32 = 10 } }, .{ .key = "tags", .value = .{ .int32 = 10 } } } },
};
// First with a compound (a, b) index, then after dropping it — and the
// _id fast path is exercised by the _id filters in both runs.
try dispatch_create_index(&tdb, io, "eq", .{ .doc = &.{
.{ .key = "key", .value = .{ .doc = &.{
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "b", .value = .{ .int32 = 1 } },
} } },
.{ .key = "name", .value = .{ .string = "a_1_b_1" } },
} });
// Run every filter through the index (the (a, b) plan plus the _id fast
// path), then drop the index and require identical results from the
// scan. The corpus's int32/int64 _id pair exercises the fast-path guard
// (numbers fall back to a scan in both runs).
var indexed_results: std.ArrayListUnmanaged(std.ArrayListUnmanaged([]u8)) = .empty;
defer {
for (indexed_results.items) |*l| free_id_list(testing.allocator, l);
indexed_results.deinit(testing.allocator);
}
for (filters) |f| {
var list: std.ArrayListUnmanaged([]u8) = .empty;
errdefer free_id_list(testing.allocator, &list);
try dispatch_find_ids(&tdb, io, "eq", f.pairs, &list);
try indexed_results.append(testing.allocator, list);
}
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("dropIndexes", .{ .string = "eq" }, &.{
.{ .key = "index", .value = .{ .string = "*" } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
}
var scanned: std.ArrayListUnmanaged([]u8) = .empty;
defer free_id_list(testing.allocator, &scanned);
for (filters, 0..) |f, fi| {
clear_id_list(testing.allocator, &scanned);
try dispatch_find_ids(&tdb, io, "eq", f.pairs, &scanned);
const indexed = indexed_results.items[fi];
if (scanned.items.len != indexed.items.len) std.debug.print("MISMATCH(ab) filter {d}: scan={d} idx={d}\n", .{ fi, scanned.items.len, indexed.items.len });
try testing.expectEqual(scanned.items.len, indexed.items.len);
for (scanned.items, indexed.items) |a, b| try testing.expectEqualSlices(u8, a, b);
}
// Same corpus with a sparse (a, b) index: the sparse/null bail keeps
// {a: null} and null-range filters correct.
try dispatch_create_index(&tdb, io, "eq", .{ .doc = &.{
.{ .key = "key", .value = .{ .doc = &.{
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "b", .value = .{ .int32 = 1 } },
} } },
.{ .key = "name", .value = .{ .string = "a_1_b_1_sparse" } },
.{ .key = "sparse", .value = .{ .bool = true } },
} });
for (indexed_results.items) |*l| free_id_list(testing.allocator, l);
indexed_results.clearRetainingCapacity();
for (filters) |f| {
var list: std.ArrayListUnmanaged([]u8) = .empty;
errdefer free_id_list(testing.allocator, &list);
try dispatch_find_ids(&tdb, io, "eq", f.pairs, &list);
try indexed_results.append(testing.allocator, list);
}
{
var ctx = tdb.ctx(io);
var msg = try parse_fake_msg("dropIndexes", .{ .string = "eq" }, &.{
.{ .key = "index", .value = .{ .string = "*" } },
});
defer msg.deinit();
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
try dispatch(&ctx, &msg, &reply);
}
for (filters, 0..) |f, fi| {
clear_id_list(testing.allocator, &scanned);
try dispatch_find_ids(&tdb, io, "eq", f.pairs, &scanned);
const indexed = indexed_results.items[fi];
try testing.expectEqual(scanned.items.len, indexed.items.len);
for (scanned.items, indexed.items) |a, b| try testing.expectEqualSlices(u8, a, b);
}
}

View File

@@ -289,7 +289,9 @@ pub const Engine = struct {
const coll = try self.get_or_create_collection(db_name, coll_name); const coll = try self.get_or_create_collection(db_name, coll_name);
var ix = try index.parse_spec(self.gpa, spec_doc); var ix = try index.parse_spec(self.gpa, spec_doc);
var committed = false; var committed = false;
errdefer if (!committed) ix.deinit(self.gpa); // Runs on every return path (including the idempotent no-op): the
// parsed spec is only owned by the collection once committed.
defer if (!committed) ix.deinit(self.gpa);
for (coll.indexes.items) |*existing| { for (coll.indexes.items) |*existing| {
if (std.mem.eql(u8, existing.name, ix.name)) { if (std.mem.eql(u8, existing.name, ix.name)) {
@@ -300,20 +302,16 @@ pub const Engine = struct {
// Build entries over the existing documents, checking uniqueness as // Build entries over the existing documents, checking uniqueness as
// we go (the index is not exposed until the end, so mutating it is // we go (the index is not exposed until the end, so mutating it is
// safe). On any failure the built entries are freed and nothing is // safe). Each batch is inserted into the index immediately (which
// persisted. // drains it), so on any later failure the errdefer ix.deinit frees
var built_list: std.ArrayListUnmanaged(index.BuiltEntries) = .empty; // every inserted entry key; a batch that fails before insertion is
defer { // freed by its own errdefer. Nothing is persisted on failure.
for (built_list.items) |*b| b.deinit(self.gpa);
built_list.deinit(self.gpa);
}
var doc_it = coll.docs.iterator(); var doc_it = coll.docs.iterator();
while (doc_it.next()) |entry| { while (doc_it.next()) |entry| {
var built = try ix.build_entries(self.gpa, entry.value_ptr.*, entry.key_ptr.*); var built = try ix.build_entries(self.gpa, entry.value_ptr.*, entry.key_ptr.*);
built_list.append(self.gpa, built) catch |err| { // Runs on success too: insert_entries drains the keys, leaving
built.deinit(self.gpa); // only the (now empty) ArrayList buffer to free.
return err; defer built.deinit(self.gpa);
};
if (built.multikey) ix.multikey = true; if (built.multikey) ix.multikey = true;
if (ix.unique) { if (ix.unique) {
try ix.check_unique(built.entries.items, entry.key_ptr.*); try ix.check_unique(built.entries.items, entry.key_ptr.*);

View File

@@ -677,6 +677,14 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla
} }
if (run == 0 and lo == null and hi == null) return null; if (run == 0 and lo == null and hi == null) return null;
// A two-sided range on a multikey index can under-approximate: a doc
// like {a: [1, 2]} satisfies {a: {$gt: 5, $lt: 25}} with the array for
// the lower bound (rank 5 > 5) and an element for the upper (1 < 25),
// so no single entry lies inside (5, 25) — the range scan would miss
// it. One-sided ranges are safe: whichever candidate satisfies the
// bound is itself an entry inside it. Equality/$in are unaffected.
if (ix.multikey and lo != null and hi != null) return null;
// A sparse index skips documents missing a field; {a: null} would // A sparse index skips documents missing a field; {a: null} would
// otherwise miss them. Never use a sparse index for a null component. // otherwise miss them. Never use a sparse index for a null component.
if (ix.sparse) { if (ix.sparse) {
@@ -692,15 +700,13 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla
if (hi) |v| if (v == .null) return null; if (hi) |v| if (v == .null) return null;
} }
var opts: [max_index_keys][]const bson.Value = undefined; var counts: [max_index_keys]usize = undefined;
for (0..run) |i| { for (0..run) |i| {
if (infos[i].eq) |v| { counts[i] = if (infos[i].eq != null) 1 else infos[i].in_values.?.len;
opts[i] = &.{v};
} else opts[i] = infos[i].in_values.?;
} }
var combos: u64 = 1; var combos: u64 = 1;
for (0..run) |i| { for (0..run) |i| {
combos *= opts[i].len; combos *= counts[i];
if (combos > max_combos) return null; if (combos > max_combos) return null;
} }
@@ -724,14 +730,19 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla
while (true) { while (true) {
const key = try gpa.alloc(bson.Value, run); const key = try gpa.alloc(bson.Value, run);
errdefer gpa.free(key); errdefer gpa.free(key);
for (0..run) |i| key[i] = opts[i][choice[i]]; // An eq component contributes its single value; an $in
// component the choice-th member. (Never materialize the
// single-value option as a temporary array: it would dangle.)
for (0..run) |i| {
key[i] = if (infos[i].eq) |v| v else infos[i].in_values.?[choice[i]];
}
try pl.lookup_keys.append(gpa, key); try pl.lookup_keys.append(gpa, key);
var i: usize = run; var i: usize = run;
var carry = true; var carry = true;
while (carry and i > 0) { while (carry and i > 0) {
i -= 1; i -= 1;
choice[i] += 1; choice[i] += 1;
if (choice[i] < opts[i].len) carry = false else choice[i] = 0; if (choice[i] < counts[i]) carry = false else choice[i] = 0;
} }
if (carry) break; if (carry) break;
} }