M3's row and §6 updated: the operator corpus is 125 cases across seven files, all green, and the pipeline work also gave `aggregate` the `$replaceWith` stage it never had -- the compiler is shared between the two. Full matrix at this commit: 249/249 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, operators 125/0, positional 51/0, aggregation 70/0, pinned crud 228/63/196, e2e and crash-fuzz green.
5.6 KiB
The update-operator corpus
What an update document may be, measured. PLAN §3 lists eight operators for
M3 — $setOnInsert, $addToSet, $mul, $min, $max, $pop, $pullAll,
$currentDate — and pipeline-style updates beside them; the pinned crud
corpus says almost nothing about any of it. 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,$positionand$sortsilently ignored.{$each: [3, 4], $slice: -3}appended both values, sliced nothing, and answeredok: 1withmodifiedCount: 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
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
$currentDatefield is whatever the clock said. The case names it involatile, 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
pipeline.json 23 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 for the operators, 0 / 23 for the pipeline file — and driven green by eight 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 |
a pipeline update's _id |
always survives, through $replaceRoot and through $project: {_id: 0} |
a stage changing _id to something else |
ImmutableField (66) |
$match, $group, $sort, $unwind in an update |
InvalidOptions (72) — real stages, refused here |
| a name that is no stage at all | 40324; two stages in one array element, 40323 |
arrayFilters beside a pipeline |
FailedToParse (9) |
Three cases were authored and then removed, all for the same reason: the
driver rejects {b: 1, $set: {c: 1}}, an empty pipeline and a pipeline
holding a non-document before any of them reaches a server, so there is no
server answer to record and the cases would have asserted nothing.