The point of a lagging checkpoint: a record whose effect the data file already holds is redundant, so the log can go back to just its header. Without this the log only grows and every open pays for every write ever made. Ordering, which is the whole safety argument: publish the watermark, *then* truncate. The other way round, a crash between them leaves the records gone from the log and absent from any image. A failed truncation is a warning rather than an error -- it costs space and replay time, and loses nothing, so it must not fail a checkpoint that already succeeded. Also wires checkpointing up, which nothing did before. `note_checkpoint` arms it when the log passes a threshold, and the write epilogue and the TTL monitor both claim it -- outside any collection lock, for the same reason compaction runs there: it takes the log lock. The threshold is separate from the compaction one on purpose: compaction is about the garbage share of the data, a checkpoint is about how much replay an open would otherwise do. -- Two things the tests taught me. The first version measured the log before the checkpoint and found 16 bytes -- just the header. Appends buffer in the log's open block and only a commit seals and writes it, so there was nothing on disk to shrink. The test commits first now, and says why. And the "no valid watermark" warning fired for every young database, which is its normal state before the first checkpoint. It now distinguishes a watermark that was *written and cannot be read* from one that was never written -- warning about the ordinary case is how people learn to ignore the warning that matters. Mutation-checked, red: skipping the truncation. Not covered, and the test says so: moving the truncation before the publish, whose failure mode is a crash landing between the two. That needs process-level crash injection, which an in-process test cannot express.
2780 lines
129 KiB
Zig
2780 lines
129 KiB
Zig
//! MongoDB command dispatch. Each command fills `reply` with its result;
|
|
//! unknown commands and failures produce error replies with real codes.
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const bson = @import("bson.zig");
|
|
const wire = @import("wire.zig");
|
|
const db = @import("db.zig");
|
|
const Collection = db.Collection;
|
|
const query = @import("query.zig");
|
|
const update = @import("update.zig");
|
|
const index = @import("index.zig");
|
|
// Always active, including in the default ReleaseFast build -- see assert.zig.
|
|
const assert = @import("assert.zig").assert;
|
|
const assert_msg = @import("assert.zig").assert_msg;
|
|
|
|
pub const Context = struct {
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
oid_gen: *bson.ObjectIdGen,
|
|
connection_id: u32,
|
|
client_desc: []const u8,
|
|
engine: *db.Engine,
|
|
server_start: std.Io.Timestamp,
|
|
};
|
|
|
|
pub const ErrorCode = enum(i32) {
|
|
command_not_found = 59,
|
|
bad_value = 2,
|
|
invalid_argument = 72,
|
|
namespace_not_found = 26,
|
|
index_not_found = 27,
|
|
duplicate_key = 11000,
|
|
namespace_exists = 48,
|
|
failed_to_parse = 9,
|
|
internal_error = 1,
|
|
invalid_pipeline_operator = 40324,
|
|
index_options_conflict = 85,
|
|
cannot_create_index = 67,
|
|
invalid_index_specification_option = 197,
|
|
};
|
|
|
|
/// Which lock (if any) a command needs on the engine. Contract: only
|
|
/// `.write` commands may call engine mutation functions (insert, replace,
|
|
/// remove, drop*, get_or_create_collection, compact); `.read` commands may
|
|
/// only read (`get_collection`, `database_names`, `collection_names`);
|
|
/// `.none` commands must not touch the engine at all.
|
|
const CommandKind = enum { none, read, write };
|
|
|
|
/// Lock shape for one command, acquired by dispatch: the catalog lock mode
|
|
/// and whether the command's target collection is locked (shared for reads,
|
|
/// exclusive for writes/DDL). The target collection is the message field
|
|
/// named after the command (find/count/insert/...), which every
|
|
/// collection-targeting command uses.
|
|
const LockShape = struct {
|
|
catalog: enum { none, shared, exclusive } = .none,
|
|
coll: enum { none, shared, exclusive } = .none,
|
|
};
|
|
|
|
const Command = struct {
|
|
name: []const u8,
|
|
kind: CommandKind,
|
|
locks: LockShape = .{},
|
|
handler: *const fn (*Context, *wire.Message, *wire.Reply) anyerror!void,
|
|
};
|
|
|
|
/// The one place a command exists: name, lock class, and handler declared
|
|
/// together so a new command cannot be given a handler but no lock.
|
|
const command_table = [_]Command{
|
|
// Handshake, admin info, no-ops: never touch the engine.
|
|
.{ .name = "hello", .kind = .none, .handler = cmd_hello },
|
|
.{ .name = "isMaster", .kind = .none, .handler = cmd_is_master },
|
|
.{ .name = "ismaster", .kind = .none, .handler = cmd_is_master },
|
|
.{ .name = "ping", .kind = .none, .handler = cmd_ping },
|
|
.{ .name = "buildInfo", .kind = .none, .handler = cmd_build_info },
|
|
.{ .name = "getParameter", .kind = .none, .handler = cmd_get_parameter },
|
|
.{ .name = "whatsmyuri", .kind = .none, .handler = cmd_whatsmyuri },
|
|
.{ .name = "hostInfo", .kind = .none, .handler = cmd_host_info },
|
|
.{ .name = "getCmdLineOpts", .kind = .none, .handler = cmd_get_cmd_line_opts },
|
|
.{ .name = "serverStatus", .kind = .none, .handler = cmd_server_status },
|
|
.{ .name = "endSessions", .kind = .none, .handler = cmd_end_sessions },
|
|
.{ .name = "connectionStatus", .kind = .none, .handler = cmd_connection_status },
|
|
.{ .name = "getMore", .kind = .none, .handler = cmd_get_more },
|
|
.{ .name = "killCursors", .kind = .none, .handler = cmd_kill_cursors },
|
|
// Read-only: scan the engine without mutating it.
|
|
.{ .name = "find", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_find },
|
|
.{ .name = "count", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_count },
|
|
.{ .name = "aggregate", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_aggregate },
|
|
.{ .name = "listDatabases", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_databases },
|
|
.{ .name = "listCollections", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_collections },
|
|
// Writes: the target collection exclusively; create/drop take the
|
|
// catalog exclusively (they mutate the maps).
|
|
.{ .name = "create", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_create },
|
|
.{ .name = "drop", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_drop },
|
|
.{ .name = "dropDatabase", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop_database },
|
|
.{ .name = "createIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_create_indexes },
|
|
.{ .name = "dropIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_drop_indexes },
|
|
.{ .name = "insert", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_insert },
|
|
.{ .name = "update", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_update },
|
|
.{ .name = "delete", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_delete },
|
|
.{ .name = "findAndModify", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_find_and_modify },
|
|
.{ .name = "listIndexes", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_list_indexes },
|
|
};
|
|
|
|
/// Command name to its index in `command_table`, resolved at comptime so a
|
|
/// request costs one hash instead of a walk down the whole table comparing
|
|
/// strings.
|
|
const command_index = blk: {
|
|
var kvs: [command_table.len]struct { []const u8, usize } = undefined;
|
|
for (&command_table, 0..) |c, i| kvs[i] = .{ c.name, i };
|
|
break :blk std.StaticStringMap(usize).initComptime(kvs);
|
|
};
|
|
|
|
pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const name = msg.command_name();
|
|
const cmd = if (command_index.get(name)) |i| &command_table[i] else {
|
|
var buf: [256]u8 = undefined;
|
|
const errmsg = try std.fmt.bufPrint(&buf, "no such command: '{s}'", .{name});
|
|
return reply.put_error(@intFromEnum(ErrorCode.command_not_found), "CommandNotFound", errmsg);
|
|
};
|
|
|
|
// Lock the catalog (shared for most commands, exclusive for DDL), then
|
|
// the target collection, then run the handler. The collection lock is
|
|
// taken while the catalog lock is held, so a concurrent drop can never
|
|
// free the collection out from under us.
|
|
// Resolve the namespace *before* taking any lock. It used to happen after
|
|
// the catalog lock, with `orelse return` on both parts -- and a plain
|
|
// `return` is not an error return, so it ran neither the errdefer below nor
|
|
// the explicit unlocks after the handler. The catalog lock was simply
|
|
// leaked, shared, forever.
|
|
//
|
|
// A database-level command reaches it: `db.aggregate(...)` sends
|
|
// `{aggregate: 1}`, whose value is not a string, so str_arg returns null.
|
|
// The symptom was baffling because a leaked *shared* lock is invisible to
|
|
// readers -- ping and listDatabases kept answering in microseconds -- while
|
|
// the next write that needs the catalog exclusive to create a collection
|
|
// blocks forever. It presented as an unrelated client-side timeout one
|
|
// command later.
|
|
var ns: ?struct { db: []const u8, coll: []const u8 } = null;
|
|
if (cmd.locks.coll != .none) {
|
|
const db_name = msg.db_name() orelse
|
|
return bad_value(reply, "command requires a $db");
|
|
const coll_name = str_arg(msg.body.get(name)) orelse
|
|
return bad_value(reply, "command requires a collection name");
|
|
ns = .{ .db = db_name, .coll = coll_name };
|
|
}
|
|
|
|
switch (cmd.locks.catalog) {
|
|
.none => {},
|
|
.shared => try ctx.engine.lock_catalog(false),
|
|
.exclusive => try ctx.engine.lock_catalog(true),
|
|
}
|
|
var catalog_held = cmd.locks.catalog != .none;
|
|
var coll: ?*Collection = null;
|
|
errdefer {
|
|
if (coll) |c| ctx.engine.unlock_collection(c, cmd.locks.coll == .exclusive);
|
|
if (catalog_held) ctx.engine.unlock_catalog(cmd.locks.catalog == .exclusive);
|
|
}
|
|
|
|
if (ns) |n| {
|
|
// Write commands may create the collection on first use; the catalog
|
|
// lock is upgraded to exclusive for that, then restored to shared.
|
|
const create = cmd.kind == .write and cmd.locks.catalog == .shared;
|
|
if (try ctx.engine.lock_collection(n.db, n.coll, cmd.locks.coll == .exclusive, create)) |c| {
|
|
coll = c;
|
|
}
|
|
}
|
|
|
|
const result = cmd.handler(ctx, msg, reply);
|
|
|
|
// Release the collection and catalog locks before the commit: the
|
|
// commit may block on other writers' appends, and must never do so
|
|
// while holding a collection lock.
|
|
if (coll) |c| ctx.engine.unlock_collection(c, cmd.locks.coll == .exclusive);
|
|
coll = null;
|
|
if (catalog_held) ctx.engine.unlock_catalog(cmd.locks.catalog == .exclusive);
|
|
catalog_held = false;
|
|
if (cmd.locks.coll == .exclusive) {
|
|
// Durability (seal + fsync) coalesces across concurrent writers. A
|
|
// commit error deliberately wins over the handler's captured `result`:
|
|
// whether the write reached disk matters more to the client than why
|
|
// the write itself was unhappy.
|
|
// commit() asserts its own postcondition (committed_seq >= this
|
|
// command's seq) internally. Re-checking it here is not possible
|
|
// without the log lock, and taking it just to assert would add a real
|
|
// race in exchange for a weaker check than the one already made.
|
|
try ctx.engine.commit();
|
|
// The write is durable by now, so a compaction failure is a maintenance
|
|
// problem and not the client's. Report it and hand the request back
|
|
// rather than turning an applied write into an error the client retries.
|
|
// A checkpoint reclaims the log, so an open stops paying for every write
|
|
// ever made. Runs here, with no lock held, for the same reason
|
|
// compaction does: it takes the log lock and must not do that while
|
|
// holding a collection lock.
|
|
if (ctx.engine.take_checkpoint()) {
|
|
ctx.engine.checkpoint() catch |err| {
|
|
// Durability is unaffected -- the log still holds everything.
|
|
// The cost is a slower next open, which is not the client's
|
|
// problem, so report and carry on.
|
|
std.debug.print("multiforadb: checkpoint failed: {s}\n", .{@errorName(err)});
|
|
};
|
|
}
|
|
if (ctx.engine.take_compact()) {
|
|
ctx.engine.compact() catch |err| {
|
|
std.debug.print("multiforadb: compaction failed: {s}\n", .{@errorName(err)});
|
|
ctx.engine.request_compact();
|
|
};
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handshake / administration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn add_server_info(ctx: *Context, reply: *wire.Reply) !void {
|
|
try reply.put("isWritablePrimary", .{ .bool = true });
|
|
try reply.put("maxBsonObjectSize", .{ .int32 = wire.max_bson_object_size });
|
|
try reply.put("maxMessageSizeBytes", .{ .int32 = 48000000 });
|
|
try reply.put("maxWriteBatchSize", .{ .int32 = 100000 });
|
|
const now = std.Io.Timestamp.now(ctx.io, .real);
|
|
try reply.put("localTime", .{ .datetime = now.toMilliseconds() });
|
|
try reply.put("logicalSessionTimeoutMinutes", .{ .int32 = 30 });
|
|
try reply.put("connectionId", .{ .int32 = @intCast(ctx.connection_id) });
|
|
try reply.put("minWireVersion", .{ .int32 = 0 });
|
|
try reply.put("maxWireVersion", .{ .int32 = 8 });
|
|
try reply.put("readOnly", .{ .bool = false });
|
|
|
|
// Deliberately no `topologyVersion`. A driver treats its presence as
|
|
// "this server supports the streaming (awaitable) hello protocol" and
|
|
// switches monitoring to an exhaust hello: it sends one hello with
|
|
// maxAwaitTimeMS and the OP_MSG exhaustAllowed flag, then expects a
|
|
// stream of unsolicited replies each carrying moreToCome. We answer
|
|
// once with moreToCome clear and go back to reading, so the driver
|
|
// fails the heartbeat ("Server ended moreToCome unexpectedly"), drops
|
|
// the connection and resets its pool — a connect/disconnect loop once
|
|
// per heartbeat, which is what MongoDB Compass showed. Omitting the
|
|
// field keeps monitoring on the polling path, which we do implement,
|
|
// and matches maxWireVersion 8: streaming hello arrived in wire 9.
|
|
}
|
|
|
|
fn cmd_hello(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try add_server_info(ctx, reply);
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_is_master(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try add_server_info(ctx, reply);
|
|
try reply.put("ismaster", .{ .bool = true });
|
|
try reply.put("helloOk", .{ .bool = true });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_ping(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_build_info(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try reply.put("version", .{ .string = "4.4.0" });
|
|
try reply.put("gitVersion", .{ .string = "multiforadb" });
|
|
try reply.put("versionArray", .{ .array = try int_array(reply, &.{ 4, 4, 0, 0 }) });
|
|
try reply.put("openssl", .{ .doc = &.{} });
|
|
try reply.put("loaderFlags", .{ .string = "" });
|
|
try reply.put("compilerInfo", .{ .string = "zig 0.16.0" });
|
|
try reply.put("allocator", .{ .string = "system" });
|
|
try reply.put("javascriptEngine", .{ .string = "none" });
|
|
try reply.put("bits", .{ .int32 = 64 });
|
|
try reply.put("debug", .{ .bool = false });
|
|
try reply.put("maxBsonObjectSize", .{ .int32 = wire.max_bson_object_size });
|
|
try reply.put("storageEngines", .{ .array = try str_array(reply, &.{"wiredTiger"}) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_get_parameter(_: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
// mongosh probes featureCompatibilityVersion; respond per requested key.
|
|
const params = doc_arg(msg.body.get("getParameter")) orelse
|
|
return invalid_arg(reply, "getParameter requires a document");
|
|
if (bson.get_pair(params, "featureCompatibilityVersion") == null) {
|
|
return invalid_arg(reply, "no option found to get");
|
|
}
|
|
const fcv = try reply.arena_alloc().alloc(bson.Pair, 1);
|
|
fcv[0] = .{ .key = "version", .value = .{ .string = "4.4" } };
|
|
try reply.put("featureCompatibilityVersion", .{ .doc = fcv });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_whatsmyuri(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try reply.put("you", .{ .string = ctx.client_desc });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_host_info(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
const now = std.Io.Timestamp.now(ctx.io, .real);
|
|
const cpu_count: usize = std.Thread.getCpuCount() catch 1;
|
|
|
|
const system = try reply.arena_alloc().alloc(bson.Pair, 6);
|
|
system[0] = .{ .key = "currentTime", .value = .{ .datetime = now.toMilliseconds() } };
|
|
system[1] = .{ .key = "hostname", .value = .{ .string = "localhost" } };
|
|
system[2] = .{ .key = "cpuAddrSize", .value = .{ .int32 = 64 } };
|
|
system[3] = .{ .key = "memSizeMB", .value = .{ .int32 = 0 } };
|
|
system[4] = .{ .key = "numCores", .value = .{ .int32 = @intCast(cpu_count) } };
|
|
system[5] = .{ .key = "cpuArch", .value = .{ .string = @tagName(builtin.cpu.arch) } };
|
|
try reply.put("system", .{ .doc = system });
|
|
|
|
const os = try reply.arena_alloc().alloc(bson.Pair, 3);
|
|
os[0] = .{ .key = "type", .value = .{ .string = @tagName(builtin.os.tag) } };
|
|
os[1] = .{ .key = "name", .value = .{ .string = @tagName(builtin.os.tag) } };
|
|
os[2] = .{ .key = "version", .value = .{ .string = "unknown" } };
|
|
try reply.put("os", .{ .doc = os });
|
|
|
|
try reply.put("extra", .{ .doc = &.{} });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_get_cmd_line_opts(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
const argv = try reply.arena_alloc().alloc(bson.Pair, 2);
|
|
argv[0] = .{ .key = "dbpath", .value = .{ .string = "multiforadb.log" } };
|
|
argv[1] = .{ .key = "port", .value = .{ .int32 = 27017 } };
|
|
try reply.put("argv", .{ .array = &.{} });
|
|
try reply.put("parsed", .{ .doc = argv });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_server_status(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
const now = std.Io.Timestamp.now(ctx.io, .real);
|
|
const uptime: i64 = std.Io.Timestamp.durationTo(ctx.server_start, now).toSeconds();
|
|
|
|
try reply.put("host", .{ .string = "localhost" });
|
|
try reply.put("version", .{ .string = "4.4.0" });
|
|
try reply.put("process", .{ .string = "mongod" });
|
|
try reply.put("uptime", .{ .double = @floatFromInt(uptime) });
|
|
try reply.put("localTime", .{ .datetime = now.toMilliseconds() });
|
|
const connections = try reply.arena_alloc().alloc(bson.Pair, 1);
|
|
connections[0] = .{ .key = "current", .value = .{ .int32 = @intCast(ctx.connection_id) } };
|
|
try reply.put("connections", .{ .doc = connections });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_end_sessions(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_connection_status(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
const auth_info = try reply.arena_alloc().alloc(bson.Pair, 2);
|
|
auth_info[0] = .{ .key = "authenticatedUsers", .value = .{ .array = &.{} } };
|
|
auth_info[1] = .{ .key = "authenticatedUserRoles", .value = .{ .array = &.{} } };
|
|
try reply.put("authInfo", .{ .doc = auth_info });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_list_databases(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
var names: std.ArrayListUnmanaged([]const u8) = .empty;
|
|
defer names.deinit(ctx.gpa);
|
|
try ctx.engine.database_names(&names);
|
|
|
|
const values = try reply.arena_alloc().alloc(bson.Value, names.items.len);
|
|
for (names.items, 0..) |n, i| {
|
|
const entry = try reply.arena_alloc().alloc(bson.Pair, 3);
|
|
entry[0] = .{ .key = "name", .value = .{ .string = try reply.arena_alloc().dupe(u8, n) } };
|
|
entry[1] = .{ .key = "sizeOnDisk", .value = .{ .double = 0 } };
|
|
entry[2] = .{ .key = "empty", .value = .{ .bool = true } };
|
|
values[i] = .{ .doc = entry };
|
|
}
|
|
try reply.put("databases", .{ .array = values });
|
|
try reply.put("totalSize", .{ .int32 = 0 });
|
|
try reply.put("totalSizeMb", .{ .int32 = 0 });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_list_collections(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "listCollections requires $db");
|
|
var names: std.ArrayListUnmanaged([]const u8) = .empty;
|
|
defer names.deinit(ctx.gpa);
|
|
try ctx.engine.collection_names(db_name, &names);
|
|
|
|
const values = try reply.arena_alloc().alloc(bson.Value, names.items.len);
|
|
for (names.items, 0..) |n, i| {
|
|
const entry = try reply.arena_alloc().alloc(bson.Pair, 3);
|
|
entry[0] = .{ .key = "name", .value = .{ .string = try reply.arena_alloc().dupe(u8, n) } };
|
|
entry[1] = .{ .key = "type", .value = .{ .string = "collection" } };
|
|
entry[2] = .{ .key = "options", .value = .{ .doc = &.{} } };
|
|
values[i] = .{ .doc = entry };
|
|
}
|
|
try reply.put("cursor", .{ .doc = try cursor_doc(reply, 0, try format_namespace(reply, db_name, ""), "firstBatch", values) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_create(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "create requires $db");
|
|
const coll_name = str_arg(msg.body.get("create")) orelse return bad_value(reply, "create requires a collection name");
|
|
_ = try ctx.engine.get_or_create_collection(db_name, coll_name);
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_drop(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "drop requires $db");
|
|
const coll_name = str_arg(msg.body.get("drop")) orelse return bad_value(reply, "drop requires a collection name");
|
|
if (!try ctx.engine.drop_collection(db_name, coll_name)) {
|
|
return reply.put_error(@intFromEnum(ErrorCode.namespace_not_found), "NamespaceNotFound", "ns not found");
|
|
}
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_drop_database(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "dropDatabase requires $db");
|
|
_ = try ctx.engine.drop_database(db_name);
|
|
try reply.put("dropped", .{ .string = try reply.arena_alloc().dupe(u8, db_name) });
|
|
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;
|
|
for (key_pairs) |p| {
|
|
if (std.mem.eql(u8, p.key, "_id")) has_id = true;
|
|
}
|
|
if (has_id) {
|
|
const only = key_pairs[0].value;
|
|
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));
|
|
if (is_id_index) {
|
|
// The no-op must not swallow options that would change what
|
|
// the index does — MongoDB rejects a TTL _id index rather
|
|
// than quietly ignoring the expiry.
|
|
if (bson.get_pair(spec, "expireAfterSeconds") != null) {
|
|
return reply.put_error(
|
|
@intFromEnum(ErrorCode.invalid_index_specification_option),
|
|
"InvalidIndexSpecificationOption",
|
|
"the field 'expireAfterSeconds' is not valid for an _id " ++
|
|
"index specification",
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
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.TtlOnCompoundIndex => return reply.put_error(
|
|
@intFromEnum(ErrorCode.cannot_create_index),
|
|
"CannotCreateIndex",
|
|
"TTL indexes are single-field indexes, compound indexes do not support TTL",
|
|
),
|
|
error.InvalidExpireAfterSeconds => return reply.put_error(
|
|
@intFromEnum(ErrorCode.cannot_create_index),
|
|
"CannotCreateIndex",
|
|
"TTL index 'expireAfterSeconds' option must be a whole number " ++
|
|
"between 0 and 2147483647",
|
|
),
|
|
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 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"),
|
|
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");
|
|
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| _ = 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)) {
|
|
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 '*'"),
|
|
};
|
|
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 '*'");
|
|
}
|
|
|
|
try reply.put("nIndexesWas", .{ .int32 = n_indexes_was });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
/// 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
|
|
/// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_insert(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "insert requires $db");
|
|
const coll_name = str_arg(msg.body.get("insert")) orelse return bad_value(reply, "insert requires a collection name");
|
|
|
|
const docs = try batch_arg(msg, reply, "insert", "documents") orelse return;
|
|
|
|
var inserted: i64 = 0;
|
|
var write_errors: std.ArrayListUnmanaged(bson.Value) = .empty;
|
|
defer write_errors.deinit(reply.arena_alloc());
|
|
|
|
// Group commit: one fsync for the whole batch instead of one per document.
|
|
// Nothing to open or close here -- appends never sync, and the dispatch
|
|
// epilogue is the single commit point. It runs on every return path, so a
|
|
// failed doc (writeErrors) or a hard error still syncs what was appended,
|
|
// and does it after the collection lock is released.
|
|
for (docs, 0..) |*doc, i| {
|
|
if (ctx.engine.insert(db_name, coll_name, doc, ctx.oid_gen)) |_| {
|
|
inserted += 1;
|
|
} else |err| {
|
|
switch (err) {
|
|
error.DuplicateKey, error.DuplicateKeyIndex => {
|
|
const e = try reply.arena_alloc().alloc(bson.Pair, 3);
|
|
e[0] = .{ .key = "index", .value = .{ .int32 = @intCast(i) } };
|
|
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, doc) } };
|
|
try write_errors.append(reply.arena_alloc(), .{ .doc = e });
|
|
},
|
|
else => return err,
|
|
}
|
|
}
|
|
}
|
|
|
|
try reply.put("n", .{ .int32 = @intCast(inserted) });
|
|
if (write_errors.items.len > 0) {
|
|
// Copy into the reply arena: write_errors is freed when this command
|
|
// returns, before the reply is serialized.
|
|
const arr = try reply.arena_alloc().alloc(bson.Value, write_errors.items.len);
|
|
@memcpy(arr, write_errors.items);
|
|
try reply.put("writeErrors", .{ .array = arr });
|
|
}
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_find(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "find requires $db");
|
|
const coll_name = str_arg(msg.body.get("find")) orelse return bad_value(reply, "find requires a collection name");
|
|
|
|
const filter = doc_arg(msg.body.get("filter")) orelse return bad_value(reply, "filter must be a document");
|
|
|
|
const sort_keys = try parse_sort_keys(reply, msg.body.get("sort"));
|
|
const proj_pairs = doc_arg(msg.body.get("projection"));
|
|
const skip: u64 = int_arg(msg.body.get("skip")) orelse 0;
|
|
// A negative limit means "return this many in a single batch"; we always
|
|
// reply with one batch, so only the magnitude matters.
|
|
const limit: usize = @abs(int_value(msg.body.get("limit")) orelse 0);
|
|
|
|
var matched: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer matched.deinit(ctx.gpa);
|
|
// Documents needed to fill the page, counting the skipped prefix; 0
|
|
// means unbounded.
|
|
const page_end: usize = if (limit == 0) 0 else blk: {
|
|
const skip_usize = std.math.cast(usize, skip) orelse break :blk 0;
|
|
break :blk skip_usize +| limit;
|
|
};
|
|
// An index whose order already is the requested one lets the scan stop
|
|
// at the page boundary and skip sorting entirely. Otherwise a sort has
|
|
// to see every match before it can tell which ones the page contains.
|
|
var index_sorted = false;
|
|
_ = try scan_sorted(ctx, db_name, coll_name, filter, page_end, &matched, sort_keys, &index_sorted);
|
|
|
|
// Sorting and emitting need the documents as trees; materialize the
|
|
// matched page into the reply arena (the slab itself is never copied).
|
|
// A find on a namespace that does not exist is an empty cursor, not an
|
|
// error and not a reply missing `ok`: the scan above matched nothing, so
|
|
// falling through to the emit at the end of this function says exactly
|
|
// that without a second exit path to keep in step with it.
|
|
const coll = ctx.engine.get_collection(db_name, coll_name);
|
|
// The scan above ran against this same collection with the catalog lock
|
|
// held, so a missing collection means nothing matched. Asserted rather than
|
|
// left implicit: if that ever stops holding, the loop below silently emits
|
|
// an empty page for a query that did match, which is the hardest kind of
|
|
// wrong answer to notice.
|
|
if (coll == null) assert_msg(matched.items.len == 0, "find matched documents in a collection that does not exist");
|
|
// Lives in the reply arena; freed with it.
|
|
var tree_docs: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
const arena = reply.arena_alloc();
|
|
if (coll) |c| {
|
|
for (matched.items) |off| {
|
|
try tree_docs.append(arena, try doc_tree(arena, c, off));
|
|
}
|
|
}
|
|
if (sort_keys.len > 0 and !index_sorted) {
|
|
// Selecting the page is much cheaper than ordering everything when
|
|
// the page is a small fraction of the matches. Above that fraction
|
|
// the heap's bookkeeping stops paying for itself.
|
|
if (page_end > 0 and page_end *| 4 <= tree_docs.items.len) {
|
|
try query.sort_docs_top_k(arena, tree_docs.items, sort_keys, page_end);
|
|
} else {
|
|
try query.sort_docs(arena, tree_docs.items, sort_keys);
|
|
}
|
|
}
|
|
const rest = if (skip < tree_docs.items.len) tree_docs.items[skip..] else &.{};
|
|
const page = if (limit > 0 and limit < rest.len) rest[0..limit] else rest;
|
|
try emit_docs_tree(reply, db_name, coll_name, proj_pairs, page);
|
|
try reply.put_ok();
|
|
}
|
|
|
|
/// Collect the documents in `db_name.coll_name` matching `filter`, stopping
|
|
/// after `limit` matches (0 = unlimited). Returns the number matched; `out`
|
|
/// may be null when only the count is wanted. Every command that scans a
|
|
/// 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(
|
|
ctx: *Context,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
filter: []const bson.Pair,
|
|
limit: usize,
|
|
out: ?*std.ArrayListUnmanaged(u64),
|
|
) !usize {
|
|
return scan_sorted(ctx, db_name, coll_name, filter, limit, out, &.{}, null);
|
|
}
|
|
|
|
/// `scan_matching` plus the option of having an index produce the ordering.
|
|
/// When `sorted` is given it reports whether the candidates came out in
|
|
/// `sort` order, in which case the caller must not sort them again — and
|
|
/// `limit` is then a genuine early stop rather than an arbitrary subset.
|
|
fn scan_sorted(
|
|
ctx: *Context,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
filter: []const bson.Pair,
|
|
limit: usize,
|
|
out: ?*std.ArrayListUnmanaged(u64),
|
|
sort: []const query.SortKey,
|
|
sorted: ?*bool,
|
|
) !usize {
|
|
if (sorted) |flag| flag.* = false;
|
|
// Stopping early is only meaningful when the candidates come out in the
|
|
// order the caller asked for. Without a sort any subset of that size is
|
|
// a valid page; with one, the limit is honoured only if an index turns
|
|
// out to supply the ordering.
|
|
var lim: usize = if (sort.len == 0) limit else 0;
|
|
const coll = ctx.engine.get_collection(db_name, coll_name) orelse return 0;
|
|
var n: usize = 0;
|
|
|
|
// Index plan (the implicit _id_ index first, then the secondaries):
|
|
// candidates in index order, re-filtered. A candidate *is* a slab offset
|
|
// now, so the map lookup that used to translate an id into one is gone --
|
|
// and so is the accidental safety net it provided: a stale entry used to be
|
|
// dropped silently by `orelse continue`, where now it resolves to
|
|
// superseded-but-parseable bytes that the re-applied filter might accept.
|
|
// Loud beats silent: a wrong answer a test can see beats a missing
|
|
// candidate nothing can.
|
|
// Candidates arrive as a stream so that a whole-index read never
|
|
// materializes: at the tens-of-GB target a `countDocuments({})` would
|
|
// otherwise build a list of every offset in the collection before the
|
|
// first one is examined. A narrowed plan still materializes, because its
|
|
// multikey/$in dedupe genuinely needs the whole set, and it is bounded by
|
|
// selectivity.
|
|
var offs: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer offs.deinit(ctx.gpa);
|
|
var cands: index.Candidates = undefined;
|
|
var plan_opt = try index.plan(ctx.gpa, &coll.id_index, coll.indexes.items, filter, sort);
|
|
defer if (plan_opt) |*p| p.deinit(ctx.gpa);
|
|
|
|
if (plan_opt) |*plan| {
|
|
if (sorted) |flag| flag.* = plan.provides_sort;
|
|
if (plan.provides_sort) lim = limit;
|
|
if (plan.full_scan()) {
|
|
cands = if (plan.backward)
|
|
.{ .scan_rev = plan.index.iter_reverse() }
|
|
else
|
|
.{ .scan = plan.index.iter() };
|
|
} else {
|
|
try plan.search(ctx.gpa, &offs);
|
|
cands = .{ .list = .{ .items = offs.items } };
|
|
}
|
|
} else {
|
|
// No usable predicate: every document, in _id order. The docs map was
|
|
// the fallback here, and its iteration order was the hash's; walking
|
|
// the _id_ index instead is ordered, streams, and does not depend on a
|
|
// structure that is going away.
|
|
cands = .{ .scan = coll.id_index.iter() };
|
|
}
|
|
|
|
while (cands.next()) |off| {
|
|
if (!try query.matches_bytes(ctx.gpa, filter, coll.doc_bytes(off))) continue;
|
|
if (out) |list| try list.append(ctx.gpa, off);
|
|
n += 1;
|
|
if (lim != 0 and n >= lim) break;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/// A stored document (a slab offset) materialized as a borrowed spine in
|
|
/// `arena`: keys and leaf values point into the slab's stable bytes, only
|
|
/// the pair/value skeleton is allocated. The arena owns the skeleton, so
|
|
/// the result is never deinit'd — the reply arena frees it with the reply.
|
|
fn doc_tree(arena: std.mem.Allocator, coll: *const Collection, off: u64) !*const bson.Document {
|
|
const doc = try arena.create(bson.Document);
|
|
doc.* = bson.Document{ .arena = undefined, .pairs = try bson.spine(arena, coll.doc_bytes(off)) };
|
|
return doc;
|
|
}
|
|
|
|
fn emit_docs_tree(
|
|
reply: *wire.Reply,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
proj_pairs: ?[]const bson.Pair,
|
|
docs: []const *const bson.Document,
|
|
) !void {
|
|
const values = try reply.arena_alloc().alloc(bson.Value, docs.len);
|
|
for (docs, 0..) |d, i| {
|
|
values[i] = try project_doc(reply, d, proj_pairs);
|
|
}
|
|
try reply.put("cursor", .{ .doc = try cursor_doc(reply, 0, try format_namespace(reply, db_name, coll_name), "firstBatch", values) });
|
|
}
|
|
|
|
/// Project a stored doc (or deep-copy it) into the reply arena.
|
|
fn project_doc(
|
|
reply: *wire.Reply,
|
|
doc: *const bson.Document,
|
|
proj_pairs: ?[]const bson.Pair,
|
|
) !bson.Value {
|
|
if (proj_pairs) |pp| {
|
|
var out: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
errdefer out.deinit(reply.arena_alloc());
|
|
try query.project(reply.arena_alloc(), doc, &.{ .arena = undefined, .pairs = pp }, &out);
|
|
return .{ .doc = out.items };
|
|
}
|
|
return .{ .doc = try bson.copy_pairs(reply.arena_alloc(), doc.pairs) };
|
|
}
|
|
|
|
fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "update requires $db");
|
|
const coll_name = str_arg(msg.body.get("update")) orelse return bad_value(reply, "update requires a collection name");
|
|
|
|
const specs = try batch_arg(msg, reply, "update", "updates") orelse return;
|
|
|
|
var n_matched: i64 = 0;
|
|
var n_modified: i64 = 0;
|
|
var upserted: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer upserted.deinit(reply.arena_alloc());
|
|
var write_errors: std.ArrayListUnmanaged(bson.Value) = .empty;
|
|
defer write_errors.deinit(reply.arena_alloc());
|
|
|
|
// Group commit for multi-document updates: one fsync per command, issued
|
|
// by the dispatch epilogue once the collection lock is released.
|
|
for (specs, 0..) |*spec, si| {
|
|
const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "update spec requires q");
|
|
const u_doc = doc_arg(spec.get("u")) orelse return bad_value(reply, "update spec requires u");
|
|
const multi = bool_arg(spec.get("multi")) orelse false;
|
|
const upsert = bool_arg(spec.get("upsert")) orelse false;
|
|
|
|
var matched: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer matched.deinit(ctx.gpa);
|
|
_ = try scan_matching(ctx, db_name, coll_name, q, if (multi) 0 else 1, &matched);
|
|
|
|
if (matched.items.len == 0) {
|
|
if (upsert) {
|
|
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) {
|
|
error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
|
|
else => return err,
|
|
};
|
|
const id = new_doc.get("_id") orelse bson.Value.null;
|
|
const u = try reply.arena_alloc().alloc(bson.Pair, 2);
|
|
u[0] = .{ .key = "index", .value = .{ .int32 = @intCast(si) } };
|
|
u[1] = .{ .key = "_id", .value = try bson.copy_value(reply.arena_alloc(), id) };
|
|
try upserted.append(reply.arena_alloc(), .{ .key = "u", .value = .{ .doc = u } });
|
|
n_matched += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
n_matched += @intCast(matched.items.len);
|
|
const coll = ctx.engine.get_collection(db_name, coll_name) orelse return;
|
|
for (matched.items) |off| {
|
|
// Work on a copy: the log write must precede any visible change,
|
|
// and a rejected update must not corrupt the stored document.
|
|
const doc = try doc_tree(reply.arena_alloc(), coll, off);
|
|
const copy = try clone_doc(reply, doc);
|
|
update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) {
|
|
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"),
|
|
else => return err,
|
|
};
|
|
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;
|
|
}
|
|
}
|
|
|
|
try reply.put("n", .{ .int32 = @intCast(n_matched) });
|
|
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) {
|
|
const arr = try reply.arena_alloc().alloc(bson.Value, upserted.items.len);
|
|
for (upserted.items, 0..) |u, i| arr[i] = u.value;
|
|
try reply.put("upserted", .{ .array = arr });
|
|
}
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_delete(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "delete requires $db");
|
|
const coll_name = str_arg(msg.body.get("delete")) orelse return bad_value(reply, "delete requires a collection name");
|
|
|
|
const specs = try batch_arg(msg, reply, "delete", "deletes") orelse return;
|
|
|
|
var n_deleted: i64 = 0;
|
|
// Group commit for multi-document deletes: one fsync per command, issued
|
|
// by the dispatch epilogue once the collection lock is released.
|
|
for (specs) |*spec| {
|
|
const q = doc_arg(spec.get("q")) orelse return bad_value(reply, "delete spec requires q");
|
|
const limit = int_value(spec.get("limit")) orelse 1;
|
|
|
|
var matched: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer matched.deinit(ctx.gpa);
|
|
_ = try scan_matching(ctx, db_name, coll_name, q, if (limit == 1) 1 else 0, &matched);
|
|
if (ctx.engine.get_collection(db_name, coll_name)) |coll| {
|
|
var id_arena = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer id_arena.deinit();
|
|
for (matched.items) |off| {
|
|
const id = (try bson.get_at(id_arena.allocator(), coll.doc_bytes(off), "_id")) orelse continue;
|
|
if (try ctx.engine.remove_by_id(db_name, coll_name, id)) n_deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
try reply.put("n", .{ .int32 = @intCast(n_deleted) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "findAndModify requires $db");
|
|
const coll_name = str_arg(msg.body.get("findAndModify")) orelse return bad_value(reply, "findAndModify requires a collection name");
|
|
|
|
const q = doc_arg(msg.body.get("query")) orelse &.{};
|
|
const sort_keys = try parse_sort_keys(reply, msg.body.get("sort"));
|
|
const remove = bool_arg(msg.body.get("remove")) orelse false;
|
|
const do_update = msg.body.get("update") != null;
|
|
const upsert = bool_arg(msg.body.get("upsert")) orelse false;
|
|
const ret_new = bool_arg(msg.body.get("new")) orelse false;
|
|
const proj_pairs = doc_arg(msg.body.get("fields"));
|
|
|
|
if (remove and do_update) return bad_value(reply, "remove and update are mutually exclusive");
|
|
if (!remove and !do_update) return bad_value(reply, "must specify update or remove");
|
|
|
|
var matched: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer matched.deinit(ctx.gpa);
|
|
// Without a sort, only the first match is ever used.
|
|
_ = try scan_matching(ctx, db_name, coll_name, q, if (sort_keys.len > 0) 0 else 1, &matched);
|
|
const arena = reply.arena_alloc();
|
|
const coll = ctx.engine.get_collection(db_name, coll_name) orelse return;
|
|
// findAndModify reads and rewrites the document, so materialize the
|
|
// (usually tiny) match set as trees in the reply arena.
|
|
var matched_docs: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
for (matched.items) |off| try matched_docs.append(arena, try doc_tree(arena, coll, off));
|
|
if (sort_keys.len > 0) {
|
|
try query.sort_docs(arena, matched_docs.items, sort_keys);
|
|
}
|
|
|
|
const target = if (matched_docs.items.len > 0) matched_docs.items[0] else null;
|
|
|
|
// Each branch decides what the reply says; the tail below emits it once.
|
|
var n: i32 = 0;
|
|
var updated_existing = false;
|
|
var upserted_id: ?bson.Value = null;
|
|
var value: bson.Value = .null;
|
|
|
|
if (target == null and do_update and upsert) {
|
|
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);
|
|
ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) {
|
|
error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
|
|
else => return err,
|
|
};
|
|
n = 1;
|
|
upserted_id = try bson.copy_value(arena, new_doc.get("_id") orelse bson.Value.null);
|
|
if (ret_new) value = try project_doc(reply, new_doc, proj_pairs);
|
|
} else if (target != null and remove) {
|
|
n = 1;
|
|
// Project before removing: this reads the stored document.
|
|
value = try project_doc(reply, target.?, proj_pairs);
|
|
_ = try ctx.engine.remove_by_id(db_name, coll_name, target.?.get("_id") orelse unreachable);
|
|
} else if (target != null and do_update) {
|
|
const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document");
|
|
const before = try bson.copy_pairs(arena, target.?.pairs);
|
|
const copy = try clone_doc(reply, target.?);
|
|
update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) {
|
|
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"),
|
|
else => return err,
|
|
};
|
|
try ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen);
|
|
n = 1;
|
|
updated_existing = true;
|
|
value = if (ret_new) try project_doc(reply, copy, proj_pairs) else .{ .doc = before };
|
|
} // else: no match and no upsert — an empty result
|
|
|
|
var leo: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer leo.deinit(arena);
|
|
try leo.append(arena, .{ .key = "n", .value = .{ .int32 = n } });
|
|
try leo.append(arena, .{ .key = "updatedExisting", .value = .{ .bool = updated_existing } });
|
|
if (upserted_id) |id| try leo.append(arena, .{ .key = "upserted", .value = id });
|
|
try reply.put("value", value);
|
|
try reply.put("lastErrorObject", .{ .doc = try leo.toOwnedSlice(arena) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_count(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "count requires $db");
|
|
const coll_name = str_arg(msg.body.get("count")) orelse return bad_value(reply, "count requires a collection name");
|
|
const q = doc_arg(msg.body.get("query")) orelse &.{};
|
|
|
|
const n = try scan_matching(ctx, db_name, coll_name, q, 0, null);
|
|
try reply.put("n", .{ .int32 = @intCast(n) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "aggregate requires $db");
|
|
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");
|
|
var stages = switch (pipeline_value) {
|
|
.array => |a| a,
|
|
else => return bad_value(reply, "pipeline must be an array"),
|
|
};
|
|
|
|
// countDocuments() reaches us as [{$match: F}?, {$group: {_id: <literal>,
|
|
// n: {$sum: 1}}}]. The general path answers that by materializing every
|
|
// matching document and then throwing them all away, so recognize the
|
|
// shape and answer it from a counting scan instead.
|
|
if (try count_only_pipeline(reply, stages)) |shape| {
|
|
const n = try scan_matching(ctx, db_name, coll_name, shape.filter, 0, null);
|
|
// No documents means no groups at all, not a group holding zero —
|
|
// same as the general path, which builds groups per document.
|
|
var docs: []const *const bson.Document = &.{};
|
|
if (n > 0) {
|
|
const arena = reply.arena_alloc();
|
|
const pairs = try arena.alloc(bson.Pair, 1 + shape.accs.len);
|
|
pairs[0] = .{ .key = "_id", .value = try bson.copy_value(arena, shape.id_value) };
|
|
for (shape.accs, 0..) |acc, i| {
|
|
// Mirrors run_group's coercion exactly: an integral sum in
|
|
// int32 range comes back as int32, otherwise a double.
|
|
const sum: f64 = @as(f64, @floatFromInt(n)) * acc.term;
|
|
pairs[1 + i] = .{
|
|
.key = acc.key,
|
|
.value = if (sum == @floor(sum) and sum <= 2_147_483_647 and sum >= -2_147_483_648)
|
|
.{ .int32 = @intFromFloat(sum) }
|
|
else
|
|
.{ .double = sum },
|
|
};
|
|
}
|
|
const doc = try arena.create(bson.Document);
|
|
doc.* = bson.Document{ .arena = undefined, .pairs = pairs };
|
|
const one = try arena.alloc(*const bson.Document, 1);
|
|
one[0] = doc;
|
|
docs = one;
|
|
}
|
|
try emit_docs_tree(reply, db_name, coll_name, null, docs);
|
|
return reply.put_ok();
|
|
}
|
|
|
|
// The pipeline operates on a stream of documents; each stage transforms
|
|
// the current window [start, end). Before $group the stream holds slab
|
|
// offsets (matched in place, never materialized); $group replaces it
|
|
// with generated group documents, so the stream flips to tree form.
|
|
var offs: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer offs.deinit(ctx.gpa);
|
|
var trees: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
defer trees.deinit(ctx.gpa);
|
|
var in_trees = false;
|
|
// No such collection is an empty result, not an absent one. A bare
|
|
// `return` here sent a reply with no `ok` field at all, which the driver
|
|
// reports as the uninformative `MongoServerError: n/a` -- and it is what
|
|
// `db.aggregate(...)` hits, because a database-level aggregate names no
|
|
// collection. MongoDB answers an aggregate over a missing collection with
|
|
// an empty cursor.
|
|
const coll = ctx.engine.get_collection(db_name, coll_name) orelse {
|
|
try emit_docs_tree(reply, db_name, coll_name, null, &.{});
|
|
return reply.put_ok();
|
|
};
|
|
// A leading $match is pushed down into an indexed candidate scan; the
|
|
// 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, &offs);
|
|
stages = stages[1..];
|
|
} else {
|
|
// Every document, in _id order. A pipeline materializes its stream
|
|
// anyway (stages need random access to the window), so this one stays a
|
|
// list -- but it comes from the _id_ index rather than the docs map,
|
|
// which is ordered and does not depend on a structure that is going
|
|
// away. Streaming the whole pipeline is M1's cursor work.
|
|
var it = coll.id_index.iter();
|
|
while (it.next()) |e| try offs.append(ctx.gpa, e.off);
|
|
}
|
|
|
|
var start: usize = 0;
|
|
var end: usize = offs.items.len;
|
|
var count_stage: ?[]const u8 = null;
|
|
var proj_pairs: ?[]const bson.Pair = null;
|
|
|
|
for (stages) |stage_v| {
|
|
const stage = switch (stage_v) {
|
|
.doc => |pairs| pairs,
|
|
else => return bad_value(reply, "pipeline stages must be documents"),
|
|
};
|
|
if (stage.len == 0) continue;
|
|
const stage_name = stage[0].key;
|
|
if (std.mem.eql(u8, stage_name, "$match")) {
|
|
const filter = doc_arg(stage[0].value) orelse return bad_value(reply, "$match requires a document");
|
|
if (!in_trees) {
|
|
var kept: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer kept.deinit(ctx.gpa);
|
|
for (offs.items[start..end]) |off| {
|
|
if (try query.matches_bytes(ctx.gpa, filter, coll.doc_bytes(off))) {
|
|
try kept.append(ctx.gpa, off);
|
|
}
|
|
}
|
|
offs.deinit(ctx.gpa);
|
|
offs = kept;
|
|
kept = .empty;
|
|
} else {
|
|
var kept: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
defer kept.deinit(ctx.gpa);
|
|
for (trees.items[start..end]) |d| {
|
|
if (try query.matches(ctx.gpa, &.{ .arena = undefined, .pairs = filter }, d)) {
|
|
try kept.append(ctx.gpa, d);
|
|
}
|
|
}
|
|
trees.deinit(ctx.gpa);
|
|
trees = kept;
|
|
kept = .empty;
|
|
}
|
|
start = 0;
|
|
end = (if (in_trees) trees.items.len else offs.items.len);
|
|
} else if (std.mem.eql(u8, stage_name, "$sort")) {
|
|
const keys = try parse_sort_keys(reply, stage[0].value);
|
|
if (keys.len > 0) {
|
|
const arena = reply.arena_alloc();
|
|
if (!in_trees) {
|
|
// Sorting needs the values; materialize and switch the
|
|
// stream to tree form for the rest of the pipeline.
|
|
//
|
|
// The list buffer must come from `ctx.gpa`, because that is
|
|
// what frees it: ownership moves to `trees`, and `trees` is
|
|
// released by this function's `defer trees.deinit(ctx.gpa)`
|
|
// and by the $match branch above. Building it from the
|
|
// reply arena instead handed a gpa-free an arena-owned
|
|
// pointer -- a remote, client-triggerable invalid free that
|
|
// macOS malloc turns into SIGTRAP with no panic text, so it
|
|
// read as "the connection closed". Any pipeline with $sort
|
|
// and no preceding $group reached it.
|
|
//
|
|
// The *documents* stay in the arena on purpose: it outlives
|
|
// the command, and only the ArrayList's own allocator has
|
|
// to match its deinit.
|
|
var all: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
errdefer all.deinit(ctx.gpa);
|
|
for (offs.items) |off| try all.append(ctx.gpa, try doc_tree(arena, coll, off));
|
|
trees.deinit(ctx.gpa);
|
|
trees = all;
|
|
all = .empty;
|
|
in_trees = true;
|
|
}
|
|
try query.sort_docs(arena, trees.items[start..end], keys);
|
|
}
|
|
} else if (std.mem.eql(u8, stage_name, "$skip")) {
|
|
const n = try stage_count(reply, stage[0].value, "$skip") orelse return;
|
|
start = @min(start + n, end);
|
|
} else if (std.mem.eql(u8, stage_name, "$limit")) {
|
|
const n = try stage_count(reply, stage[0].value, "$limit") orelse return;
|
|
end = @min(end, start + n);
|
|
} else if (std.mem.eql(u8, stage_name, "$project")) {
|
|
proj_pairs = doc_arg(stage[0].value);
|
|
} else if (std.mem.eql(u8, stage_name, "$group")) {
|
|
const gp = doc_arg(stage[0].value) orelse return bad_value(reply, "$group requires a document");
|
|
const grouped_opt = try run_group(ctx, reply, coll, gp, offs.items[start..end]);
|
|
var grouped = grouped_opt orelse return;
|
|
// Group results replace the stream: later stages see groups.
|
|
offs.deinit(ctx.gpa);
|
|
offs = .empty;
|
|
trees.deinit(ctx.gpa);
|
|
trees = grouped;
|
|
grouped = .empty;
|
|
in_trees = true;
|
|
start = 0;
|
|
end = trees.items.len;
|
|
} else if (std.mem.eql(u8, stage_name, "$count")) {
|
|
count_stage = switch (stage[0].value) {
|
|
.string => |s| s,
|
|
else => return bad_value(reply, "$count requires a string"),
|
|
};
|
|
} else {
|
|
const msg_text = try std.fmt.allocPrint(reply.arena_alloc(), "Unrecognized pipeline stage name: '{s}'", .{stage_name});
|
|
return reply.put_error(@intFromEnum(ErrorCode.invalid_pipeline_operator), "InvalidPipelineOperator", msg_text);
|
|
}
|
|
}
|
|
|
|
if (count_stage) |name| {
|
|
const len = if (in_trees) trees.items[start..end].len else offs.items[start..end].len;
|
|
const c = try reply.arena_alloc().alloc(bson.Pair, 1);
|
|
c[0] = .{ .key = try reply.arena_alloc().dupe(u8, name), .value = .{ .int32 = @intCast(len) } };
|
|
const values = try reply.arena_alloc().alloc(bson.Value, 1);
|
|
values[0] = .{ .doc = c };
|
|
try reply.put("cursor", .{ .doc = try cursor_doc(reply, 0, try format_namespace(reply, db_name, coll_name), "firstBatch", values) });
|
|
} else {
|
|
const arena = reply.arena_alloc();
|
|
if (in_trees) {
|
|
try emit_docs_tree(reply, db_name, coll_name, proj_pairs, trees.items[start..end]);
|
|
} else {
|
|
var page: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
for (offs.items[start..end]) |off| try page.append(arena, try doc_tree(arena, coll, off));
|
|
try emit_docs_tree(reply, db_name, coll_name, proj_pairs, page.items);
|
|
}
|
|
}
|
|
try reply.put_ok();
|
|
}
|
|
|
|
/// A pipeline whose whole answer is the number of matching documents.
|
|
const CountShape = struct {
|
|
filter: []const bson.Pair,
|
|
/// The literal every document groups under.
|
|
id_value: bson.Value,
|
|
accs: []const Acc,
|
|
|
|
const Acc = struct { key: []const u8, term: f64 };
|
|
};
|
|
|
|
/// Recognize `[{$match: F}?, {$group: {_id: <literal>, k: {$sum: <number>}}}]`
|
|
/// — the shape a driver sends for countDocuments().
|
|
///
|
|
/// Deliberately conservative: a `_id` of `"$field"`, an accumulator over a
|
|
/// field, or any other stage needs the documents themselves, so anything
|
|
/// that is not exactly this shape returns null and takes the general path.
|
|
fn count_only_pipeline(reply: *wire.Reply, stages: []const bson.Value) !?CountShape {
|
|
if (stages.len == 0 or stages.len > 2) return null;
|
|
|
|
var filter: []const bson.Pair = &.{};
|
|
if (stages.len == 2) {
|
|
const first = switch (stages[0]) {
|
|
.doc => |p| p,
|
|
else => return null,
|
|
};
|
|
if (first.len != 1 or !std.mem.eql(u8, first[0].key, "$match")) return null;
|
|
filter = doc_arg(first[0].value) orelse return null;
|
|
}
|
|
|
|
const last = switch (stages[stages.len - 1]) {
|
|
.doc => |p| p,
|
|
else => return null,
|
|
};
|
|
if (last.len != 1 or !std.mem.eql(u8, last[0].key, "$group")) return null;
|
|
const gp = doc_arg(last[0].value) orelse return null;
|
|
|
|
const id_value = bson.get_pair(gp, "_id") orelse return null;
|
|
switch (id_value) {
|
|
// A field path or a computed id groups per document.
|
|
.string => |s| if (s.len > 0 and s[0] == '$') return null,
|
|
.doc, .array => return null,
|
|
else => {},
|
|
}
|
|
|
|
var accs: std.ArrayListUnmanaged(CountShape.Acc) = .empty;
|
|
for (gp) |p| {
|
|
if (std.mem.eql(u8, p.key, "_id")) continue;
|
|
const spec = switch (p.value) {
|
|
.doc => |d| d,
|
|
else => return null,
|
|
};
|
|
if (spec.len != 1 or !std.mem.eql(u8, spec[0].key, "$sum")) return null;
|
|
const term: f64 = switch (spec[0].value) {
|
|
.int32 => |i| @floatFromInt(i),
|
|
.int64 => |i| @floatFromInt(i),
|
|
.double => |d| d,
|
|
// $sum over a field depends on the documents.
|
|
else => return null,
|
|
};
|
|
try accs.append(reply.arena_alloc(), .{ .key = p.key, .term = term });
|
|
}
|
|
|
|
return .{ .filter = filter, .id_value = id_value, .accs = accs.items };
|
|
}
|
|
|
|
/// Minimal $group: supports `_id` of null/literal/"$field" and `$sum`
|
|
/// accumulators (constant or "$field").
|
|
fn run_group(
|
|
ctx: *Context,
|
|
reply: *wire.Reply,
|
|
coll: *const Collection,
|
|
group_pairs: []const bson.Pair,
|
|
docs: []const u64,
|
|
) !?std.ArrayListUnmanaged(*const bson.Document) {
|
|
const arena = reply.arena_alloc();
|
|
const id_expr = bson.get_pair(group_pairs, "_id") orelse {
|
|
try bad_value(reply, "$group requires _id");
|
|
return null;
|
|
};
|
|
|
|
var accs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer accs.deinit(arena);
|
|
for (group_pairs) |p| {
|
|
if (std.mem.eql(u8, p.key, "_id")) continue;
|
|
try accs.append(arena, p);
|
|
}
|
|
|
|
const Group = struct {
|
|
id_value: bson.Value,
|
|
sums: []f64,
|
|
};
|
|
var groups: std.StringHashMapUnmanaged(Group) = .empty;
|
|
defer groups.deinit(ctx.gpa);
|
|
// StringHashMapUnmanaged does not copy keys; keep them alive until done.
|
|
var keys_owned: std.ArrayListUnmanaged([]u8) = .empty;
|
|
defer {
|
|
for (keys_owned.items) |k| ctx.gpa.free(k);
|
|
keys_owned.deinit(ctx.gpa);
|
|
}
|
|
|
|
var id_key_buf: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer id_key_buf.deinit(ctx.gpa);
|
|
// Byte-walk materializations (nested group keys) live here.
|
|
var walk_arena = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer walk_arena.deinit();
|
|
for (docs) |off| {
|
|
const doc = coll.doc_bytes(off);
|
|
const id_value: bson.Value = switch (id_expr) {
|
|
.string => |s| if (s.len > 0 and s[0] == '$') (try query_path_value_bytes(walk_arena.allocator(), doc, s[1..])) orelse .null else id_expr,
|
|
else => id_expr,
|
|
};
|
|
id_key_buf.clearRetainingCapacity();
|
|
try bson.write_value(id_value, ctx.gpa, &id_key_buf);
|
|
|
|
const gop = try groups.getOrPut(ctx.gpa, id_key_buf.items);
|
|
if (!gop.found_existing) {
|
|
// Only a new group needs an owned copy of the key; the map
|
|
// borrows it, so keys_owned keeps it alive until we are done.
|
|
const key = try ctx.gpa.dupe(u8, id_key_buf.items);
|
|
try keys_owned.append(ctx.gpa, key);
|
|
gop.key_ptr.* = key;
|
|
const sums = try ctx.gpa.alloc(f64, accs.items.len);
|
|
@memset(sums, 0);
|
|
gop.value_ptr.* = .{ .id_value = id_value, .sums = sums };
|
|
}
|
|
for (accs.items, 0..) |acc, i| {
|
|
var expr = acc.value;
|
|
// Unwrap {$sum: <expr>} accumulator documents.
|
|
if (expr == .doc) {
|
|
if (bson.get_pair(expr.doc, "$sum")) |inner| {
|
|
expr = inner;
|
|
} else continue;
|
|
}
|
|
const term: f64 = switch (expr) {
|
|
.int32 => |n| @floatFromInt(n),
|
|
.int64 => |n| @floatFromInt(n),
|
|
.double => |n| n,
|
|
.string => |s| if (s.len > 0 and s[0] == '$')
|
|
switch ((try query_path_value_bytes(walk_arena.allocator(), doc, s[1..])) orelse .null) {
|
|
.int32 => |n| @floatFromInt(n),
|
|
.int64 => |n| @floatFromInt(n),
|
|
.double => |n| n,
|
|
else => 0,
|
|
}
|
|
else
|
|
0,
|
|
else => 0,
|
|
};
|
|
gop.value_ptr.sums[i] += term;
|
|
}
|
|
}
|
|
|
|
var out: std.ArrayListUnmanaged(*const bson.Document) = .empty;
|
|
errdefer out.deinit(ctx.gpa);
|
|
var it = groups.iterator();
|
|
// free sum arrays
|
|
defer {
|
|
var git = groups.iterator();
|
|
while (git.next()) |e| ctx.gpa.free(e.value_ptr.sums);
|
|
}
|
|
while (it.next()) |entry| {
|
|
const npairs = 1 + accs.items.len;
|
|
const pairs = try arena.alloc(bson.Pair, npairs);
|
|
pairs[0] = .{ .key = "_id", .value = try bson.copy_value(arena, entry.value_ptr.id_value) };
|
|
for (accs.items, 0..) |acc, i| {
|
|
const sum: f64 = entry.value_ptr.sums[i];
|
|
const sum_value: bson.Value = if (sum == @floor(sum) and sum <= 2_147_483_647 and sum >= -2_147_483_648)
|
|
.{ .int32 = @intFromFloat(sum) }
|
|
else
|
|
.{ .double = sum };
|
|
pairs[1 + i] = .{ .key = acc.key, .value = sum_value };
|
|
}
|
|
const doc = try arena.create(bson.Document);
|
|
doc.* = bson.Document{ .arena = undefined, .pairs = pairs };
|
|
try out.append(ctx.gpa, doc);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// Resolve a simple "$field" path expression inside a document.
|
|
fn query_path_value_bytes(
|
|
gpa: std.mem.Allocator,
|
|
bytes: []const u8,
|
|
path: []const u8,
|
|
) !?bson.Value {
|
|
var cur: bson.Value = undefined;
|
|
var it = std.mem.splitScalar(u8, path, '.');
|
|
const first = it.next() orelse return null;
|
|
cur = (try bson.get_at(gpa, bytes, first)) orelse return null;
|
|
while (it.next()) |seg| {
|
|
cur = switch (cur) {
|
|
.doc => |pairs| bson.get_pair(pairs, seg) orelse return null,
|
|
else => return null,
|
|
};
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
fn cmd_get_more(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
// Cursors are never left open, so getMore always yields an empty batch.
|
|
try reply.put("cursor", .{ .doc = try cursor_doc(reply, 0, "test.$cmd", "nextBatch", &.{}) });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
fn cmd_kill_cursors(_: *Context, _: *wire.Message, reply: *wire.Reply) !void {
|
|
try reply.put("cursorsKilled", .{ .array = &.{} });
|
|
try reply.put("cursorsNotFound", .{ .array = &.{} });
|
|
try reply.put("cursorsAlive", .{ .array = &.{} });
|
|
try reply.put_ok();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Deep-copy a stored document into the reply arena so updates can be
|
|
/// applied off the live doc.
|
|
fn clone_doc(reply: *wire.Reply, doc: *const bson.Document) !*bson.Document {
|
|
const arena = reply.arena_alloc();
|
|
const owned = try arena.create(bson.Document);
|
|
owned.* = .{
|
|
.arena = std.heap.ArenaAllocator.init(arena),
|
|
.pairs = try bson.copy_pairs(arena, doc.pairs),
|
|
};
|
|
return owned;
|
|
}
|
|
|
|
/// Build the document for an upsert: equality fields from the filter, then
|
|
/// the update operators applied. Owned by the reply arena.
|
|
fn build_upsert_doc(
|
|
reply: *wire.Reply,
|
|
q: []const bson.Pair,
|
|
u_doc: []const bson.Pair,
|
|
) !*bson.Document {
|
|
const arena = reply.arena_alloc();
|
|
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer pairs.deinit(arena);
|
|
for (q) |p| {
|
|
const is_operator = p.key.len > 0 and p.key[0] == '$';
|
|
const is_embedded_operator = p.value == .doc and query.all_operator_keys(p.value.doc);
|
|
if (!is_operator and !is_embedded_operator) {
|
|
try pairs.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = try bson.copy_value(arena, p.value) });
|
|
}
|
|
}
|
|
const owned = try arena.create(bson.Document);
|
|
owned.* = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try pairs.toOwnedSlice(arena) };
|
|
// Apply update operators to build the final doc; _id handled by insert.
|
|
update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) {
|
|
error.ImmutableId, error.InvalidUpdate => return error.InvalidUpdate,
|
|
else => return err,
|
|
};
|
|
return owned;
|
|
}
|
|
|
|
fn parse_sort_keys(reply: *wire.Reply, value: ?bson.Value) ![]const query.SortKey {
|
|
const pairs = doc_arg(value) orelse return &.{};
|
|
const out = try reply.arena_alloc().alloc(query.SortKey, pairs.len);
|
|
for (pairs, 0..) |p, i| {
|
|
const descending = switch (p.value) {
|
|
.int32 => |n| n < 0,
|
|
.int64 => |n| n < 0,
|
|
.double => |n| n < 0,
|
|
.string => |s| std.mem.eql(u8, s, "desc"),
|
|
else => false,
|
|
};
|
|
out[i] = .{
|
|
.path = try reply.arena_alloc().dupe(u8, p.key),
|
|
.descending = descending,
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
fn doc_arg(v: ?bson.Value) ?[]const bson.Pair {
|
|
return switch (v orelse return null) {
|
|
.doc => |pairs| pairs,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn str_arg(v: ?bson.Value) ?[]const u8 {
|
|
return switch (v orelse return null) {
|
|
.string => |s| s,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn bool_arg(v: ?bson.Value) ?bool {
|
|
return switch (v orelse return null) {
|
|
.bool => |b| b,
|
|
.int32 => |i| i != 0,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn int_arg(v: ?bson.Value) ?u64 {
|
|
const i = int_value(v) orelse return null;
|
|
return if (i < 0) null else @intCast(i);
|
|
}
|
|
|
|
/// Numeric argument as a signed integer, with no clamping — callers that care
|
|
/// about the sign (find's limit, delete's limit, $skip) apply their own rule.
|
|
fn int_value(v: ?bson.Value) ?i64 {
|
|
return switch (v orelse return null) {
|
|
.int32 => |i| i,
|
|
.int64 => |i| i,
|
|
// lossyCast saturates instead of trapping on out-of-range doubles.
|
|
.double => |d| std.math.lossyCast(i64, d),
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
/// A `$skip`/`$limit` stage operand: a non-negative document count. Writes
|
|
/// the error reply and returns null when the operand is not a number.
|
|
fn stage_count(reply: *wire.Reply, v: bson.Value, stage: []const u8) !?usize {
|
|
const n = int_value(v) orelse {
|
|
const text = try std.fmt.allocPrint(reply.arena_alloc(), "{s} requires a number", .{stage});
|
|
try bad_value(reply, text);
|
|
return null;
|
|
};
|
|
return @intCast(@max(0, n));
|
|
}
|
|
|
|
/// Fetch a batch argument, writing the standard error reply and returning
|
|
/// null when it is missing or malformed.
|
|
fn batch_arg(
|
|
msg: *wire.Message,
|
|
reply: *wire.Reply,
|
|
cmd: []const u8,
|
|
name: []const u8,
|
|
) !?[]const bson.Document {
|
|
return msg.batch(name) catch |err| {
|
|
const arena = reply.arena_alloc();
|
|
const text = switch (err) {
|
|
error.MissingBatch => try std.fmt.allocPrint(arena, "{s} requires {s}", .{ cmd, name }),
|
|
error.BatchNotArray => try std.fmt.allocPrint(arena, "{s} must be an array", .{name}),
|
|
error.BatchElementNotDoc => try std.fmt.allocPrint(arena, "{s} must be documents", .{name}),
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
};
|
|
try bad_value(reply, text);
|
|
return null;
|
|
};
|
|
}
|
|
|
|
fn invalid_arg(reply: *wire.Reply, msg: []const u8) !void {
|
|
return reply.put_error(@intFromEnum(ErrorCode.invalid_argument), "InvalidArgument", msg);
|
|
}
|
|
|
|
fn bad_value(reply: *wire.Reply, msg: []const u8) !void {
|
|
return reply.put_error(@intFromEnum(ErrorCode.bad_value), "BadValue", msg);
|
|
}
|
|
|
|
/// The E11000 text, shared by the top-level error reply and the per-document
|
|
/// `writeErrors` entries of a batch insert. Uses the collection's
|
|
/// dup_index (set by a rejected unique-index write) when the conflict came
|
|
/// from a secondary index; otherwise it is the _id_ index. Per-collection
|
|
/// so concurrent writers on other collections cannot clobber it.
|
|
fn duplicate_key_message(
|
|
ctx: *Context,
|
|
reply: *wire.Reply,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
doc: *const bson.Document,
|
|
) ![]const u8 {
|
|
var index_name: []const u8 = "_id_";
|
|
var key_text: []const u8 = undefined;
|
|
const coll = ctx.engine.get_collection(db_name, coll_name);
|
|
if (coll) |c| {
|
|
if (c.dup_index) |name| {
|
|
index_name = name;
|
|
key_text = try render_dup_key(ctx, reply, db_name, coll_name, name, doc);
|
|
return e11000_message(reply, db_name, coll_name, index_name, key_text);
|
|
}
|
|
}
|
|
key_text = try serialize_value_compact(reply, doc.get("_id") orelse bson.Value.null);
|
|
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();
|
|
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));
|
|
}
|
|
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 {
|
|
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);
|
|
}
|
|
|
|
/// Compact extended-JSON-ish rendering of a value for error messages.
|
|
fn serialize_value_compact(reply: *wire.Reply, v: bson.Value) ![]const u8 {
|
|
const arena = reply.arena_alloc();
|
|
return switch (v) {
|
|
.int32 => |i| try std.fmt.allocPrint(arena, "{d}", .{i}),
|
|
.int64 => |i| try std.fmt.allocPrint(arena, "{d}", .{i}),
|
|
.double => |d| try std.fmt.allocPrint(arena, "{d}", .{d}),
|
|
.string => |s| try std.fmt.allocPrint(arena, "'{s}'", .{s}),
|
|
.bool => |b| try std.fmt.allocPrint(arena, "{}", .{b}),
|
|
.object_id => |oid| blk: {
|
|
const hex = std.fmt.bytesToHex(oid[0..], .lower);
|
|
break :blk try std.fmt.allocPrint(arena, "ObjectId('{s}')", .{hex});
|
|
},
|
|
else => "{ ... }",
|
|
};
|
|
}
|
|
|
|
/// Build a { id: <n>, ns: "...", <batch_key>: [...] } cursor document.
|
|
pub fn cursor_doc(
|
|
reply: *wire.Reply,
|
|
cursor_id: i64,
|
|
ns: []const u8,
|
|
batch_key: []const u8,
|
|
docs: []const bson.Value,
|
|
) ![]const bson.Pair {
|
|
const c = try reply.arena_alloc().alloc(bson.Pair, 3);
|
|
c[0] = .{ .key = "id", .value = .{ .int64 = cursor_id } };
|
|
c[1] = .{ .key = "ns", .value = .{ .string = ns } };
|
|
c[2] = .{ .key = batch_key, .value = .{ .array = docs } };
|
|
return c;
|
|
}
|
|
|
|
fn format_namespace(reply: *wire.Reply, db_name: []const u8, coll_name: []const u8) ![]const u8 {
|
|
return std.fmt.allocPrint(reply.arena_alloc(), "{s}.{s}", .{ db_name, coll_name });
|
|
}
|
|
|
|
fn int_array(reply: *wire.Reply, values: []const i32) ![]const bson.Value {
|
|
const out = try reply.arena_alloc().alloc(bson.Value, values.len);
|
|
for (values, 0..) |v, i| out[i] = .{ .int32 = v };
|
|
return out;
|
|
}
|
|
|
|
fn str_array(reply: *wire.Reply, values: []const []const u8) ![]const bson.Value {
|
|
const out = try reply.arena_alloc().alloc(bson.Value, values.len);
|
|
for (values, 0..) |v, i| out[i] = .{ .string = try reply.arena_alloc().dupe(u8, v) };
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Temp-log-backed engine plus the Context a command test dispatches with.
|
|
const TestDb = struct {
|
|
tmp: std.testing.TmpDir,
|
|
path: []u8,
|
|
engine: db.Engine,
|
|
gen: bson.ObjectIdGen,
|
|
|
|
fn init(io: std.Io) !TestDb {
|
|
const tmp = std.testing.tmpDir(.{});
|
|
const path = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}/cmd.log", .{tmp.sub_path});
|
|
return .{
|
|
.tmp = tmp,
|
|
.path = path,
|
|
.engine = try db.Engine.open(testing.allocator, io, path),
|
|
.gen = bson.ObjectIdGen.init(io),
|
|
};
|
|
}
|
|
|
|
fn deinit(self: *TestDb) void {
|
|
self.engine.deinit();
|
|
self.tmp.cleanup();
|
|
testing.allocator.free(self.path);
|
|
}
|
|
|
|
fn ctx(self: *TestDb, io: std.Io) Context {
|
|
return test_ctx(io, &self.engine, &self.gen, 1);
|
|
}
|
|
};
|
|
|
|
fn test_ctx(io: std.Io, engine: *db.Engine, gen: *bson.ObjectIdGen, connection_id: u32) Context {
|
|
return .{
|
|
.gpa = testing.allocator,
|
|
.io = io,
|
|
.oid_gen = gen,
|
|
.connection_id = connection_id,
|
|
.client_desc = "127.0.0.1:0",
|
|
.engine = engine,
|
|
.server_start = std.Io.Timestamp.now(io, .real),
|
|
};
|
|
}
|
|
|
|
test "ping and hello replies parse" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
var ctx = tdb.ctx(io);
|
|
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
var msg = try parse_fake_msg("ping", .null, &.{});
|
|
defer msg.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
try testing.expectEqual(@as(f64, 1.0), reply.pairs.items[0].value.double);
|
|
|
|
var reply2 = wire.Reply.init(testing.allocator);
|
|
defer reply2.deinit();
|
|
var msg2 = try parse_fake_msg("hello", .null, &.{});
|
|
defer msg2.deinit();
|
|
try dispatch(&ctx, &msg2, &reply2);
|
|
const ok = bson.get_pair(reply2.pairs.items, "ok").?;
|
|
try testing.expectEqual(@as(f64, 1.0), ok.double);
|
|
const primary = bson.get_pair(reply2.pairs.items, "isWritablePrimary").?;
|
|
try testing.expect(primary.bool);
|
|
try testing.expectEqual(@as(i32, 8), bson.get_pair(reply2.pairs.items, "maxWireVersion").?.int32);
|
|
}
|
|
|
|
test "handshake does not advertise the streaming hello protocol" {
|
|
// `topologyVersion` in a hello/isMaster reply tells the driver it may
|
|
// monitor with an exhaust hello and expect moreToCome replies we never
|
|
// send; the driver then kills the connection every heartbeat.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
var ctx = tdb.ctx(io);
|
|
|
|
for ([_][]const u8{ "hello", "isMaster", "ismaster" }) |cmd| {
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
var msg = try parse_fake_msg(cmd, .null, &.{});
|
|
defer msg.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
|
|
try testing.expect(bson.get_pair(reply.pairs.items, "topologyVersion") == null);
|
|
}
|
|
}
|
|
|
|
test "unknown command gives CommandNotFound" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
var ctx = tdb.ctx(io);
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
var msg = try parse_fake_msg("nonsenseCmd", .null, &.{});
|
|
defer msg.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
try testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double);
|
|
try testing.expectEqual(@as(i32, 59), bson.get_pair(reply.pairs.items, "code").?.int32);
|
|
try testing.expectEqualStrings("CommandNotFound", bson.get_pair(reply.pairs.items, "codeName").?.string);
|
|
}
|
|
|
|
test "getParameter responds for featureCompatibilityVersion" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
var ctx = tdb.ctx(io);
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
var fcv_doc = try testing.allocator.alloc(bson.Pair, 1);
|
|
defer testing.allocator.free(fcv_doc);
|
|
fcv_doc[0] = .{ .key = "featureCompatibilityVersion", .value = .{ .int32 = 1 } };
|
|
var msg = try parse_fake_msg("getParameter", .{ .doc = fcv_doc }, &.{});
|
|
defer msg.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
const fcv = bson.get_pair(reply.pairs.items, "featureCompatibilityVersion").?;
|
|
try testing.expectEqualStrings("4.4", fcv.doc[0].value.string);
|
|
}
|
|
|
|
test "insert counts only successful inserts" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
var ctx = tdb.ctx(io);
|
|
|
|
// documents: [{_id:1}, {_id:1} (dup), {_id:2}] → n: 2 + one writeError.
|
|
const docs = [_]bson.Value{
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} },
|
|
};
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
var msg = try parse_fake_msg("insert", .{ .string = "users" }, &.{
|
|
.{ .key = "documents", .value = .{ .array = &docs } },
|
|
});
|
|
defer msg.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
try testing.expectEqual(@as(i64, 2), bson.get_pair(reply.pairs.items, "n").?.int32);
|
|
const errors = bson.get_pair(reply.pairs.items, "writeErrors").?;
|
|
try testing.expectEqual(@as(usize, 1), errors.array.len);
|
|
const first = errors.array[0].doc[0];
|
|
try testing.expectEqualStrings("index", first.key);
|
|
try testing.expectEqual(@as(i64, 1), first.value.int32);
|
|
}
|
|
|
|
fn parse_fake_msg(name: []const u8, value: bson.Value, extra: []const bson.Pair) !wire.Message {
|
|
var out: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer out.deinit(testing.allocator);
|
|
const pairs = try testing.allocator.alloc(bson.Pair, 2 + extra.len);
|
|
defer testing.allocator.free(pairs);
|
|
pairs[0] = .{ .key = name, .value = value };
|
|
pairs[1] = .{ .key = "$db", .value = .{ .string = "test" } };
|
|
@memcpy(pairs[2..], extra);
|
|
try bson.write_doc(pairs, testing.allocator, &out);
|
|
var msg: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer msg.deinit(testing.allocator);
|
|
try msg.appendSlice(testing.allocator, &[_]u8{ 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0xDD, 0x07, 0, 0, 0, 0, 0, 0 });
|
|
try msg.append(testing.allocator, 0x00);
|
|
try msg.appendSlice(testing.allocator, out.items);
|
|
std.mem.writeInt(u32, msg.items[0..4], @intCast(msg.items.len), .little);
|
|
std.mem.writeInt(i32, msg.items[12..16], wire.op_code_msg, .little);
|
|
return wire.Message.parse(testing.allocator, msg.items);
|
|
}
|
|
|
|
test "concurrent insert/find commands on a threaded Io" {
|
|
// Exercises dispatch's lock classification end-to-end: writer fibers run
|
|
// `insert` under the exclusive lock, reader fibers run `count`/`find`
|
|
// under the shared lock. Every committed insert must be visible once all
|
|
// writers finish, and no reader may observe more docs than can exist.
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var gen = bson.ObjectIdGen.init(io);
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const path = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}/conc.log", .{tmp.sub_path});
|
|
defer testing.allocator.free(path);
|
|
var engine = try db.Engine.open(testing.allocator, io, path);
|
|
defer engine.deinit();
|
|
|
|
const writers = 4;
|
|
const readers = 4;
|
|
const per_writer: i32 = 150;
|
|
const total: i32 = writers * per_writer;
|
|
var next_id = std.atomic.Value(i32).init(1);
|
|
var remaining = std.atomic.Value(usize).init(@intCast(total));
|
|
|
|
const Worker = struct {
|
|
fn writer(
|
|
iow: std.Io,
|
|
eng: *db.Engine,
|
|
id_counter: *std.atomic.Value(i32),
|
|
pending: *std.atomic.Value(usize),
|
|
fiber_id: u32,
|
|
total_writes: i32,
|
|
) error{Canceled}!void {
|
|
var wgen = bson.ObjectIdGen.init(iow);
|
|
var ctx = test_ctx(iow, eng, &wgen, fiber_id);
|
|
while (true) {
|
|
const id = id_counter.fetchAdd(1, .monotonic);
|
|
if (id > total_writes) return;
|
|
const docs = [_]bson.Value{.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = id } }} }};
|
|
var msg = parse_fake_msg("insert", .{ .string = "users" }, &.{
|
|
.{ .key = "documents", .value = .{ .array = &docs } },
|
|
}) catch return error.Canceled;
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
dispatch(&ctx, &msg, &reply) catch return error.Canceled;
|
|
_ = pending.fetchSub(1, .monotonic);
|
|
}
|
|
}
|
|
|
|
fn reader(
|
|
iow: std.Io,
|
|
eng: *db.Engine,
|
|
pending: *std.atomic.Value(usize),
|
|
fiber_id: u32,
|
|
total_writes: i32,
|
|
) error{Canceled}!void {
|
|
var rgen = bson.ObjectIdGen.init(iow);
|
|
var ctx = test_ctx(iow, eng, &rgen, fiber_id);
|
|
while (pending.load(.acquire) > 0) {
|
|
var msg = parse_fake_msg("count", .{ .string = "users" }, &.{}) catch return error.Canceled;
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
dispatch(&ctx, &msg, &reply) catch return error.Canceled;
|
|
const n = bson.get_pair(reply.pairs.items, "n") orelse return error.Canceled;
|
|
if (n.int32 > total_writes) return error.Canceled;
|
|
}
|
|
}
|
|
};
|
|
|
|
var group: std.Io.Group = .init;
|
|
defer group.cancel(io);
|
|
for (0..readers) |i| group.async(io, Worker.reader, .{ io, &engine, &remaining, @intCast(i + 1), total });
|
|
for (0..writers) |i| group.async(io, Worker.writer, .{ io, &engine, &next_id, &remaining, @intCast(i + 1), total });
|
|
try group.await(io);
|
|
|
|
var ctx = test_ctx(io, &engine, &gen, 0);
|
|
var msg = try parse_fake_msg("count", .{ .string = "users" }, &.{});
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
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);
|
|
// `ok: 1` is not success for an insert batch: a rejected document comes
|
|
// back as a writeError alongside it. Asserting only `ok` let a corpus
|
|
// silently shrink -- when _id_ became a unique index, the mixed-type
|
|
// corpus below lost its int64 1 document and every test over it still
|
|
// passed, over nine documents instead of ten.
|
|
if (bson.get_pair(reply.pairs.items, "writeErrors")) |we| {
|
|
std.debug.print("dispatch_insert: unexpected writeErrors: {any}\n", .{we});
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
try testing.expectEqual(@as(i32, @intCast(docs.len)), bson.get_pair(reply.pairs.items, "n").?.int32);
|
|
}
|
|
|
|
/// 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 "TTL index round-trips through createIndexes/listIndexes; bad specs give 67" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
|
|
// A driver sends expireAfterSeconds as a double.
|
|
try dispatch_create_index(&tdb, io, "sessions", .{ .doc = &.{
|
|
.{
|
|
.key = "key",
|
|
.value = .{ .doc = &.{
|
|
.{ .key = "expireAt", .value = .{ .int32 = 1 } },
|
|
} },
|
|
},
|
|
|
|
.{ .key = "name", .value = .{ .string = "expireAt_1" } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .double = 60.0 } },
|
|
} });
|
|
try testing.expectEqual(@as(?i64, 60), tdb.engine.get_collection("test", "sessions").?.indexes.items[0].ttl);
|
|
|
|
// listIndexes reports it back.
|
|
{
|
|
var ctx = tdb.ctx(io);
|
|
var msg = try parse_fake_msg("listIndexes", .{ .string = "sessions" }, &.{});
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
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.expectEqual(@as(i32, 60), bson.get_pair(batch[1].doc, "expireAfterSeconds").?.int32);
|
|
// The _id_ entry never carries one.
|
|
try testing.expect(bson.get_pair(batch[0].doc, "expireAfterSeconds") == null);
|
|
}
|
|
|
|
// Same name and key, different expiry: IndexOptionsConflict, as in
|
|
// MongoDB (changing it is a collMod, which this server does not have).
|
|
const bad = [_]struct { spec: bson.Value, code: i32 }{
|
|
.{ .code = 85, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "name", .value = .{ .string = "expireAt_1" } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 90 } },
|
|
} } },
|
|
// TTL on a compound key.
|
|
.{ .code = 67, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{
|
|
.{ .key = "a", .value = .{ .int32 = 1 } },
|
|
.{ .key = "b", .value = .{ .int32 = 1 } },
|
|
} } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } },
|
|
} } },
|
|
// Negative and non-numeric expiries.
|
|
.{ .code = 67, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .int32 = -1 } },
|
|
} } },
|
|
.{ .code = 67, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .string = "60" } },
|
|
} } },
|
|
// Past MongoDB's 2147483647 bound.
|
|
.{ .code = 67, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .int64 = index.max_expire_after_seconds + 1 } },
|
|
} } },
|
|
// {_id: 1} is otherwise an idempotent no-op, but an expiry on it
|
|
// would be silently dropped, so it is rejected instead.
|
|
.{ .code = 197, .spec = .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } },
|
|
} } },
|
|
};
|
|
for (bad) |case| {
|
|
var ctx = tdb.ctx(io);
|
|
const specs = [_]bson.Value{case.spec};
|
|
var msg = try parse_fake_msg("createIndexes", .{ .string = "sessions" }, &.{
|
|
.{ .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(case.code, bson.get_pair(reply.pairs.items, "code").?.int32);
|
|
}
|
|
// Nothing partial was registered by the rejected specs.
|
|
try testing.expectEqual(@as(usize, 1), tdb.engine.get_collection("test", "sessions").?.indexes.items.len);
|
|
}
|
|
|
|
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 "aggregate $sort without a preceding $group sorts and frees correctly" {
|
|
// Regression test for a remote, client-triggerable invalid free: the
|
|
// $sort stage materialized its document list from the reply arena and
|
|
// handed it to `trees`, which is freed with the gpa. Two things made it
|
|
// survive for so long, and this test is shaped to close both:
|
|
//
|
|
// - every existing aggregate test sorts *after* a $group, which leaves
|
|
// the stream already materialized so the guilty branch never runs.
|
|
// So this pipeline must have $sort with NO $group before it.
|
|
// - the symptom was allocator-dependent (macOS malloc aborted; other
|
|
// allocators may not notice). testing.allocator detects an invalid
|
|
// free itself, which is what gives this teeth in every mode.
|
|
//
|
|
// Mutation check: change the `all.append(ctx.gpa, ...)` back to
|
|
// `all.append(arena, ...)` in cmd_aggregate and this goes red.
|
|
var threaded = std.Io.Threaded.init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
|
|
// Insert out of order so a missing sort is visible, not coincidental.
|
|
try dispatch_insert(&tdb, io, "agg", &.{
|
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 30 } } } },
|
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .int32 = 10 } } } },
|
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "x", .value = .{ .int32 = 20 } } } },
|
|
});
|
|
|
|
const sort_stage = bson.Value{ .doc = &.{.{ .key = "$sort", .value = .{ .doc = &.{.{ .key = "x", .value = .{ .int32 = 1 } }} } }} };
|
|
const stages = [_]bson.Value{sort_stage};
|
|
|
|
var ctx = tdb.ctx(io);
|
|
var msg = try parse_fake_msg("aggregate", .{ .string = "agg" }, &.{
|
|
.{ .key = "pipeline", .value = .{ .array = &stages } },
|
|
.{ .key = "cursor", .value = .{ .doc = &.{} } },
|
|
});
|
|
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,
|
|
};
|
|
try testing.expectEqual(@as(usize, 3), batch.len);
|
|
// Ascending by x means _id order 2, 3, 1.
|
|
const want = [_]i32{ 2, 3, 1 };
|
|
for (batch, want) |d, id| {
|
|
try testing.expectEqual(id, bson.get_pair(d.doc, "_id").?.int32);
|
|
}
|
|
}
|
|
|
|
test "count_only_pipeline accepts only shapes a count can answer" {
|
|
// The fast path skips materializing documents, so mis-accepting a
|
|
// pipeline would silently return a wrong aggregate rather than a slow
|
|
// one. Pin exactly which shapes it claims.
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
|
|
const group_count = bson.Value{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
|
|
.{ .key = "_id", .value = .null },
|
|
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } },
|
|
} } }} };
|
|
const match_k = bson.Value{ .doc = &.{.{ .key = "$match", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 2 } }} } }} };
|
|
|
|
// Accepted: the two shapes countDocuments() produces.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{group_count}) != null);
|
|
const with_match = try count_only_pipeline(&reply, &.{ match_k, group_count });
|
|
try testing.expect(with_match != null);
|
|
try testing.expectEqual(@as(usize, 1), with_match.?.filter.len);
|
|
try testing.expectEqualStrings("k", with_match.?.filter[0].key);
|
|
|
|
// Rejected: grouping by a field value needs the documents.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
|
|
.{ .key = "_id", .value = .{ .string = "$k" } },
|
|
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .int32 = 1 } }} } },
|
|
} } }} }}) == null);
|
|
|
|
// Rejected: summing a field, not a constant.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
|
|
.{ .key = "_id", .value = .null },
|
|
.{ .key = "t", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .string = "$x" } }} } },
|
|
} } }} }}) == null);
|
|
|
|
// Rejected: an accumulator we do not model at all.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{.{ .doc = &.{.{ .key = "$group", .value = .{ .doc = &.{
|
|
.{ .key = "_id", .value = .null },
|
|
.{ .key = "m", .value = .{ .doc = &.{.{ .key = "$max", .value = .{ .string = "$x" } }} } },
|
|
} } }} }}) == null);
|
|
|
|
// Rejected: any extra stage, since it could reshape the result.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{ match_k, group_count, .{ .doc = &.{.{ .key = "$limit", .value = .{ .int32 = 1 } }} } }) == null);
|
|
// Rejected: a leading stage that is not $match.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{ .{ .doc = &.{.{ .key = "$sort", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 1 } }} } }} }, group_count }) == null);
|
|
// Rejected: empty pipeline.
|
|
try testing.expect(try count_only_pipeline(&reply, &.{}) == null);
|
|
}
|
|
|
|
test "a sorted full scan over a multikey index returns each document once" {
|
|
// scan_sorted streams a whole-index read instead of materializing it, which
|
|
// is what keeps a countDocuments({}) from building a list of every offset in
|
|
// the collection. But one document contributes several entries to a multikey
|
|
// index, so walking that index end to end yields it once per array element.
|
|
// The materializing path deduped; a stream cannot, so Plan.full_scan()
|
|
// refuses multikey indexes and this shape keeps materializing.
|
|
//
|
|
// The shape is `find({}).sort({tags: 1})`: no filter, so the only reason to
|
|
// use an index at all is that it supplies the order.
|
|
//
|
|
// Mutation check, and it is worth stating precisely because the obvious
|
|
// version of it does nothing: two independent guards refuse this, so
|
|
// removing either one alone leaves the test green. `index_provides_sort`
|
|
// returns null for a multikey index, and `Plan.full_scan` refuses one
|
|
// again. Remove *both* and each document comes back three times. So this
|
|
// test pins the pair, not either guard -- which is the useful property,
|
|
// since it is the behaviour that matters rather than which check delivers
|
|
// it.
|
|
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_insert(&tdb, io, "mk", &.{
|
|
.{ .doc = &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
|
.{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, .{ .int32 = 3 } } } },
|
|
} },
|
|
.{ .doc = &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 2 } },
|
|
.{ .key = "tags", .value = .{ .array = &.{ .{ .int32 = 4 }, .{ .int32 = 5 }, .{ .int32 = 6 } } } },
|
|
} },
|
|
});
|
|
try dispatch_create_index(&tdb, io, "mk", .{ .doc = &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "name", .value = .{ .string = "tags_1" } },
|
|
} });
|
|
|
|
var ctx = tdb.ctx(io);
|
|
var msg = try parse_fake_msg("find", .{ .string = "mk" }, &.{
|
|
.{ .key = "filter", .value = .{ .doc = &.{} } },
|
|
.{ .key = "sort", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .int32 = 1 } }} } },
|
|
});
|
|
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 => |arr| arr,
|
|
else => return error.TestUnexpectedResult,
|
|
};
|
|
// Two documents, not six.
|
|
try testing.expectEqual(@as(usize, 2), batch.len);
|
|
}
|
|
|
|
test "a command with no collection name errors and holds no lock" {
|
|
// Regression for a leaked catalog lock. dispatch resolved the namespace
|
|
// *after* taking the catalog lock, with `orelse return` -- and a plain
|
|
// return runs neither the errdefer nor the explicit unlocks, so the lock
|
|
// was held shared forever. `db.aggregate(...)` sends {aggregate: 1}, whose
|
|
// value is not a string, so it reached exactly that path.
|
|
//
|
|
// Two assertions, because the first alone would have passed before the fix
|
|
// for the wrong reason: the reply must be a real error (it used to be an
|
|
// empty document, which a driver reports as the useless "n/a"), and a
|
|
// subsequent write that has to take the catalog exclusive to create a
|
|
// collection must still complete. The second is the lock check.
|
|
//
|
|
// Mutation check: restore the `orelse return` pair after the lock
|
|
// acquisition and this test hangs on the insert rather than failing -- the
|
|
// suite times out. That is a deadlock, so it cannot be asserted more
|
|
// politely from a single fiber; the e2e suite covers the same sequence
|
|
// where a hang surfaces as a client timeout instead.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
var tdb = try TestDb.init(io);
|
|
defer tdb.deinit();
|
|
|
|
{
|
|
var ctx = tdb.ctx(io);
|
|
// {aggregate: 1} -- a database-level aggregate, no collection named.
|
|
var msg = try parse_fake_msg("aggregate", .{ .int32 = 1 }, &.{
|
|
.{ .key = "pipeline", .value = .{ .array = &.{} } },
|
|
.{ .key = "cursor", .value = .{ .doc = &.{} } },
|
|
});
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
// A proper error, not an empty reply.
|
|
try testing.expectEqual(@as(f64, 0.0), bson.get_pair(reply.pairs.items, "ok").?.double);
|
|
try testing.expectEqual(
|
|
@as(i32, @intFromEnum(ErrorCode.bad_value)),
|
|
bson.get_pair(reply.pairs.items, "code").?.int32,
|
|
);
|
|
}
|
|
|
|
// The lock assertion: this insert creates a collection, which upgrades the
|
|
// catalog lock to exclusive. With the lock leaked it never returns.
|
|
try dispatch_insert(&tdb, io, "after", &.{
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
|
|
});
|
|
}
|
|
|
|
test "compare-equal _id encodings collide under the unique _id_ index" {
|
|
// _id uniqueness moved from a docs-map probe keyed on serialize_value to
|
|
// the _id_ B+tree, keyed on the canonical bson.encode_key (PLAN A3/A4).
|
|
// That changes observable behavior, in MongoDB's direction: int32 1,
|
|
// int64 1 and double 1.0 are one _id, not three.
|
|
//
|
|
// Mutation check: setting `unique = false` back on id_index makes these
|
|
// collisions vanish (this test and "insert counts only successful inserts"
|
|
// both go red).
|
|
//
|
|
// The other half of the change is covered elsewhere, which is worth
|
|
// knowing so nobody re-checks it here: passing `id_key` instead of null as
|
|
// check_unique's exclude on the insert path is caught by "insert counts
|
|
// only successful inserts", not by this test. Two documents with the
|
|
// *same* encoding share an id_key, so exclude-self hides the collision;
|
|
// int32 1 and int64 1 have different id_keys, so this test survives that
|
|
// mutation. The two tests are complementary, not redundant.
|
|
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_insert(&tdb, io, "ids", &.{
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
|
|
});
|
|
|
|
// Each of these is the same _id as int32 1.
|
|
for ([_]bson.Value{
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int64 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .double = 1.0 } }} },
|
|
}) |dup| {
|
|
var ctx = tdb.ctx(io);
|
|
const one = [_]bson.Value{dup};
|
|
var msg = try parse_fake_msg("insert", .{ .string = "ids" }, &.{
|
|
.{ .key = "documents", .value = .{ .array = &one } },
|
|
});
|
|
defer msg.deinit();
|
|
var reply = wire.Reply.init(testing.allocator);
|
|
defer reply.deinit();
|
|
try dispatch(&ctx, &msg, &reply);
|
|
const we = bson.get_pair(reply.pairs.items, "writeErrors") orelse return error.TestUnexpectedResult;
|
|
const first = we.array[0];
|
|
try testing.expectEqual(@as(i32, 11000), bson.get_pair(first.doc, "code").?.int32);
|
|
// The index named in the message is the one MongoDB names.
|
|
const errmsg = bson.get_pair(first.doc, "errmsg").?.string;
|
|
try testing.expect(std.mem.indexOf(u8, errmsg, "_id_") != null);
|
|
}
|
|
|
|
// A different rank is a different _id: "1" is not 1.
|
|
try dispatch_insert(&tdb, io, "ids", &.{
|
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .string = "1" } }} },
|
|
});
|
|
|
|
// And the collision is not merely rejection: a lookup by any of the
|
|
// equivalent encodings finds the one stored document.
|
|
for ([_]bson.Value{ .{ .int32 = 1 }, .{ .int64 = 1 }, .{ .double = 1.0 } }) |probe| {
|
|
var ids: std.ArrayListUnmanaged([]u8) = .empty;
|
|
defer {
|
|
for (ids.items) |id| testing.allocator.free(id);
|
|
ids.deinit(testing.allocator);
|
|
}
|
|
try dispatch_find_ids(&tdb, io, "ids", &.{.{ .key = "_id", .value = probe }}, &ids);
|
|
try testing.expectEqual(@as(usize, 1), ids.items.len);
|
|
}
|
|
}
|
|
|
|
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, arrays, nested docs,
|
|
// missing fields, explicit nulls, and duplicate values.
|
|
//
|
|
// It used to carry int32 1 and int64 1 as two documents, to exercise the
|
|
// old docs-map fast path (they compare equal but serialize differently).
|
|
// _id_ is a unique index now, keyed on the canonical bson.encode_key, so
|
|
// those two *are* the same _id and the second is rejected -- which is
|
|
// MongoDB's behavior. The int64 encoding still appears below, on a
|
|
// distinct value; the collision itself is asserted in "compare-equal _id
|
|
// encodings collide under the unique _id_ index".
|
|
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 = 2 } },
|
|
.{ .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];
|
|
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);
|
|
}
|
|
}
|