M3: the update operators, and 's modifiers #8

Merged
dev merged 9 commits from m3-operators into main 2026-08-10 18:41:26 +00:00
18 changed files with 2460 additions and 110 deletions

24
PLAN.md
View File

@@ -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 |
| 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 |
| M3 | **Update operators + index types** | `distinct` (**done**); positional paths refused rather than destructive (**done**); `$`/`$[]`/`$[<ident>]` + `arrayFilters` implemented (**done**); then $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate, pipeline updates; partial + hashed indexes | `tests/spec/positional/` 0 fail (51 cases, recorded from mongod — **green**; the named gate could not see this work, see below); remaining crud coverage; e2e3/e2e4 green |
| M3 | **Update operators + index types** | `distinct` (**done**); positional paths refused rather than destructive (**done**); `$`/`$[]`/`$[<ident>]` + `arrayFilters` (**done**); $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate + `$push`'s modifiers (**done**); then pipeline updates; partial + hashed indexes | `tests/spec/positional/` 0 fail (51 cases) and `tests/spec/operators/` 0 fail (102 cases), both recorded from mongod — **green**; the named gate could not see either, see below; 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 |
| 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 |
@@ -1123,6 +1123,28 @@ has to be its own commit with its own re-recorded scorecard.
agrees. Worth one commit, and it needs its own measurements first: the
padding case (`y.3.b` past the end) is a *legal* creation on mongod, so
the fix is not "refuse a non-document element".
- **M3's second corpus is `tests/spec/operators/`.** The eight operators PLAN
§3 names all answered `bad update` with code 2, one message for every
question — and `$push`'s `$slice`, `$position` and `$sort` were parsed,
accepted and *dropped*. `{$each: [3, 4], $slice: -3}` appended both values,
sliced nothing and answered ok: 1 with modifiedCount: 1, which is the same
class of wrong answer the positional operators were. 102 cases, recorded red
at 18/84 and driven green.
It found one thing that was nobody's operator: **an upsert never reported the
`_id` it generated**. `Engine.insert` writes a generated `_id` into the bytes
and leaves the caller's tree without it, so `upsertedId` came back null and
`findOneAndUpdate` with `returnDocument: after` returned a document with no
`_id` — the only name the client has for a document it has never seen. Fixed
in `build_upsert_doc`; the pinned crud corpus never noticed because its
upsert cases match `upsertedId` loosely.
Left open, measured and deliberate: `$bit` is not implemented (mongod has it,
no driver in `tests/` sends it, and it is not in the M3 row); `$sort` inside
`$push` accepts only a one-field key document, where mongod allows several;
and the conflict check stops after 64 distinct paths in one update rather
than refusing a legal wide update.
- **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:

View File

@@ -66,6 +66,10 @@ pub const ErrorCode = enum(i32) {
duplicate_key = 11000,
namespace_exists = 48,
failed_to_parse = 9,
/// `ConflictingUpdateOperators`, measured on mongod 8.3.7: two paths in
/// one update where either is a prefix of the other, so which of them
/// decides the result would depend on the order the operators ran in.
conflicting_update_operators = 40,
internal_error = 1,
/// "Unrecognized pipeline stage name". A `Location` code, so mongod names it
/// `Location40324` rather than after any symbol.
@@ -1977,6 +1981,7 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
"update.updates.arrayFilters",
) orelse return,
.query = q,
.now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(),
.diag = &diag,
};
// Before the scan, not after: an update naming an identifier nothing
@@ -1991,7 +1996,7 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
if (matched.items.len == 0) {
if (upsert) {
const new_doc = build_upsert_doc(reply, q, u_doc, opts) catch |err|
const new_doc = build_upsert_doc(reply, ctx, q, u_doc, opts) catch |err|
return update_refusal(reply, err, diag);
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),
@@ -2102,6 +2107,7 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
"findAndModify.arrayFilters",
) orelse return,
.query = q,
.now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(),
.diag = &diag,
};
if (doc_arg(msg.body.get("update"))) |u| {
@@ -2132,7 +2138,7 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
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 = build_upsert_doc(reply, q, u_doc, opts) catch |err|
const new_doc = build_upsert_doc(reply, ctx, q, u_doc, opts) catch |err|
return update_refusal(reply, err, diag);
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),
@@ -4253,6 +4259,97 @@ fn update_refusal(reply: *wire.Reply, err: anyerror, diag: update.Diagnostic) !v
"name, found '{s}' and '{s}'",
.{ diag.segment, diag.other },
)),
error.NotNumericField => return reply.put_error(
@intFromEnum(ErrorCode.type_mismatch),
"TypeMismatch",
try std.fmt.allocPrint(
arena,
"Cannot apply {s} to a value of non-numeric type. The field '{s}' is of " ++
"non-numeric type {s}",
.{ diag.segment, diag.path, diag.other },
),
),
error.NotNumericOperand => return reply.put_error(
@intFromEnum(ErrorCode.type_mismatch),
"TypeMismatch",
try std.fmt.allocPrint(
arena,
"Cannot {s} with non-numeric argument at field '{s}'",
.{ if (std.mem.eql(u8, diag.segment, "$inc")) "increment" else "multiply", diag.path },
),
),
error.NotAnArrayField => return bad_value(reply, try std.fmt.allocPrint(
arena,
"Cannot apply {s} to non-array field. Field named '{s}' has non-array type",
.{ diag.segment, diag.path },
)),
error.NotAnArrayPathElement => return reply.put_error(
@intFromEnum(ErrorCode.type_mismatch),
"TypeMismatch",
try std.fmt.allocPrint(
arena,
"Path '{s}' contains an element of non-array type",
.{diag.path},
),
),
// mongod has two sentences here -- "$pop expects 1 or -1, found: 2"
// and "Expected a number in: t: \"x\"" -- both code 9, and both about
// an argument that is not one of the two values `$pop` takes. One
// sentence covering both says the same thing without rendering the
// operand, which no formatter here does.
error.BadPopArgument => return failed_to_parse(reply, try std.fmt.allocPrint(
arena,
"$pop expects 1 or -1, at field '{s}'",
.{diag.path},
)),
error.PullAllNeedsArray => return bad_value(reply, try std.fmt.allocPrint(
arena,
"$pullAll requires an array argument but was given a {s}",
.{diag.other},
)),
// `$push` and `$addToSet` disagree about the code for the identical
// mistake: 2 and 14. Measured on both, and not derivable from either.
error.BadEach => if (std.mem.eql(u8, diag.segment, "$addToSet")) return reply.put_error(
@intFromEnum(ErrorCode.type_mismatch),
"TypeMismatch",
"The argument to $each in $addToSet must be an array",
) else return bad_value(reply, "The argument to $each in $push must be an array"),
error.BadPushModifier => return bad_value(reply, try std.fmt.allocPrint(
arena,
"Unrecognized or invalid $push modifier '{s}' at field '{s}'",
.{ diag.segment, diag.path },
)),
error.BadCurrentDateType => return bad_value(
reply,
"The '$type' string field is required to be 'date' or 'timestamp': " ++
"{$currentDate: {field : {$type: 'date'}}}",
),
error.BadCurrentDateOperand => return bad_value(reply, try std.fmt.allocPrint(
arena,
"{s} is not valid type for $currentDate. Please use a boolean ('true') or a " ++
"$type expression ({{$type: 'timestamp/date'}}).",
.{diag.other},
)),
error.UnknownModifier => return failed_to_parse(reply, try std.fmt.allocPrint(
arena,
"Unknown modifier: {s}. Expected a valid update modifier or pipeline-style " ++
"update specified as an array",
.{diag.segment},
)),
error.ModifierNeedsFields => return failed_to_parse(reply, try std.fmt.allocPrint(
arena,
"Modifiers operate on fields but we found type {s} instead",
.{diag.segment},
)),
error.ConflictingUpdate => return reply.put_error(
@intFromEnum(ErrorCode.conflicting_update_operators),
"ConflictingUpdateOperators",
try std.fmt.allocPrint(
arena,
"Updating the path '{s}' would create a conflict at '{s}'",
.{ diag.path, diag.segment },
),
),
error.PathNotViable => return reply.put_error(
@intFromEnum(ErrorCode.path_not_viable),
"PathNotViable",
@@ -4331,8 +4428,17 @@ fn wrong_type_dynamic(
/// Build the document for an upsert: equality fields from the filter, then
/// the update operators applied. Owned by the reply arena.
///
/// The `_id` is settled *here* rather than in the storage engine. `insert`
/// generates one into the bytes it writes and leaves the caller's tree without
/// it, so `updateOne(..., {upsert: true}).upsertedId` came back null and
/// `findOneAndUpdate` with `returnDocument: after` returned a document missing
/// its `_id`. Both are what the client is told about a document it has never
/// seen, and both were wrong. Found by `tests/spec/operators/`, which is the
/// first corpus here to upsert into an empty collection and then look.
fn build_upsert_doc(
reply: *wire.Reply,
ctx: *Context,
q: []const bson.Pair,
u_doc: []const bson.Pair,
opts: update.Options,
@@ -4350,7 +4456,21 @@ fn build_upsert_doc(
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.
try update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, opts);
// `inserting` is what `$setOnInsert` asks about, and this is the only
// caller that answers yes.
var insert_opts = opts;
insert_opts.inserting = true;
try update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, insert_opts);
// After the operators, because `$setOnInsert` may supply the `_id` itself
// and a generated one would then be the wrong answer. At the front,
// because that is where MongoDB stores it and where the `_id_` index
// descends on it.
if (owned.get("_id") == null) {
const with_id = try arena.alloc(bson.Pair, owned.pairs.len + 1);
with_id[0] = .{ .key = "_id", .value = .{ .object_id = ctx.oid_gen.new(ctx.io) } };
@memcpy(with_id[1..], owned.pairs);
owned.pairs = with_id;
}
return owned;
}
@@ -6981,3 +7101,57 @@ test "a rebuild kills an offsets cursor and spares a streaming one" {
try testing.expectEqual(@as(i64, 0), id);
try testing.expectEqual(@as(u32, 60), seen);
}
test "an upsert reports the _id it generated" {
// The client has never seen this document, so the `_id` in the reply is
// the only way it can name it again. `Engine.insert` generates one into
// the bytes it writes and leaves the caller's tree without it, so both
// `upserted` here and `findAndModify`'s returned document used to come
// back without an `_id` at all -- `upsertedId: null` on the driver.
//
// Mutation check: delete the `_id` block in `build_upsert_doc` and both
// halves go red.
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 updates = [_]bson.Value{.{ .doc = &.{
.{ .key = "q", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 1 } }} } },
.{ .key = "u", .value = .{ .doc = &.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
} } },
.{ .key = "upsert", .value = .{ .bool = true } },
} }};
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
var msg = try parse_fake_msg("update", .{ .string = "up" }, &.{
.{ .key = "updates", .value = .{ .array = &updates } },
});
defer msg.deinit();
try dispatch(&ctx, &msg, &reply);
const upserted = bson.get_pair(reply.pairs.items, "upserted").?.array;
try testing.expectEqual(@as(usize, 1), upserted.len);
try testing.expect(bson.get_pair(upserted[0].doc, "_id").? == .object_id);
// And an `_id` the update supplied itself is the one that is used, rather
// than being generated over.
const with_id = [_]bson.Value{.{ .doc = &.{
.{ .key = "q", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .int32 = 2 } }} } },
.{ .key = "u", .value = .{ .doc = &.{
.{ .key = "$setOnInsert", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 9 } }} } },
} } },
.{ .key = "upsert", .value = .{ .bool = true } },
} }};
var reply2 = wire.Reply.init(testing.allocator);
defer reply2.deinit();
var msg2 = try parse_fake_msg("update", .{ .string = "up" }, &.{
.{ .key = "updates", .value = .{ .array = &with_id } },
});
defer msg2.deinit();
try dispatch(&ctx, &msg2, &reply2);
const upserted2 = bson.get_pair(reply2.pairs.items, "upserted").?.array;
try testing.expectEqual(@as(i32, 9), bson.get_pair(upserted2[0].doc, "_id").?.int32);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
# The update-operator corpus
PLAN §3 lists eight update operators for M3 — `$setOnInsert`, `$addToSet`,
`$mul`, `$min`, `$max`, `$pop`, `$pullAll`, `$currentDate` — and the pinned
crud corpus says almost nothing about any of them. A probe running the
identical update against mongod 8.3.7 and this server found:
- all eight answering `bad update`, code 2, one message for every question;
- `$push`'s `$slice`, `$position` and `$sort` **silently ignored**.
`{$each: [3, 4], $slice: -3}` appended both values, sliced nothing, and
answered `ok: 1` with `modifiedCount: 1`.
The second is the reason this directory exists rather than a list of TODOs.
A missing operator is an error the client can see; a modifier that is parsed,
accepted and then dropped is the same class of wrong answer the positional
operators were — the client asked for one thing and was told it got it.
## The one rule
**Inputs are authored here; expectations are measured against a real mongod.**
```
tests/spec/operators/
sources/*.json documents + operations, authored
record.js runs them against mongod, writes the expectations
*.json generated, unified format, do not hand-edit
```
```sh
mongod --port 27099 --dbpath <dir>
node tests/spec/operators/record.js --mongod-port 27099
node tests/spec/run.js --suite-dir tests/spec/operators
```
Same discipline as `tests/spec/positional/` and `tests/spec/aggregate/`, and
for the same reason: a corpus written end to end here can encode our own bugs
as expectations and then agree with us forever.
## What cannot be recorded as a value
Two things in this corpus are not predictable, and both are replaced by a
`$$type` assertion rather than left out:
- a `$currentDate` field is whatever the clock said. The case names it in
`volatile`, per case, so a corpus that one day wants to pin a real stored
date still can.
- an upsert that inserts gets a generated ObjectId. That one is automatic:
no source file authors an ObjectId, so the rule is unambiguous.
Everything else about the document — which fields exist, in what order,
holding what — is still compared exactly. A case that dropped a field still
fails.
Files are written with canonical extended JSON (`relaxed: false`), which the
runner already parses that way. It is verbose and it is exact: `$mul`
overflowing an int32 produces an int64, and a corpus that wrote `4000000000`
as a bare number would not have said so.
## Where it stands
Recorded against mongod 8.3.7. Green:
```
array-ops.json 26 pass 0 fail 0 skip
current-date.json 11 pass 0 fail 0 skip
modifiers.json 14 pass 0 fail 0 skip
numeric.json 21 pass 0 fail 0 skip
push-modifiers.json 21 pass 0 fail 0 skip
set-on-insert.json 9 pass 0 fail 0 skip
```
It was recorded red — 18 pass / 84 fail against a server with none of these
operators — and driven green by seven commits. The 18 that passed then were
the shapes this server already answered mongod's way, mostly refusals that
happened to agree.
It also found a bug nothing else here had: an upsert never reported the `_id`
it generated, so `updateOne(..., {upsert: true}).upsertedId` was null and
`findOneAndUpdate` with `returnDocument: after` returned a document with no
`_id`. This is the first corpus here that upserts into an empty collection and
then looks at what came back.
## What recording it settled
None of this is guessable, and several rows contradict the obvious reading:
| | mongod |
|---|---|
| `$mul` of a missing field | writes **0**, not the operand |
| `$mul` of a non-numeric field, or by one | TypeMismatch (14) |
| `$min`/`$max` across types | compares in BSON canonical order, so `$min: {s: 5}` on `s: "b"` writes 5 |
| `$min`/`$max` of a missing field | always writes |
| two operators writing one field | **ConflictingUpdateOperators (40)**`$min`+`$max`, `$set`+`$inc`, `$setOnInsert`+`$set` |
| `$addToSet` of a document | compares whole, **field order included**: `{a:1,b:2}` and `{b:2,a:1}` are two values |
| `$addToSet` of `2` and `2.0` | one value |
| `$pop` of an empty or missing field | no-op, not an error |
| `$pop` with an argument that is not ±1 | FailedToParse (9); on a non-array field, TypeMismatch (14) |
| `$pullAll` with a non-array argument | BadValue (2) |
| `$push` modifiers without `$each` | **not modifiers at all**`{$slice: 1}` is pushed as a value |
| `$push` modifier order | insert at `$position`, then `$sort` the whole array, then `$slice` |
| `$position` negative | counted from the end |
| `$currentDate` with `false` | still writes a date; the boolean's value is ignored |
| `$currentDate` with anything but a bool or `{$type: date\|timestamp}` | BadValue (2) |
| `$setOnInsert` writing `_id` on an insert | **allowed**, unlike `$set` |
| an unknown modifier | FailedToParse (9), not BadValue |
One case was authored and then removed: `{b: 1, $set: {c: 1}}` is rejected by
the driver before it reaches a server, so there is no server answer to record
and the case would have asserted nothing.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,222 @@
// Record an update-operator corpus by asking a real mongod what the answer is.
//
// Eight operators PLAN §3 lists for M3 -- `$setOnInsert`, `$addToSet`, `$mul`,
// `$min`, `$max`, `$pop`, `$pullAll`, `$currentDate` -- plus `$push`'s
// `$slice`/`$position`/`$sort` modifiers. The pinned crud corpus contains
// almost nothing about any of them, and a probe against both servers found
// every one of the eight answering `bad update` here, and the three `$push`
// modifiers *silently ignored*: `{$each: [3, 4], $slice: -3}` appended without
// slicing and answered ok: 1. Same discipline as `tests/spec/positional/` and
// `tests/spec/aggregate/`: inputs are authored in `sources/`, expectations are
// measured here.
//
// node tests/spec/operators/record.js --mongod-port 27099
//
// Options:
// --mongod-port <n> a running mongod to measure against (default 27099)
// --only <name> record just one source file
const fs = require('fs');
const path = require('path');
// The same pinned driver `run.js` uses, resolved the same way.
const DRIVER = path.join(__dirname, '..', '..', 'e2e', 'node_modules', 'mongodb');
const { MongoClient } = require(DRIVER);
const { EJSON, ObjectId, Timestamp } = require(path.join(DRIVER, 'lib', 'bson.js'));
const argv = process.argv.slice(2);
function opt(name, dflt) {
const i = argv.indexOf('--' + name);
if (i < 0) return dflt;
const v = argv[i + 1];
return v === undefined || v.startsWith('--') ? true : v;
}
const PORT = parseInt(opt('mongod-port', '27099'), 10);
const ONLY = opt('only', null);
const SRC_DIR = path.join(__dirname, 'sources');
const DB_NAME = 'operator-corpus';
const COLL = 'coll';
// Every construct here predates 4.0, so nothing depends on a server newer than
// the 4.4 this one reports.
const SCHEMA_VERSION = '1.4';
const UPDATE_KEYS = ['matchedCount', 'modifiedCount', 'upsertedCount'];
/// Values nobody can predict, replaced by an assertion about their type.
///
/// Two kinds, and both are the corpus staying honest rather than the corpus
/// looking away: a `$currentDate` field is whatever the clock said, and an
/// upsert that inserts gets a generated ObjectId. Everything else about the
/// document -- which fields exist, in what order, holding what -- is still
/// compared exactly, so a case that lost a field still fails.
///
/// The ObjectId rule is automatic because it is unambiguous: no source file
/// authors one. A date is not: `volatile` names those per case, so a corpus
/// that one day wants to pin a real stored date still can.
function maskGenerated(value) {
if (value instanceof ObjectId) return { $$type: 'objectId' };
if (Array.isArray(value)) return value.map(maskGenerated);
if (value && typeof value === 'object' && value.constructor === Object) {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = maskGenerated(v);
return out;
}
return value;
}
function maskVolatile(docs, paths) {
for (const p of paths) {
for (const doc of docs) {
const segs = p.split('.');
let cur = doc;
for (const s of segs.slice(0, -1)) cur = cur === undefined ? undefined : cur[s];
const last = segs[segs.length - 1];
if (cur === undefined || !(last in cur)) {
throw new Error(`volatile path '${p}' is not in the recorded document ` +
`${JSON.stringify(doc)} -- the case did not write what it said it would`);
}
const v = cur[last];
if (v instanceof Date) cur[last] = { $$type: 'date' };
else if (v instanceof Timestamp) cur[last] = { $$type: 'timestamp' };
else throw new Error(`volatile path '${p}' holds ${v}, which is neither a date nor a timestamp`);
}
}
}
async function main() {
if (!fs.existsSync(SRC_DIR)) {
console.error(`missing ${SRC_DIR}`);
process.exit(2);
}
const client = new MongoClient(`mongodb://127.0.0.1:${PORT}`, { serverSelectionTimeoutMS: 3000 });
try {
await client.connect();
} catch (e) {
console.error(`no mongod on :${PORT} -- start one first:\n` +
` mongod --port ${PORT} --dbpath <dir>\n${e.message}`);
process.exit(2);
}
const build = await client.db('admin').command({ buildInfo: 1 });
console.log(`recording against mongod ${build.version} on :${PORT}`);
const sources = fs.readdirSync(SRC_DIR).filter((f) => f.endsWith('.json'))
.filter((f) => !ONLY || f === ONLY || f === ONLY + '.json')
.sort();
if (!sources.length) {
console.error('no source files');
process.exit(2);
}
for (const file of sources) {
const src = JSON.parse(fs.readFileSync(path.join(SRC_DIR, file), 'utf8'));
const name = path.basename(file, '.json');
const out = await record(client, name, src);
const text = EJSON.stringify(out, { relaxed: false, indent: 2 });
fs.writeFileSync(path.join(__dirname, `${name}.json`), text + '\n');
const errs = out.tests.filter((t) => t.operations[t.operations.length - 1].expectError).length;
console.log(` ${name}: ${out.tests.length} cases, ${errs} of them errors`);
}
await client.close();
console.log('RECORDED');
}
async function record(client, name, src) {
const coll = client.db(DB_NAME).collection(COLL);
const tests = [];
for (const c of src.cases) {
const documents = c.documents === undefined ? src.documents : c.documents;
await coll.drop().catch(() => {});
if (documents.length) await coll.insertMany(structuredClone(documents));
// A case that brings its own documents reseeds through *operations*:
// the unified format's `initialData` is per file, and the runner seeds
// it once per test.
const setup = [];
if (c.documents !== undefined) {
setup.push({ object: 'collection0', name: 'deleteMany', arguments: { filter: {} } });
if (documents.length) {
setup.push({
object: 'collection0',
name: 'insertMany',
arguments: { documents: structuredClone(documents) },
});
}
}
const op = {
object: 'collection0',
name: c.operation,
arguments: structuredClone(c.arguments),
};
try {
op.expectResult = maskGenerated(await invoke(coll, c.operation, structuredClone(c.arguments)));
} catch (e) {
// The code, not the message: message text is mongod's to change
// between releases, and several of these embed a rendering of the
// offending BSON that no formatter here produces.
delete op.expectResult;
op.expectError = { isError: true, errorCode: e.code };
}
// Recorded for every case including the refusals: a refusal that left
// the document half-written would look identical to a clean one in
// `expectError` alone.
const after = await coll.find({}, { sort: { _id: 1 } }).toArray();
if (c.volatile) maskVolatile(after, c.volatile);
tests.push({
description: c.description,
operations: [...setup, op],
outcome: [{
collectionName: COLL,
databaseName: DB_NAME,
documents: after.map(maskGenerated),
}],
});
}
await coll.drop().catch(() => {});
return {
description: name,
schemaVersion: SCHEMA_VERSION,
// Recorded, not authored. Regenerate with tests/spec/operators/record.js.
createEntities: [
{ client: { id: 'client0' } },
{ database: { id: 'database0', client: 'client0', databaseName: DB_NAME } },
{ collection: { id: 'collection0', database: 'database0', collectionName: COLL } },
],
initialData: [{ collectionName: COLL, databaseName: DB_NAME, documents: src.documents }],
tests,
};
}
async function invoke(coll, name, args) {
const { filter, update, replacement, ...rest } = args;
switch (name) {
case 'updateOne':
return pick(await coll.updateOne(filter, update, rest), UPDATE_KEYS);
case 'updateMany':
return pick(await coll.updateMany(filter, update, rest), UPDATE_KEYS);
case 'replaceOne':
return pick(await coll.replaceOne(filter, replacement, rest), UPDATE_KEYS);
case 'findOneAndUpdate': {
const r = await coll.findOneAndUpdate(filter, update, rest);
// Driver 5+ returns the document itself; 4.x wrapped it in
// `{value}`. run.js tolerates both the same way.
return r && typeof r === 'object' && 'value' in r && 'ok' in r ? r.value : r;
}
default:
throw new Error(`record.js does not know the operation '${name}'`);
}
}
function pick(result, keys) {
const o = {};
for (const k of keys) if (result[k] !== undefined) o[k] = result[k];
return o;
}
main().catch((e) => {
console.error('RECORD_FAIL', e);
process.exit(1);
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,153 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$addToSet`, `$pop` and `$pullAll`: the three array operators that are",
"neither `$push` nor `$pull`. Each has a shape that decides whether it",
"wrote at all -- a set that already held the value, a pop of an empty",
"array, a pullAll matching nothing -- and `modifiedCount` is the only",
"place that shows, which is why every case records an outcome too."
],
"documents": [{ "_id": 1, "t": [1, 2, 3] }],
"cases": [
{
"description": "$addToSet appends a value the array does not hold",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": 4 } } }
},
{
"description": "$addToSet on a value already there writes nothing",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": 2 } } }
},
{
"description": "$addToSet on a missing field creates the array",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "gone": 1 } } }
},
{
"description": "$addToSet $each adds only what is missing",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": { "$each": [2, 4, 5] } } } }
},
{
"description": "$addToSet $each with duplicates inside it",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": { "$each": [7, 7] } } } }
},
{
"description": "$addToSet compares documents whole",
"documents": [{ "_id": 1, "t": [{ "a": 1 }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": { "a": 1 } } } }
},
{
"description": "$addToSet distinguishes documents by field order",
"documents": [{ "_id": 1, "t": [{ "a": 1, "b": 2 }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": { "b": 2, "a": 1 } } } }
},
{
"description": "$addToSet treats an int and an equal double as one value",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": 2.0 } } }
},
{
"description": "$addToSet on a non-array field",
"documents": [{ "_id": 1, "t": 5 }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "t": 1 } } }
},
{
"description": "$pop removes the last element",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 1 } } }
},
{
"description": "$pop with -1 removes the first",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": -1 } } }
},
{
"description": "$pop of an empty array writes nothing",
"documents": [{ "_id": 1, "t": [] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 1 } } }
},
{
"description": "$pop of a missing field writes nothing",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "gone": 1 } } }
},
{
"description": "$pop of a one-element array leaves an empty one",
"documents": [{ "_id": 1, "t": [9] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 1 } } }
},
{
"description": "$pop with an argument that is neither 1 nor -1",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 2 } } }
},
{
"description": "$pop with 1.0, which is 1",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 1.0 } } }
},
{
"description": "$pop of a non-array field",
"documents": [{ "_id": 1, "t": 5 }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "t": 1 } } }
},
{
"description": "$pullAll removes every copy of every listed value",
"documents": [{ "_id": 1, "t": [1, 2, 3, 2] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": [2] } } }
},
{
"description": "$pullAll with several values",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": [1, 3] } } }
},
{
"description": "$pullAll matching nothing writes nothing",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": [9] } } }
},
{
"description": "$pullAll compares documents whole, not by predicate",
"documents": [{ "_id": 1, "t": [{ "a": 1 }, { "a": 2 }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": [{ "a": 1 }] } } }
},
{
"description": "$pullAll on a missing field writes nothing",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "gone": [1] } } }
},
{
"description": "$pullAll with an argument that is not an array",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": 1 } } }
},
{
"description": "$pullAll emptying the array",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pullAll": { "t": [1, 2, 3] } } }
},
{
"description": "$addToSet through a positional segment",
"documents": [{ "_id": 1, "y": [{ "t": [1] }, { "t": [2] }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$addToSet": { "y.$[].t": 1 } } }
},
{
"description": "$pop down a dotted path",
"documents": [{ "_id": 1, "n": { "t": [1, 2] } }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pop": { "n.t": -1 } } }
}
]
}

View File

@@ -0,0 +1,81 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$currentDate`, the one update operator whose result cannot be recorded",
"as a value: it is whatever the clock said. `volatile` names the fields",
"whose recorded value is replaced by a `$$type` assertion, so the case",
"still pins the type, the position and every other field of the document",
"-- everything except the number nobody can predict."
],
"documents": [{ "_id": 1, "a": 1 }],
"cases": [
{
"description": "$currentDate with true writes a date",
"volatile": ["d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": true } } }
},
{
"description": "$currentDate with an explicit date type",
"volatile": ["d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": { "$type": "date" } } } }
},
{
"description": "$currentDate with the timestamp type",
"volatile": ["d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": { "$type": "timestamp" } } } }
},
{
"description": "$currentDate overwrites a field that is already there",
"volatile": ["a"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "a": true } } }
},
{
"description": "$currentDate down a dotted path",
"volatile": ["n.d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "n.d": true } } }
},
{
"description": "$currentDate beside another operator",
"volatile": ["d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": true }, "$inc": { "a": 1 } } }
},
{
"description": "$currentDate with false, which is still a date",
"volatile": ["d"],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": false } } }
},
{
"description": "$currentDate with an unknown type name",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": { "$type": "nope" } } } }
},
{
"description": "$currentDate with a document that is not $type",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": { "a": 1 } } } }
},
{
"description": "$currentDate with a number",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$currentDate": { "d": 1 } } }
},
{
"description": "$currentDate on an upsert that inserts",
"documents": [],
"volatile": ["d"],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$currentDate": { "d": true } },
"upsert": true
}
}
]
}

View File

@@ -0,0 +1,88 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"The operator table itself, rather than any one operator: what an update",
"document may be, and what happens to a name that is not in the table.",
"This server answers `bad update` with code 2 to every one of these, which",
"is one message covering several different questions.",
"A `{b: 1, $set: {...}}` case was authored and then removed: the driver",
"rejects it before it reaches a server, so there is no server answer to",
"record and a case with no `errorCode` would assert nothing."
],
"documents": [{ "_id": 1, "a": 1, "t": [1, 2] }],
"cases": [
{
"description": "an unknown modifier",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$bogus": { "a": 1 } } }
},
{
"description": "an unknown modifier beside a known one",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": { "b": 1 }, "$bogus": { "a": 1 } } }
},
{
"description": "a known modifier whose argument is not a document",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": 1 } }
},
{
"description": "an empty argument to a known modifier",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": {} } }
},
{
"description": "a data field after an operator",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": { "c": 1 }, "b": 1 } }
},
{
"description": "two operators writing the same field",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": { "a": 2 }, "$inc": { "a": 1 } } }
},
{
"description": "$inc on a non-numeric field",
"documents": [{ "_id": 1, "a": "x" }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$inc": { "a": 1 } } }
},
{
"description": "$inc by a non-numeric operand",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$inc": { "a": "x" } } }
},
{
"description": "$push onto a non-array field",
"documents": [{ "_id": 1, "t": 5 }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": 1 } } }
},
{
"description": "$pull on a non-array field",
"documents": [{ "_id": 1, "t": 5 }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$pull": { "t": 1 } } }
},
{
"description": "$unset of a field that is not there",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$unset": { "gone": "" } } }
},
{
"description": "$rename onto a field that already exists",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$rename": { "a": "t" } } }
},
{
"description": "$rename of a field that is not there",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$rename": { "gone": "b" } } }
},
{
"description": "an update that writes nothing still reports a match",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$set": { "a": 1 } } }
}
]
}

View File

@@ -0,0 +1,122 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$mul`, `$min` and `$max`. The two comparison operators are not numeric",
"at all -- they compare in BSON canonical order, which is why a string",
"field and a number operand have a defined answer -- and the cases here",
"are shaped to say which of the three rules each one follows."
],
"documents": [{ "_id": 1, "a": 5, "s": "b", "d": 2.5 }],
"cases": [
{
"description": "$mul multiplies an int by an int",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "a": 2 } } }
},
{
"description": "$mul of a missing field",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "gone": 5 } } }
},
{
"description": "$mul mixes int and double",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "d": 2 } } }
},
{
"description": "$mul by zero",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "a": 0 } } }
},
{
"description": "$mul overflowing an int32",
"documents": [{ "_id": 1, "a": 2000000000 }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "a": 2 } } }
},
{
"description": "$mul of a non-numeric field",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "s": 2 } } }
},
{
"description": "$mul by a non-numeric operand",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "a": "x" } } }
},
{
"description": "$mul down a dotted path",
"documents": [{ "_id": 1, "n": { "a": 3 } }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "n.a": 4 } } }
},
{
"description": "$min writes when the operand is lower",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "a": 3 } } }
},
{
"description": "$min leaves the field when the operand is higher",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "a": 7 } } }
},
{
"description": "$min of a missing field always writes",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "gone": 7 } } }
},
{
"description": "$min against a field of another type",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "s": 5 } } }
},
{
"description": "$min compares an int and an equal double",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "a": 5.0 } } }
},
{
"description": "$max writes when the operand is higher",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$max": { "a": 7 } } }
},
{
"description": "$max leaves the field when the operand is lower",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$max": { "a": 3 } } }
},
{
"description": "$max of a missing field always writes",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$max": { "gone": 7 } } }
},
{
"description": "$max against a field of another type",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$max": { "s": 5 } } }
},
{
"description": "$max against a null field",
"documents": [{ "_id": 1, "a": null }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$max": { "a": 1 } } }
},
{
"description": "$min and $max on the same field in one update",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "a": 3 }, "$max": { "a": 9 } } }
},
{
"description": "$min through an array index",
"documents": [{ "_id": 1, "t": [5, 5] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$min": { "t.0": 1 } } }
},
{
"description": "$mul through a positional segment",
"documents": [{ "_id": 1, "t": [{ "a": 2 }, { "a": 3 }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$mul": { "t.$[].a": 10 } } }
}
]
}

View File

@@ -0,0 +1,122 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$push`'s modifiers: `$each`, `$slice`, `$position`, `$sort`. This server",
"already implements `$push` and `$each` and *silently ignores* the other",
"three -- `{$each: [3, 4], $slice: -3}` appended and did not slice, and",
"answered ok: 1. That is the same class of wrong answer the positional",
"operators were: the client asked for one thing and was told it got it."
],
"documents": [{ "_id": 1, "t": [1, 2] }],
"cases": [
{
"description": "$slice keeps the last n",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3, 4], "$slice": -3 } } } }
},
{
"description": "$slice keeps the first n",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3, 4], "$slice": 3 } } } }
},
{
"description": "$slice of zero empties the array",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3], "$slice": 0 } } } }
},
{
"description": "$slice larger than the array keeps all of it",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3], "$slice": 10 } } } }
},
{
"description": "$slice with an empty $each truncates without adding",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [], "$slice": 1 } } } }
},
{
"description": "$position inserts at the front",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [9], "$position": 0 } } } }
},
{
"description": "$position inserts in the middle",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [9], "$position": 1 } } } }
},
{
"description": "$position past the end appends",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [9], "$position": 99 } } } }
},
{
"description": "$position counted from the end",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [9], "$position": -1 } } } }
},
{
"description": "$sort ascending over scalars",
"documents": [{ "_id": 1, "t": [3, 1] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [2], "$sort": 1 } } } }
},
{
"description": "$sort descending over scalars",
"documents": [{ "_id": 1, "t": [3, 1] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [2], "$sort": -1 } } } }
},
{
"description": "$sort on a field of the elements",
"documents": [{ "_id": 1, "t": [{ "a": 3 }, { "a": 1 }] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [{ "a": 2 }], "$sort": { "a": 1 } } } } }
},
{
"description": "$sort and $slice together",
"documents": [{ "_id": 1, "t": [3, 1] }],
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [2], "$sort": 1, "$slice": 2 } } } }
},
{
"description": "$position and $slice together",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [9], "$position": 0, "$slice": 2 } } } }
},
{
"description": "$slice without $each",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$slice": 1 } } } }
},
{
"description": "$each that is not an array",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": 3 } } } }
},
{
"description": "$slice that is not a number",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3], "$slice": "x" } } } }
},
{
"description": "$position that is not a number",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3], "$position": "x" } } } }
},
{
"description": "an unknown modifier beside $each",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "$each": [3], "$bogus": 1 } } } }
},
{
"description": "a document operand that is not modifiers at all is a value",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "t": { "a": 1 } } } }
},
{
"description": "$push $each onto a missing field",
"operation": "updateOne",
"arguments": { "filter": {}, "update": { "$push": { "gone": { "$each": [1, 2] } } } }
}
]
}

View File

@@ -0,0 +1,99 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$setOnInsert`, the only update operator whose meaning depends on which",
"branch of an upsert ran. Every case here is therefore a pair: the same",
"update against a document that exists and against one that does not."
],
"documents": [{ "_id": 1, "k": 1, "a": 100 }],
"cases": [
{
"description": "$setOnInsert writes when the upsert inserts",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "a": 1 }, "$set": { "b": 2 } },
"upsert": true
}
},
{
"description": "$setOnInsert is ignored when the upsert updates",
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "a": 1 }, "$set": { "b": 2 } },
"upsert": true
}
},
{
"description": "$setOnInsert alone on a plain update is a no-op",
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "a": 1 } }
}
},
{
"description": "$setOnInsert alone on an insert is the whole document",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 7 },
"update": { "$setOnInsert": { "a": 1 } },
"upsert": true
}
},
{
"description": "$setOnInsert writes a dotted path on insert",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "x.y": 5 } },
"upsert": true
}
},
{
"description": "$setOnInsert may not touch _id",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "_id": 9 } },
"upsert": true
}
},
{
"description": "$setOnInsert and $set naming the same field on an insert",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "a": 1 }, "$set": { "a": 2 } },
"upsert": true
}
},
{
"description": "findOneAndUpdate reports the document $setOnInsert built",
"documents": [],
"operation": "findOneAndUpdate",
"arguments": {
"filter": { "k": 3 },
"update": { "$setOnInsert": { "a": 1 } },
"upsert": true,
"returnDocument": "after"
}
},
{
"description": "updateMany with $setOnInsert on an upsert that inserts",
"documents": [],
"operation": "updateMany",
"arguments": {
"filter": { "k": 1 },
"update": { "$setOnInsert": { "a": 1 } },
"upsert": true
}
}
]
}

View File

@@ -44,9 +44,9 @@ function opt(name, dflt) {
}
const VERBOSE = !!opt('verbose', false);
// The corpus. Defaults to the pinned crud suite; `--suite-dir` points the same
// runner at another one, which is how `tests/spec/aggregate/` and
// `tests/spec/positional/` are run -- both exist because the pinned suite has
// a hole where a whole feature should be. Sharing
// runner at another one, which is how `tests/spec/aggregate/`,
// `tests/spec/positional/` and `tests/spec/operators/` are run -- each exists
// because the pinned suite has a hole where a whole feature should be. Sharing
// the runner rather than writing a second one is the point: the entity model,
// the matchers, the skip accounting and `expectEvents` all come for free, and a
// second runner would drift from this one exactly where it mattered.