# 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`, `$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
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
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.