commands: distinct #4
34
PLAN.md
34
PLAN.md
@@ -467,7 +467,7 @@ answers, and the trade is only acceptable because the lie is removed first.
|
|||||||
| M1 | **Cursors + wire polish** | getMore / killCursors / batchSize; server-side cursor state with idle timeout; sessions plumbing (lsid accepted) as drivers send it; hello advertisement updates; **`moreToCome` on requests** (see the bug below); command-monitoring assertions in the spec runner | crud spec suite green; e2e green |
|
| M1 | **Cursors + wire polish** | getMore / killCursors / batchSize; server-side cursor state with idle timeout; sessions plumbing (lsid accepted) as drivers send it; hello advertisement updates; **`moreToCome` on requests** (see the bug below); command-monitoring assertions in the spec runner | crud spec suite green; e2e green |
|
||||||
| M2 | **The `aggregate` command surface** | `$out` and `$merge` (7 of the 13 failures), and refusing every pipeline construct the engine does not implement instead of answering `0` (amendment A6). The other 6 failures are blocked on M2.5, M4 and M8 — see `docs/M2_DESIGN_REVIEW.md` §7 | `aggregate-*.json`: 0 fail among the 7 reachable cases |
|
| M2 | **The `aggregate` command surface** | `$out` and `$merge` (7 of the 13 failures), and refusing every pipeline construct the engine does not implement instead of answering `0` (amendment A6). The other 6 failures are blocked on M2.5, M4 and M8 — see `docs/M2_DESIGN_REVIEW.md` §7 | `aggregate-*.json`: 0 fail among the 7 reachable cases |
|
||||||
| M2.5 | **The aggregation engine** | expression evaluator, per-stage document iterator, the accumulators, `$unwind`; `$lookup`/`$facet` explicitly out of the first cut (amendment A6) | a purpose-built stage corpus, every expectation measured against mongod |
|
| M2.5 | **The aggregation engine** | expression evaluator, per-stage document iterator, the accumulators, `$unwind`; `$lookup`/`$facet` explicitly out of the first cut (amendment A6) | a purpose-built stage corpus, every expectation measured against mongod |
|
||||||
| M3 | **Update operators + index types** | $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate, pipeline updates; partial + hashed indexes | remaining crud coverage; e2e3/e2e4 green |
|
| M3 | **Update operators + index types** | `distinct` (**done**); then $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate, `arrayFilters`, pipeline updates; partial + hashed indexes | remaining crud coverage; e2e3/e2e4 green |
|
||||||
| M4 | **Sessions + transactions** | logical sessions, snapshot isolation on the mmap engine, write concern at commit | sessions + transactions spec suites green |
|
| M4 | **Sessions + transactions** | logical sessions, snapshot isolation on the mmap engine, write concern at commit | sessions + transactions spec suites green |
|
||||||
| M5 | **Change streams** | change feed + resume tokens (likely log-seq based), getMore integration | change-streams spec suite green |
|
| M5 | **Change streams** | change feed + resume tokens (likely log-seq based), getMore integration | change-streams spec suite green |
|
||||||
| M6 | **Admin/ops commands** | dbStats, collStats, serverStatus, ping, buildInfo, listDatabases filters, dropDatabase durability (log it) | mongosh UX smoke; e2e green |
|
| M6 | **Admin/ops commands** | dbStats, collStats, serverStatus, ping, buildInfo, listDatabases filters, dropDatabase durability (log it) | mongosh UX smoke; e2e green |
|
||||||
@@ -585,6 +585,11 @@ gaps (`bad update`, `update must be a document` — M3), unimplemented commands
|
|||||||
(`distinct`, `$merge`, `$out` — M2/M3), and result-shape mismatches in
|
(`distinct`, `$merge`, `$out` — M2/M3), and result-shape mismatches in
|
||||||
`bulkWrite`/`insertMany`. That list, not the total, is the milestone backlog.
|
`bulkWrite`/`insertMany`. That list, not the total, is the milestone backlog.
|
||||||
|
|
||||||
|
All three named commands have since landed: `$out`/`$merge` in M2, `distinct`
|
||||||
|
as M3's first commit. The backlog the same grouping gives today is
|
||||||
|
`arrayFilters` (14 cases), the `findOneAndUpdate`/`findOneAndReplace` shapes
|
||||||
|
(~10), pipeline-form updates (~10), and `create-null-ids` (6).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Ground rules (inherited and new)
|
## 4. Ground rules (inherited and new)
|
||||||
@@ -1040,6 +1045,33 @@ has to be its own commit with its own re-recorded scorecard.
|
|||||||
`$out`/`$merge` durability semantics, whether the expression evaluator is
|
`$out`/`$merge` durability semantics, whether the expression evaluator is
|
||||||
shared with M3's pipeline updates, and whether `allowDiskUse` has to stop
|
shared with M3's pipeline updates, and whether `allowDiskUse` has to stop
|
||||||
being a lie.
|
being a lie.
|
||||||
|
- **M3 update operators** — open. `distinct` landed first because it was a
|
||||||
|
whole missing command with no dependencies, and measuring it turned up three
|
||||||
|
things worth keeping, none of which are `distinct`'s to fix:
|
||||||
|
- **An unknown query operator matches nothing instead of erroring.**
|
||||||
|
`{x: {$bogus: 1}}` answers `ok: 1` with an empty result on `find`, `count`
|
||||||
|
and `aggregate` alike, where mongod answers `BadValue` (2) "unknown
|
||||||
|
operator: $bogus" on all three. Measured on both servers side by side.
|
||||||
|
This is the same class as M2's six silent wrong answers — a typo'd
|
||||||
|
operator reads as "no matches" — and it lives in the shared query path, so
|
||||||
|
it is one fix for every command rather than one per command. It predates
|
||||||
|
`distinct` and is not in its commit for that reason.
|
||||||
|
- **A non-string collection name is `BadValue` where mongod says
|
||||||
|
`InvalidNamespace` (73).** Dispatch refuses it while resolving the lock
|
||||||
|
target, before any handler runs, so this is one answer for the whole
|
||||||
|
command table. Worth correcting as its own commit, with the codes measured
|
||||||
|
per command — three `db.aggregate()` cases in the corpus fail on the same
|
||||||
|
message for an unrelated reason (they name no collection at all, M4).
|
||||||
|
- **`distinct` is unbounded in memory**, holding every value at the key
|
||||||
|
before deduping. Joins `$group` and `$sort` in that list; mongod caps the
|
||||||
|
reply at 16 MB, which is a different and cheaper answer than spilling.
|
||||||
|
|
||||||
|
Left deliberately: `distinct-comment.json`'s "pre 4.4, server error" case
|
||||||
|
wants a *document*-valued `comment` refused, because this server reports
|
||||||
|
4.4.0 and mongod only accepted one from 4.4.14. `estimatedDocumentCount`
|
||||||
|
already carries the identical standing failure, and emulating a bug fixed in
|
||||||
|
4.4.14 for one command would make the two disagree. `distinct-collation`
|
||||||
|
needs M8 and now fails with the honest "expected 1 elements, got 2".
|
||||||
- **M4 transactions**: snapshot isolation over mmap (COW vs undo), read
|
- **M4 transactions**: snapshot isolation over mmap (COW vs undo), read
|
||||||
concern snapshot, conflict → TransientTransactionError semantics,
|
concern snapshot, conflict → TransientTransactionError semantics,
|
||||||
retryable-writes interplay.
|
retryable-writes interplay.
|
||||||
|
|||||||
388
src/commands.zig
388
src/commands.zig
@@ -187,6 +187,7 @@ const command_table = [_]Command{
|
|||||||
.handler = cmd_get_more,
|
.handler = cmd_get_more,
|
||||||
},
|
},
|
||||||
.{ .name = "count", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_count },
|
.{ .name = "count", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_count },
|
||||||
|
.{ .name = "distinct", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_distinct },
|
||||||
.{ .name = "aggregate", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_aggregate },
|
.{ .name = "aggregate", .kind = .read, .locks = .{ .catalog = .shared, .coll = .shared }, .handler = cmd_aggregate },
|
||||||
.{ .name = "listDatabases", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_databases },
|
.{ .name = "listDatabases", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_databases },
|
||||||
.{ .name = "listCollections", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_collections },
|
.{ .name = "listCollections", .kind = .read, .locks = .{ .catalog = .shared }, .handler = cmd_list_collections },
|
||||||
@@ -2141,6 +2142,119 @@ fn cmd_count(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
|||||||
try reply.put_ok();
|
try reply.put_ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `distinct` is the one read command whose answer is a *set*, and both halves
|
||||||
|
/// of what that means were measured against mongod 8.3.7 rather than recalled:
|
||||||
|
///
|
||||||
|
/// - the values come back **sorted in canonical BSON order**, not in the
|
||||||
|
/// order they were met. `{s: "a"}, {s: "b"}, {s: null}` answers
|
||||||
|
/// `[null, "a", "b"]` -- null ahead of the strings, because that is where
|
||||||
|
/// its type ranks.
|
||||||
|
/// - deduping uses the same comparator, so an int32 `1` and a double `1.0`
|
||||||
|
/// collapse into one value while `null` and `"1"` stay distinct.
|
||||||
|
///
|
||||||
|
/// Both fall out of `bson.compare`, the comparator `$sort` and `$min` already
|
||||||
|
/// use, and that is not a coincidence: mongod accumulates into a
|
||||||
|
/// `BSONElementSet` ordered by the same `woCompare`. Sorting was the part
|
||||||
|
/// worth measuring -- insertion order is the obvious guess and it is wrong.
|
||||||
|
///
|
||||||
|
/// Unbounded in memory, like `$group` and `$sort`: every value at the key is
|
||||||
|
/// held before the answer is deduped. mongod caps the reply at 16 MB instead;
|
||||||
|
/// recorded in PLAN §6 with the other two rather than solved here.
|
||||||
|
fn cmd_distinct(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
||||||
|
const db_name = msg.db_name() orelse return invalid_arg(reply, "distinct requires $db");
|
||||||
|
// A non-string collection name never reaches here: dispatch refuses it
|
||||||
|
// with BadValue while resolving the lock target, where mongod answers
|
||||||
|
// InvalidNamespace (73). Left alone deliberately -- that answer is
|
||||||
|
// dispatch's for every command, and changing it is its own measurement.
|
||||||
|
const coll_name = str_arg(msg.body.get("distinct")) orelse
|
||||||
|
return bad_value(reply, "distinct requires a collection name");
|
||||||
|
|
||||||
|
const key_value = msg.body.get("key") orelse return reply.put_error(
|
||||||
|
@intFromEnum(ErrorCode.idl_failed_to_parse),
|
||||||
|
"IDLFailedToParse",
|
||||||
|
"BSON field 'distinctCommandRequest.key' is missing but a required field",
|
||||||
|
);
|
||||||
|
const key = str_arg(key_value) orelse return distinct_wrong_type(reply, "key", key_value, "string");
|
||||||
|
|
||||||
|
// `query` absent, and `query: null`, are both an empty filter -- measured,
|
||||||
|
// and the second is not guessable from the first. Anything else that is
|
||||||
|
// not a document is a TypeMismatch, which is stricter than `count` is
|
||||||
|
// about its own `query`, because mongod parses this one through its IDL.
|
||||||
|
const query_value: bson.Value = msg.body.get("query") orelse .null;
|
||||||
|
const filter: []const bson.Pair = switch (query_value) {
|
||||||
|
.doc => |pairs| pairs,
|
||||||
|
.null => &.{},
|
||||||
|
else => return distinct_wrong_type(reply, "query", query_value, "object"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// An absent collection -- or database -- is an empty set, not an error.
|
||||||
|
// Measured, and the same answer `aggregate` gives.
|
||||||
|
const coll = ctx.engine.get_collection(db_name, coll_name) orelse {
|
||||||
|
try reply.put("values", .{ .array = &.{} });
|
||||||
|
return reply.put_ok();
|
||||||
|
};
|
||||||
|
var offs: std.ArrayListUnmanaged(u64) = .empty;
|
||||||
|
defer offs.deinit(ctx.gpa);
|
||||||
|
_ = try scan_matching(ctx, db_name, coll_name, filter, 0, &offs);
|
||||||
|
|
||||||
|
// The values outlive this frame inside the reply, so they are built in its
|
||||||
|
// arena; the collection lock dispatch is holding is what keeps the slab
|
||||||
|
// bytes they were read from mapped for the duration.
|
||||||
|
const arena = reply.arena_alloc();
|
||||||
|
var values: std.ArrayListUnmanaged(bson.Value) = .empty;
|
||||||
|
var at_key: std.ArrayListUnmanaged(bson.Value) = .empty;
|
||||||
|
for (offs.items) |off| {
|
||||||
|
at_key.clearRetainingCapacity();
|
||||||
|
// Byte-walked, and the same traversal the matcher and the index use:
|
||||||
|
// a path through an array of subdocuments collects from each of them.
|
||||||
|
try query.collect_values_bytes(arena, coll.doc_bytes(off), key, &at_key, 0);
|
||||||
|
for (at_key.items) |v| switch (v) {
|
||||||
|
// A terminal array contributes its elements rather than itself,
|
||||||
|
// and exactly one level deep: `[[7, 8], 9]` answers `[7, 8]` and
|
||||||
|
// `9`, never 7 and 8. The interior of the path is already unwound
|
||||||
|
// by `collect_values_bytes`, so this only ever sees the last
|
||||||
|
// segment's value.
|
||||||
|
.array => |items| try values.appendSlice(arena, items),
|
||||||
|
else => try values.append(arena, v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort, then collapse equal neighbours -- see the note above for why both
|
||||||
|
// steps use `bson.compare`. `std.mem.sort` is stable, so when several
|
||||||
|
// documents spell one value differently (`1` and `1.0`) the representation
|
||||||
|
// that survives is the one the scan met first.
|
||||||
|
std.mem.sort(bson.Value, values.items, {}, value_less);
|
||||||
|
var n: usize = 0;
|
||||||
|
for (values.items) |v| {
|
||||||
|
if (n > 0 and bson.compare(values.items[n - 1], v) == .eq) continue;
|
||||||
|
values.items[n] = v;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
try reply.put("values", .{ .array = values.items[0..n] });
|
||||||
|
try reply.put_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_less(_: void, a: bson.Value, b: bson.Value) bool {
|
||||||
|
return bson.compare(a, b) == .lt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shape mongod's IDL parser reports a wrong-typed command field in. A
|
||||||
|
/// driver that matches on the text is matching on this, so it is reproduced
|
||||||
|
/// rather than paraphrased.
|
||||||
|
fn distinct_wrong_type(
|
||||||
|
reply: *wire.Reply,
|
||||||
|
field: []const u8,
|
||||||
|
got: bson.Value,
|
||||||
|
want: []const u8,
|
||||||
|
) !void {
|
||||||
|
const text = try std.fmt.allocPrint(
|
||||||
|
reply.arena_alloc(),
|
||||||
|
"BSON field 'distinctCommandRequest.{s}' is the wrong type '{s}', expected type '{s}'",
|
||||||
|
.{ field, got.type_name(), want },
|
||||||
|
);
|
||||||
|
return reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text);
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_aggregate(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
|
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 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 coll_name = str_arg(msg.body.get("aggregate")) orelse return bad_value(reply, "aggregate requires a collection name");
|
||||||
@@ -5106,6 +5220,280 @@ fn clear_id_list(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged([]u8)) vo
|
|||||||
list.clearRetainingCapacity();
|
list.clearRetainingCapacity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs `distinct` and hands back its `values`. The caller owns `reply`,
|
||||||
|
/// because the values are allocated in the reply's arena.
|
||||||
|
fn distinct_values(
|
||||||
|
tdb: *TestDb,
|
||||||
|
io: std.Io,
|
||||||
|
reply: *wire.Reply,
|
||||||
|
coll: []const u8,
|
||||||
|
extra: []const bson.Pair,
|
||||||
|
) ![]const bson.Value {
|
||||||
|
var ctx = tdb.ctx(io);
|
||||||
|
var msg = try parse_fake_msg("distinct", .{ .string = coll }, extra);
|
||||||
|
defer msg.deinit();
|
||||||
|
try dispatch(&ctx, &msg, reply);
|
||||||
|
return switch (bson.get_pair(reply.pairs.items, "values") orelse return error.TestUnexpectedResult) {
|
||||||
|
.array => |a| a,
|
||||||
|
else => error.TestUnexpectedResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct answers a sorted set, not the order it met the values" {
|
||||||
|
// The load-bearing property, and the one that is not guessable: mongod
|
||||||
|
// sorts the answer in canonical BSON order. Insertion order is the
|
||||||
|
// obvious implementation and it is wrong -- so the documents here are
|
||||||
|
// seeded in an order that tells the two apart, and `null` is included
|
||||||
|
// because its type ranks below strings and would otherwise trail them.
|
||||||
|
//
|
||||||
|
// Mutation check: delete the `std.mem.sort` in cmd_distinct and this
|
||||||
|
// reads ["b", "a", null].
|
||||||
|
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, "d", &.{
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "s", .value = .{ .string = "b" } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "s", .value = .{ .string = "a" } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "s", .value = .null } } },
|
||||||
|
// No `s` at all: contributes nothing, where an explicit null contributes
|
||||||
|
// null. Measured -- the two are not the same absence.
|
||||||
|
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 4 } }} },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 5 } }, .{ .key = "s", .value = .{ .string = "a" } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, "d", &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = "s" } },
|
||||||
|
});
|
||||||
|
try testing.expectEqual(@as(usize, 3), values.len);
|
||||||
|
try testing.expect(values[0] == .null);
|
||||||
|
try testing.expectEqualStrings("a", values[1].string);
|
||||||
|
try testing.expectEqualStrings("b", values[2].string);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct unwinds a terminal array exactly one level" {
|
||||||
|
// `[[7, 8], 9]` answers `[7, 8]` and `9` -- the inner array is a value,
|
||||||
|
// not something to descend into. Both halves are measured, and both
|
||||||
|
// mutations are visible here: no unwinding at all makes `[1, 2, 2]` a
|
||||||
|
// value, and recursive unwinding turns `[7, 8]` into 7 and 8.
|
||||||
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
var tdb = try TestDb.init(io);
|
||||||
|
defer tdb.deinit();
|
||||||
|
|
||||||
|
const inner = [_]bson.Value{ .{ .int32 = 7 }, .{ .int32 = 8 } };
|
||||||
|
try dispatch_insert(&tdb, io, "d", &.{
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
||||||
|
.{ .key = "arr", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, .{ .int32 = 2 } } } },
|
||||||
|
} },
|
||||||
|
// An empty array contributes nothing, the way a missing field does.
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 2 } },
|
||||||
|
.{ .key = "arr", .value = .{ .array = &.{} } },
|
||||||
|
} },
|
||||||
|
// A non-array at the key is itself one value.
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 3 } },
|
||||||
|
.{ .key = "arr", .value = .{ .string = "not an array" } },
|
||||||
|
} },
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 4 } },
|
||||||
|
.{ .key = "arr", .value = .{ .array = &.{ .{ .array = &inner }, .{ .int32 = 9 } } } },
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, "d", &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = "arr" } },
|
||||||
|
});
|
||||||
|
// Canonical order again: the numbers, then the string, then the array.
|
||||||
|
try testing.expectEqual(@as(usize, 5), values.len);
|
||||||
|
try testing.expectEqual(@as(i32, 1), values[0].int32);
|
||||||
|
try testing.expectEqual(@as(i32, 2), values[1].int32);
|
||||||
|
try testing.expectEqual(@as(i32, 9), values[2].int32);
|
||||||
|
try testing.expectEqualStrings("not an array", values[3].string);
|
||||||
|
try testing.expectEqual(@as(usize, 2), values[4].array.len);
|
||||||
|
try testing.expectEqual(@as(i32, 7), values[4].array[0].int32);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct dedupes by value, so an int 1 and a double 1.0 are one" {
|
||||||
|
// Deduping and sorting are the same comparator, which is why this falls
|
||||||
|
// out for free -- and why `null` and `"1"` survive alongside it. The
|
||||||
|
// surviving spelling is the first the scan met, because the sort is
|
||||||
|
// stable; a switch to an unstable sort would make this arbitrary.
|
||||||
|
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, "d", &.{
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "m", .value = .{ .int32 = 1 } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "m", .value = .{ .double = 1.0 } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "m", .value = .{ .string = "1" } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 4 } }, .{ .key = "m", .value = .null } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, "d", &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = "m" } },
|
||||||
|
});
|
||||||
|
try testing.expectEqual(@as(usize, 3), values.len);
|
||||||
|
try testing.expect(values[0] == .null);
|
||||||
|
try testing.expectEqual(@as(i32, 1), values[1].int32);
|
||||||
|
try testing.expectEqualStrings("1", values[2].string);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct traverses a path through an array of subdocuments" {
|
||||||
|
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, "d", &.{
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
||||||
|
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 5 } }} } },
|
||||||
|
} },
|
||||||
|
// Multikey: both subdocuments contribute, which is the same traversal
|
||||||
|
// the matcher and the index generator use.
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 2 } },
|
||||||
|
.{ .key = "n", .value = .{ .array = &.{
|
||||||
|
.{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 1 } }} },
|
||||||
|
.{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 2 } }} },
|
||||||
|
} } },
|
||||||
|
} },
|
||||||
|
.{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .int32 = 3 } },
|
||||||
|
.{ .key = "n", .value = .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 5 } }} } },
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, "d", &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = "n.d" } },
|
||||||
|
});
|
||||||
|
try testing.expectEqual(@as(usize, 3), values.len);
|
||||||
|
try testing.expectEqual(@as(i32, 1), values[0].int32);
|
||||||
|
try testing.expectEqual(@as(i32, 2), values[1].int32);
|
||||||
|
try testing.expectEqual(@as(i32, 5), values[2].int32);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct answers the empty set where it has nothing, rather than erroring" {
|
||||||
|
// Three separate ways of having nothing, all of them `ok: 1` with an empty
|
||||||
|
// array rather than an error. The empty key in particular reads like a
|
||||||
|
// malformed request and is not one.
|
||||||
|
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, "d", &.{
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 11 } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cases = [_]struct { coll: []const u8, key: []const u8 }{
|
||||||
|
.{ .coll = "no_such_collection", .key = "x" },
|
||||||
|
.{ .coll = "d", .key = "nothing_has_this" },
|
||||||
|
.{ .coll = "d", .key = "" },
|
||||||
|
};
|
||||||
|
for (cases) |c| {
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, c.coll, &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = c.key } },
|
||||||
|
});
|
||||||
|
try testing.expectEqual(@as(usize, 0), values.len);
|
||||||
|
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct refuses a malformed request with mongod's own codes" {
|
||||||
|
// Measured against mongod 8.3.7, not recalled: the missing `key` is an IDL
|
||||||
|
// parse failure (40414) while a wrong-typed one is a TypeMismatch (14),
|
||||||
|
// and a *null* query is an empty filter rather than a wrong type.
|
||||||
|
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);
|
||||||
|
|
||||||
|
const key_x = bson.Pair{ .key = "key", .value = .{ .string = "x" } };
|
||||||
|
try testing.expectEqual(@as(?i32, 40414), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{}));
|
||||||
|
try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{
|
||||||
|
.{ .key = "key", .value = .{ .int32 = 7 } },
|
||||||
|
}));
|
||||||
|
try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{
|
||||||
|
.{ .key = "key", .value = .{ .doc = &.{} } },
|
||||||
|
}));
|
||||||
|
try testing.expectEqual(@as(?i32, 14), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{
|
||||||
|
key_x, .{ .key = "query", .value = .{ .int32 = 7 } },
|
||||||
|
}));
|
||||||
|
try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{
|
||||||
|
key_x, .{ .key = "query", .value = .null },
|
||||||
|
}));
|
||||||
|
// An unknown top-level field is tolerated. mongod's IDL refuses it with
|
||||||
|
// 40415, but this server tolerates unknown fields on every command, and
|
||||||
|
// `comment` and `rawData` -- which the CRUD corpus requires be ignored --
|
||||||
|
// arrive through exactly this door.
|
||||||
|
try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "distinct", .{ .string = "d" }, &.{
|
||||||
|
key_x,
|
||||||
|
.{ .key = "comment", .value = .{ .string = "c" } },
|
||||||
|
.{ .key = "rawData", .value = .{ .bool = true } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The message text is mongod's, reproduced rather than paraphrased,
|
||||||
|
// because a driver that matches on it is matching on this.
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
var msg = try parse_fake_msg("distinct", .{ .string = "d" }, &.{});
|
||||||
|
defer msg.deinit();
|
||||||
|
try dispatch(&ctx, &msg, &reply);
|
||||||
|
try testing.expectEqualStrings(
|
||||||
|
"BSON field 'distinctCommandRequest.key' is missing but a required field",
|
||||||
|
bson.get_pair(reply.pairs.items, "errmsg").?.string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct applies its filter before collecting" {
|
||||||
|
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, "d", &.{
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "x", .value = .{ .int32 = 11 } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .int32 = 22 } } } },
|
||||||
|
.{ .doc = &.{ .{ .key = "_id", .value = .{ .int32 = 3 } }, .{ .key = "x", .value = .{ .int32 = 33 } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var reply = wire.Reply.init(testing.allocator);
|
||||||
|
defer reply.deinit();
|
||||||
|
const values = try distinct_values(&tdb, io, &reply, "d", &.{
|
||||||
|
.{ .key = "key", .value = .{ .string = "x" } },
|
||||||
|
.{ .key = "query", .value = .{ .doc = &.{
|
||||||
|
.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 1 } }} } },
|
||||||
|
} } },
|
||||||
|
});
|
||||||
|
try testing.expectEqual(@as(usize, 2), values.len);
|
||||||
|
try testing.expectEqual(@as(i32, 22), values[0].int32);
|
||||||
|
try testing.expectEqual(@as(i32, 33), values[1].int32);
|
||||||
|
}
|
||||||
|
|
||||||
test "aggregate $sort without a preceding $group sorts and frees correctly" {
|
test "aggregate $sort without a preceding $group sorts and frees correctly" {
|
||||||
// Regression test for a remote, client-triggerable invalid free: the
|
// Regression test for a remote, client-triggerable invalid free: the
|
||||||
// $sort stage materialized its document list from the reply arena and
|
// $sort stage materialized its document list from the reply arena and
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
# hasServerConnectionId, and maxTimeMS in an expected command (CSOT rewrites
|
# hasServerConnectionId, and maxTimeMS in an expected command (CSOT rewrites
|
||||||
# it -- the only assertion this runner declines to make).
|
# it -- the only assertion this runner declines to make).
|
||||||
|
|
||||||
total 201 pass 90 fail 196 skip 175 files 0 errored
|
total 204 pass 87 fail 196 skip 175 files 0 errored
|
||||||
|
|
||||||
# per-file: name pass fail skip
|
# per-file: name pass fail skip
|
||||||
aggregate-allowdiskuse.json 3 0 0
|
aggregate-allowdiskuse.json 3 0 0
|
||||||
@@ -110,8 +110,8 @@ deleteOne.json 3 0 0
|
|||||||
distinct-collation.json 0 1 0
|
distinct-collation.json 0 1 0
|
||||||
distinct-comment.json 1 1 1
|
distinct-comment.json 1 1 1
|
||||||
distinct-hint.json 0 0 2
|
distinct-hint.json 0 0 2
|
||||||
distinct-rawdata.json 0 1 1
|
distinct-rawdata.json 1 0 1
|
||||||
distinct.json 0 2 0
|
distinct.json 2 0 0
|
||||||
estimatedDocumentCount-comment.json 1 1 1
|
estimatedDocumentCount-comment.json 1 1 1
|
||||||
estimatedDocumentCount-rawdata.json 1 0 1
|
estimatedDocumentCount-rawdata.json 1 0 1
|
||||||
estimatedDocumentCount.json 2 1 3
|
estimatedDocumentCount.json 2 1 3
|
||||||
@@ -318,14 +318,11 @@ deleteOne-hint-unacknowledged.json SKIP Unacknowledged deleteOne with hint docum
|
|||||||
deleteOne-let.json SKIP deleteOne with let option needs server >= 5.0
|
deleteOne-let.json SKIP deleteOne with let option needs server >= 5.0
|
||||||
deleteOne-let.json FAIL deleteOne with let option unsupported (server-side error) deleteOne: expected an error, the operation succeeded
|
deleteOne-let.json FAIL deleteOne with let option unsupported (server-side error) deleteOne: expected an error, the operation succeeded
|
||||||
deleteOne-rawdata.json SKIP deleteOne with rawData option needs server >= 8.2.0
|
deleteOne-rawdata.json SKIP deleteOne with rawData option needs server >= 8.2.0
|
||||||
distinct-collation.json FAIL Distinct with a collation MongoServerError: no such command: 'distinct'
|
distinct-collation.json FAIL Distinct with a collation distinct: expected 1 elements, got 2
|
||||||
distinct-comment.json SKIP distinct with document comment needs server >= 4.4.14
|
distinct-comment.json SKIP distinct with document comment needs server >= 4.4.14
|
||||||
distinct-comment.json FAIL distinct with string comment MongoServerError: no such command: 'distinct'
|
distinct-comment.json FAIL distinct with document comment - pre 4.4, server error distinct: expected an error, the operation succeeded
|
||||||
distinct-hint.json SKIP * needs server >= 7.1.0
|
distinct-hint.json SKIP * needs server >= 7.1.0
|
||||||
distinct-rawdata.json SKIP distinct with rawData option needs server >= 8.2.0
|
distinct-rawdata.json SKIP distinct with rawData option needs server >= 8.2.0
|
||||||
distinct-rawdata.json FAIL distinct with rawData option on less than 8.2.0 - ignore argument MongoServerError: no such command: 'distinct'
|
|
||||||
distinct.json FAIL Distinct without a filter MongoServerError: no such command: 'distinct'
|
|
||||||
distinct.json FAIL Distinct with a filter MongoServerError: no such command: 'distinct'
|
|
||||||
estimatedDocumentCount-comment.json SKIP estimatedDocumentCount with document comment needs server >= 4.4.14
|
estimatedDocumentCount-comment.json SKIP estimatedDocumentCount with document comment needs server >= 4.4.14
|
||||||
estimatedDocumentCount-comment.json FAIL estimatedDocumentCount with document comment - pre 4.4.14, server error estimatedDocumentCount: expected an error, the operation succeeded
|
estimatedDocumentCount-comment.json FAIL estimatedDocumentCount with document comment - pre 4.4.14, server error estimatedDocumentCount: expected an error, the operation succeeded
|
||||||
estimatedDocumentCount-rawdata.json SKIP Estimated document count with rawData option needs server >= 8.2.0
|
estimatedDocumentCount-rawdata.json SKIP Estimated document count with rawData option needs server >= 8.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user