tests/spec: a recorded corpus for the positional operators

M3's gate as named -- "remaining crud coverage; e2e3/e2e4 green" -- cannot
see this work. e2e3 and e2e4 contain zero positional paths, and the pinned
crud corpus covers `$[<identifier>]` only: no `$[]` case, no bare `$` case
anywhere in it. Both stayed green through a bug that replaced an array with
`{"$[i]": {...}}` and answered ok: 1. So M3 brings its own corpus, built the
way M2.5's was: inputs authored in `sources/`, every expectation recorded
from mongod 8.3.7, run through the shared runner with `--suite-dir`.

51 cases across the three spellings. It stands at 15 pass / 36 fail against
the refusal, which is the intended shape -- `expressions.json` was recorded
at 1 pass / 26 fail before the evaluator and is green now. The 15 that pass
are refusals where this server's code already matches; of the 36, 31 are the
constructs answering "not implemented" and 5 are refusals whose code
differs, four of them arrayFilters validation this server cannot do because
it never parses the option.

Every case records its `outcome`, refusals included. That is deliberate and
it is the whole point of the file: a refusal that left the document mangled
is indistinguishable from a clean one in `expectError` alone, and a mangled
document is what this corpus exists to catch.

What recording it settled, none of it guessable, the first contradicting
what the design review assumed:

  - `y.$[i].c.$[i].d`, one identifier reused at two levels, is **accepted**
    -- not a duplicate-identifier error
  - `$[]` over an empty array is a no-op with modifiedCount 0
  - `$[]` over an array with a non-document element is error 28, where every
    other path failure here is 2
  - a positional segment never creates: a missing or non-array path is an
    error, where `$set: {'a.b': 1}` would construct one
  - an upsert gets no special case -- it fails for the same reason
  - `$` writes only the first matching element, and is refused when the query
    never touched the array
  - any of the three in first position is refused, as is `$` twice in a path
  - `arrayFilters` alongside a replacement is ignored rather than refused

A case needing its own documents reseeds through operations rather than
`initialData`, because the format's `initialData` is per file and the runner
seeds it once per test. That keeps one file per operator instead of one file
per document shape.

crud scorecard unchanged at 204/87, aggregation corpus still 70/0.
This commit was merged in pull request #6.
This commit is contained in:
A.Shakhmatov
2026-08-10 19:00:01 +03:00
parent 3c2ac38fd9
commit e5a84c0598
10 changed files with 4554 additions and 2 deletions

20
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 | | 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** | `distinct` (**done**); then $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate, `arrayFilters`, pipeline updates; partial + hashed indexes | remaining crud coverage; e2e3/e2e4 green | | M3 | **Update operators + index types** | `distinct` (**done**); positional paths refused rather than destructive (**done**); then `$`/`$[]`/`$[<ident>]` implemented, $setOnInsert, $addToSet, $mul, $min/$max, $pop, $pullAll, $currentDate, pipeline updates; partial + hashed indexes | `tests/spec/positional/` 0 fail (51 cases, recorded from mongod — the named gate could not see this work, 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 | | 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 |
@@ -1082,6 +1082,24 @@ has to be its own commit with its own re-recorded scorecard.
is not excluded by the reasoning above; it holds no collection lock, so it is not excluded by the reasoning above; it holds no collection lock, so it
is not this crash, but the two drops disagree about which lock protects the is not this crash, but the two drops disagree about which lock protects the
namespace and that wants one answer. namespace and that wants one answer.
- **M3's gate is `tests/spec/positional/`, not the one named in §3.** The row
said "remaining crud coverage; e2e3/e2e4 green". `e2e3` and `e2e4` contain
zero positional paths, and the pinned crud corpus covers `$[<identifier>]`
only — no `$[]` case, no bare `$` case anywhere in it. Both would have been
green through the worst version of the array-destroying bug. So M3 brings
its own corpus, built exactly like M2.5's: 51 cases, inputs authored in
`sources/`, every expectation recorded from mongod 8.3.7, run through the
shared runner with `--suite-dir`.
It stands at 15 pass / 36 fail against the refusal, which is the intended
shape — `expressions.json` was 1/26 before the evaluator and is green now.
Recording it settled a dozen things, and the first contradicts what the
design review assumed: `y.$[i].c.$[i].d` reusing one identifier at two
levels is **accepted**, not a duplicate-identifier error. Also: `$[]` over
an empty array is a no-op; `$[]` over an array with a non-document element
is error 28 where every other path failure is 2; a positional segment never
creates, so a missing or non-array path is an error where `$set: {'a.b': 1}`
would construct; and an upsert gets no special case.
- **M3 update operators** — open. `distinct` landed first because it was a - **M3 update operators** — open. `distinct` landed first because it was a
whole missing command with no dependencies, and measuring it turned up three whole missing command with no dependencies, and measuring it turned up three
things worth keeping, none of which are `distinct`'s to fix: things worth keeping, none of which are `distinct`'s to fix:

View File

@@ -0,0 +1,119 @@
# The positional-update corpus
MongoDB has three ways to say "descend into this array" in an update path:
| | since | pinned crud corpus |
|---|---|---|
| `$[<identifier>]` with `arrayFilters` | 3.6 | 4 files, 14 cases |
| `$[]` all-positional | 3.6 | **nothing** |
| `$` positional | ancient | **nothing** |
All three shared one code path in this engine, and that path overwrote the
array it was supposed to walk — `y: [{b: 3}, {b: 1}]` became
`y: {"$[i]": {"b": 2}}`, every element discarded, `ok: 1`. Only the
`$[<identifier>]` third of it was externally visible, so a fix measured
against the pinned corpus alone would have gone green while two of the three
still destroyed data. That is `docs/M3_ARRAYFILTERS_DESIGN_REVIEW.md` §3, and
this directory is its consequence.
## The one rule
**Inputs are authored here; expectations are measured against a real mongod.**
The same rule as `tests/spec/aggregate/`, for the same reason: a corpus we
write end to end can encode our own bugs as expectations and will then agree
with us forever. It is not a hypothetical risk here — the design review's own
guesses about `$[]`, about missing paths and about upserts were all wrong
before mongod was asked.
`sources/*.json` holds documents and operations and nothing else. `record.js`
asks mongod 8.3.7 what each one answers.
## Running it
```sh
node tests/spec/run.js --suite-dir tests/spec/positional
```
The same runner as the crud corpus, pointed elsewhere — the entity model, the
matchers, `outcome` verification and the skip accounting come for free.
`--scorecard` is refused with `--suite-dir`: `tests/spec/scorecard.txt` is the
crud corpus's record and the milestones are compared against it.
## Re-recording
```sh
mongod --port 27099 --dbpath /tmp/mongo-corpus &
node tests/spec/positional/record.js --mongod-port 27099
```
Writes `<name>.json` for every `sources/<name>.json`. The generated files are
committed: they *are* the corpus, and regenerating them is how a disagreement
with mongod gets re-measured rather than argued about.
Three things to know when adding cases:
- **Every case records its `outcome`, including the refusals.** A refusal that
left the document mangled looks identical to a clean one in `expectError`
alone, and a mangled document is the entire reason this corpus exists.
- **Errors record the code, not the message.** Message text is mongod's to
change between releases. Several of these messages also embed a
shell-syntax rendering of the offending element, which no formatter in this
server produces.
- **A case needing its own documents reseeds through operations**
(`deleteMany` + `insertMany`), not `initialData`: the unified format's
`initialData` is per file and the runner seeds it once per test. This keeps
one file per operator rather than one file per document shape.
Every construct here is 3.6 or older, so nothing depends on a server newer
than the 4.4 this one reports. A case that did should be left out rather than
annotated — recording it from mongod 8.x and judging it against a 4.4 answer
measures the version gap, not the engine.
## Where it stands
Recorded against mongod 8.3.7, run against the server at the positional
refusal:
```
filtered.json 8 pass 19 fail 0 skip
all-positional.json 3 pass 9 fail 0 skip
first-positional.json 4 pass 8 fail 0 skip
```
Red by construction and by design. The 15 that pass are the refusals where
this server's code already matches mongod's; of the 36 that fail, 31 are the
constructs themselves answering "not implemented by this server", and 5 are
refusals whose code differs — four of them the `arrayFilters` validation this
server cannot do yet because it never parses the option.
That is the intended shape. `tests/spec/aggregate/expressions.json` was
recorded at 1 pass / 26 fail before the evaluator existed and is green now;
this is the same gate at the same stage.
## What recording it settled
None of this is guessable, and the first row contradicts what the review
assumed:
| | mongod |
|---|---|
| `y.$[i].c.$[i].d`, one identifier reused at two levels | **accepted** — not a duplicate-identifier error |
| `$[]` over an empty array | no-op, `modifiedCount: 0` |
| `$[]` where one element is not a document | error **28**, where the other path failures are 2 |
| `$[]` or `$[i]` where the path is missing, or is not an array | error 2 — a positional segment never *creates* |
| an upsert that would insert | error 2; upsert gets no special case |
| `$` when the query never touched the array | error 2 |
| `$` where several elements match | only the first is written |
| `$`, `$[]` or `$[i]` in first position | error 2 |
| `$` twice in one path | error 2 |
| `arrayFilters` alongside a replacement | ignored, no error |
| an array filter the update never uses | error 9 |
| a filter with no top-level field, two of them, or a duplicate identifier | error 9 |
The 2-versus-9 split is *nearly* "9 is the `arrayFilters` array judged on its
own, 2 is anything needing the update or the stored document" — and it is not
a clean rule: an identifier that is not a lowercase alphanumeric name carries
the same `Error parsing array filter` prefix as the 9s and is a 2. Recorded as
measured rather than tidied, because deriving the codes from the rule would
get that row wrong.

View File

@@ -0,0 +1,860 @@
{
"description": "all-positional",
"schemaVersion": "1.4",
"createEntities": [
{
"client": {
"id": "client0"
}
},
{
"database": {
"id": "database0",
"client": "client0",
"databaseName": "positional-corpus"
}
},
{
"collection": {
"id": "collection0",
"database": "database0",
"collectionName": "coll"
}
}
],
"initialData": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
],
"tests": [
{
"description": "every element of the array is written",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 1
},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 9
},
{
"b": 9
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
},
{
"description": "nested $[] is a cross-product over both levels",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 3
},
"update": {
"$set": {
"y.$[].c.$[].d": 0
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 0
},
{
"d": 0
}
]
},
{
"c": [
{
"d": 0
}
]
}
]
}
]
}
]
},
{
"description": "$[] over an empty array",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 2
},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 0,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
},
{
"description": "$inc through $[] adds to every element",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 1
},
"update": {
"$inc": {
"y.$[].b": 10
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 13
},
{
"b": 11
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
},
{
"description": "$[] as the leaf over an array of scalars",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
1,
2,
3
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {},
"update": {
"$set": {
"y.$[]": 0
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
0,
0,
0
]
}
]
}
]
},
{
"description": "$unset through $[] removes a field from every element",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
{
"b": 1,
"e": 1
},
{
"b": 2,
"e": 2
}
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {},
"update": {
"$unset": {
"y.$[].e": ""
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 1
},
{
"b": 2
}
]
}
]
}
]
},
{
"description": "$[] where an element is not a document",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
{
"b": 1
},
7
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectError": {
"isError": true,
"errorCode": 28
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 1
},
7
]
}
]
}
]
},
{
"description": "updateMany with $[] across every document",
"operations": [
{
"object": "collection0",
"name": "updateMany",
"arguments": {
"filter": {
"_id": {
"$in": [
1,
2
]
}
},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectResult": {
"matchedCount": 2,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 9
},
{
"b": 9
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
},
{
"description": "$[] where the path does not exist",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"z": 1
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"z": 1
}
]
}
]
},
{
"description": "$[] where the path is not an array",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": 5
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {},
"update": {
"$set": {
"y.$[].b": 9
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": 5
}
]
}
]
},
{
"description": "$[] in first position is refused",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 1
},
"update": {
"$set": {
"$[]": 9
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 2
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
},
{
"description": "$[] combined with an identifier in one path",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 3
},
"update": {
"$set": {
"y.$[].c.$[j].d": 0
}
},
"arrayFilters": [
{
"j.d": 2
}
]
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": []
},
{
"_id": 3,
"y": [
{
"c": [
{
"d": 1
},
{
"d": 0
}
]
},
{
"c": [
{
"d": 3
}
]
}
]
}
]
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,850 @@
{
"description": "first-positional",
"schemaVersion": "1.4",
"createEntities": [
{
"client": {
"id": "client0"
}
},
{
"database": {
"id": "database0",
"client": "client0",
"databaseName": "positional-corpus"
}
},
{
"collection": {
"id": "collection0",
"database": "database0",
"collectionName": "coll"
}
}
],
"initialData": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
],
"tests": [
{
"description": "$ writes the element the filter matched",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 3
},
"update": {
"$set": {
"y.$.b": 7
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 7
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ writes only the first match when several elements qualify",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
{
"b": 1
},
{
"b": 1
},
{
"b": 2
}
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 1
},
"update": {
"$set": {
"y.$.b": 7
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 7
},
{
"b": 1
},
{
"b": 2
}
]
}
]
}
]
},
{
"description": "$ when the filter never touched the array is refused",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"_id": 1
},
"update": {
"$set": {
"y.$.b": 7
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$inc through $",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 3
},
"update": {
"$inc": {
"y.$.b": 10
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 13
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ as the leaf replaces the matched element",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 3
},
"update": {
"$set": {
"y.$": {
"replaced": true
}
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"replaced": true
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ reaching a field below the matched element",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 5
},
"update": {
"$set": {
"y.$.c": []
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": []
}
]
}
]
}
]
},
{
"description": "$ with the filter matching on a different field of the element",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
{
"k": "a",
"v": 1
},
{
"k": "b",
"v": 2
}
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.k": "b"
},
"update": {
"$set": {
"y.$.v": 9
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"k": "a",
"v": 1
},
{
"k": "b",
"v": 9
}
]
}
]
}
]
},
{
"description": "updateMany with $ writes each document's own match",
"operations": [
{
"object": "collection0",
"name": "updateMany",
"arguments": {
"filter": {
"y.b": 1
},
"update": {
"$set": {
"y.$.b": 7
}
}
},
"expectResult": {
"matchedCount": 2,
"modifiedCount": 2,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 7
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 7
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ over an array of scalars",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "insertMany",
"arguments": {
"documents": [
{
"_id": 1,
"y": [
1,
2,
3
]
}
]
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y": 2
},
"update": {
"$set": {
"y.$": 9
}
}
},
"expectResult": {
"matchedCount": 1,
"modifiedCount": 1,
"upsertedCount": 0
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
1,
9,
3
]
}
]
}
]
},
{
"description": "$ in first position is refused",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 3
},
"update": {
"$set": {
"$": 7
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ twice in one path is refused",
"operations": [
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 5
},
"update": {
"$set": {
"y.$.c.$.d": 0
}
}
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": [
{
"_id": 1,
"y": [
{
"b": 3
},
{
"b": 1
}
]
},
{
"_id": 2,
"y": [
{
"b": 0
},
{
"b": 1
}
]
},
{
"_id": 3,
"y": [
{
"b": 5,
"c": [
{
"d": 2
}
]
}
]
}
]
}
]
},
{
"description": "$ on an upsert that inserts",
"operations": [
{
"object": "collection0",
"name": "deleteMany",
"arguments": {
"filter": {}
}
},
{
"object": "collection0",
"name": "updateOne",
"arguments": {
"filter": {
"y.b": 3
},
"update": {
"$set": {
"y.$.b": 7
}
},
"upsert": true
},
"expectError": {
"isError": true,
"errorCode": 2
}
}
],
"outcome": [
{
"collectionName": "coll",
"databaseName": "positional-corpus",
"documents": []
}
]
}
]
}

View File

@@ -0,0 +1,200 @@
// Record a positional-update corpus by asking a real mongod what the answer is.
//
// The pinned crud suite tests `$[<identifier>]` in four files and does not
// contain a single `$[]` or bare `$` case -- see PLAN amendment on M3 and
// `docs/M3_ARRAYFILTERS_DESIGN_REVIEW.md` §3. That gap is not academic: all
// three spellings shared one code path, and while it destroyed the array it
// walked, only the `$[<identifier>]` third of it was externally visible.
//
// Same discipline as `tests/spec/aggregate/`: *inputs* are authored in
// `sources/`, *expectations* are measured here. A corpus we write end to end
// is a corpus that can encode our own bugs as expectations and then agree with
// us forever -- and the review's own guesses about `$[]`, missing paths and
// upserts were all wrong before mongod was asked.
//
// 1. author `sources/<name>.json`: documents, and a list of operations
// 2. run this against a real mongod
// 3. it writes `<name>.json` in the unified format, expectations filled in
// 4. `node tests/spec/run.js --suite-dir tests/spec/positional` runs it
//
// node tests/spec/positional/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: there is one
// lockfile in this repo and it lives with the e2e suites.
const { MongoClient } = require(path.join(__dirname, '..', '..', 'e2e', 'node_modules', 'mongodb'));
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 = 'positional-corpus';
const COLL = 'coll';
// Every construct here is 3.6 or older (`$` is ancient, `$[]` and
// `$[<id>]` are 3.6), so nothing in this corpus depends on a server newer
// than the 4.4 this one reports. A case that did should be left out rather
// than annotated -- recording it from mongod 8.x and judging it against a 4.4
// answer measures the version gap, not the engine.
const SCHEMA_VERSION = '1.4';
/// Which fields of a driver result are recorded, per operation.
///
/// The whole result object is not recorded: it carries `upsertedId: null` and
/// driver-side types that do not survive a JSON round trip, and the pinned
/// corpus asserts exactly these three for an update. `findOneAndUpdate`
/// returns the document itself.
const UPDATE_KEYS = ['matchedCount', 'modifiedCount', 'upsertedCount'];
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);
fs.writeFileSync(path.join(__dirname, `${name}.json`), JSON.stringify(out, null, 2) + '\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) {
// A case may bring its own documents: the shapes that make a missing
// path or a non-array path meaningful are not the shapes that make a
// filtered update meaningful.
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*
// rather than `initialData`, because the unified format's
// `initialData` is per file and the runner seeds it once per test.
// Setup-as-operations is what the format offers for this, and it keeps
// one file per operator instead of one file per document shape.
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 = 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 a corpus that pinned it would fail for the
// wrong reason. Several of these messages embed a shell-syntax
// rendering of the offending element, which no formatter here
// produces.
delete op.expectResult;
op.expectError = { isError: true, errorCode: e.code };
}
// Recorded for *every* case, including the refusals, and that is the
// point of the file. A refusal that left the document mangled would
// look identical to a clean one in `expectError` alone -- and a
// mangled document is exactly the bug this corpus exists to catch.
const after = await coll.find({}, { sort: { _id: 1 } }).toArray();
tests.push({
description: c.description,
operations: [...setup, op],
outcome: [{ collectionName: COLL, databaseName: DB_NAME, documents: after }],
});
}
await coll.drop().catch(() => {});
return {
description: name,
schemaVersion: SCHEMA_VERSION,
// Recorded, not authored. Regenerate with tests/spec/positional/record.js.
createEntities: [
{ client: { id: 'client0' } },
{ database: { id: 'database0', client: 'client0', databaseName: DB_NAME } },
{ collection: { id: 'collection0', database: 'database0', collectionName: COLL } },
],
// Reseeded by the runner before every test. Cases needing a different
// shape override it with setup operations, above.
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);
});

View File

@@ -0,0 +1,118 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$[]`, the all-positional operator. The pinned crud corpus contains no",
"`$[]` case at all, which is half of why this directory exists: before",
"the refusal landed, `$[]` overwrote the array it was meant to walk and",
"nothing external would have noticed."
],
"documents": [
{ "_id": 1, "y": [{ "b": 3 }, { "b": 1 }] },
{ "_id": 2, "y": [] },
{ "_id": 3, "y": [{ "c": [{ "d": 1 }, { "d": 2 }] }, { "c": [{ "d": 3 }] }] }
],
"cases": [
{
"description": "every element of the array is written",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "nested $[] is a cross-product over both levels",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 3 },
"update": { "$set": { "y.$[].c.$[].d": 0 } }
}
},
{
"description": "$[] over an empty array",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 2 },
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "$inc through $[] adds to every element",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$inc": { "y.$[].b": 10 } }
}
},
{
"description": "$[] as the leaf over an array of scalars",
"documents": [{ "_id": 1, "y": [1, 2, 3] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[]": 0 } }
}
},
{
"description": "$unset through $[] removes a field from every element",
"documents": [{ "_id": 1, "y": [{ "b": 1, "e": 1 }, { "b": 2, "e": 2 }] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$unset": { "y.$[].e": "" } }
}
},
{
"description": "$[] where an element is not a document",
"documents": [{ "_id": 1, "y": [{ "b": 1 }, 7] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "updateMany with $[] across every document",
"operation": "updateMany",
"arguments": {
"filter": { "_id": { "$in": [1, 2] } },
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "$[] where the path does not exist",
"documents": [{ "_id": 1, "z": 1 }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "$[] where the path is not an array",
"documents": [{ "_id": 1, "y": 5 }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[].b": 9 } }
}
},
{
"description": "$[] in first position is refused",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$set": { "$[]": 9 } }
}
},
{
"description": "$[] combined with an identifier in one path",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 3 },
"update": { "$set": { "y.$[].c.$[j].d": 0 } },
"arrayFilters": [{ "j.d": 2 }]
}
}
]
}

View File

@@ -0,0 +1,267 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$[<identifier>]` and its arrayFilters. The documents are the pinned",
"corpus's own (updateOne-arrayFilters.json) so the two agree on the data",
"and a disagreement between them is about behaviour, never about setup.",
"Cases that need a different shape carry their own `documents`."
],
"documents": [
{ "_id": 1, "y": [{ "b": 3 }, { "b": 1 }] },
{ "_id": 2, "y": [{ "b": 0 }, { "b": 1 }] },
{ "_id": 3, "y": [{ "b": 5, "c": [{ "d": 2 }, { "d": 1 }] }] }
],
"cases": [
{
"description": "one element matches the filter",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "no element matches the filter",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 4 }]
}
},
{
"description": "every element of the array matches",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": { "$gte": 0 } }]
}
},
{
"description": "updateMany where no document has a matching element",
"operation": "updateMany",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 4 }]
}
},
{
"description": "updateMany where one document has a matching element",
"operation": "updateMany",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "updateMany where several documents match",
"operation": "updateMany",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 1 }]
}
},
{
"description": "two identifiers down a nested path",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 3 },
"update": { "$set": { "y.$[i].c.$[j].d": 0 } },
"arrayFilters": [{ "i.b": 5 }, { "j.d": 2 }]
}
},
{
"description": "the outer identifier matches nothing, so the inner never runs",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 3 },
"update": { "$set": { "y.$[i].c.$[j].d": 0 } },
"arrayFilters": [{ "i.b": 99 }, { "j.d": 2 }]
}
},
{
"description": "findOneAndUpdate returns the document and reports the array",
"operation": "findOneAndUpdate",
"arguments": {
"filter": { "_id": 1 },
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }],
"returnDocument": "after"
}
},
{
"description": "$inc through an identifier adds to the element",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$inc": { "y.$[i].b": 10 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "$unset through an identifier removes the element's field",
"documents": [{ "_id": 1, "y": [{ "b": 3, "e": 9 }, { "b": 1 }] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$unset": { "y.$[i].e": "" } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "an identifier as the leaf replaces the whole element",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$set": { "y.$[i]": { "replaced": true } } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "a filter reaching a nested field of the element",
"documents": [{ "_id": 1, "y": [{ "b": { "q": 1 } }, { "b": { "q": 2 } }] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b.q": 0 } },
"arrayFilters": [{ "i.b.q": 2 }]
}
},
{
"description": "an array of scalars, filtered on the identifier itself",
"documents": [{ "_id": 1, "y": [1, 2, 3, 2] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i]": 9 } },
"arrayFilters": [{ "i": 2 }]
}
},
{
"description": "an identifier used twice in one path",
"documents": [{ "_id": 1, "y": [{ "c": [{ "d": 1 }, { "d": 2 }] }] }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].c.$[i].d": 0 } },
"arrayFilters": [{ "i.d": 1 }]
}
},
{
"description": "an identifier naming no array filter is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[k].b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "an identifier with no arrayFilters at all is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } }
}
},
{
"description": "an array filter the update never uses is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "two array filters with the same identifier are refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }, { "i.b": 1 }]
}
},
{
"description": "an array filter with no top-level field is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{}]
}
},
{
"description": "an array filter with two top-level fields is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3, "j.b": 1 }]
}
},
{
"description": "an identifier that is not a lowercase alphanumeric name is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[1x].b": 2 } },
"arrayFilters": [{ "1x.b": 3 }]
}
},
{
"description": "an identifier in first position is refused",
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "$[i]": 2 } },
"arrayFilters": [{ "i": 1 }]
}
},
{
"description": "the path's array does not exist",
"documents": [{ "_id": 1, "z": 1 }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "the path names something that is not an array",
"documents": [{ "_id": 1, "y": 5 }],
"operation": "updateOne",
"arguments": {
"filter": {},
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }]
}
},
{
"description": "an upsert gets no special case",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "k": 1 },
"update": { "$set": { "y.$[i].b": 2 } },
"arrayFilters": [{ "i.b": 3 }],
"upsert": true
}
},
{
"description": "arrayFilters alongside a replacement",
"operation": "replaceOne",
"arguments": {
"filter": { "_id": 1 },
"replacement": { "z": 1 },
"arrayFilters": [{ "i.b": 3 }]
}
}
]
}

View File

@@ -0,0 +1,118 @@
{
"_comment": [
"Inputs only. Expectations are measured -- see record.js.",
"`$`, the original positional operator, which writes the *first* element",
"the query matched. It is the one of the three that needs something the",
"document alone does not hold -- which element the filter matched -- so",
"every case here pairs a filter with an update and the pairing is the",
"point. The pinned crud corpus has no `$` case anywhere."
],
"documents": [
{ "_id": 1, "y": [{ "b": 3 }, { "b": 1 }] },
{ "_id": 2, "y": [{ "b": 0 }, { "b": 1 }] },
{ "_id": 3, "y": [{ "b": 5, "c": [{ "d": 2 }] }] }
],
"cases": [
{
"description": "$ writes the element the filter matched",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 3 },
"update": { "$set": { "y.$.b": 7 } }
}
},
{
"description": "$ writes only the first match when several elements qualify",
"documents": [{ "_id": 1, "y": [{ "b": 1 }, { "b": 1 }, { "b": 2 }] }],
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 1 },
"update": { "$set": { "y.$.b": 7 } }
}
},
{
"description": "$ when the filter never touched the array is refused",
"operation": "updateOne",
"arguments": {
"filter": { "_id": 1 },
"update": { "$set": { "y.$.b": 7 } }
}
},
{
"description": "$inc through $",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 3 },
"update": { "$inc": { "y.$.b": 10 } }
}
},
{
"description": "$ as the leaf replaces the matched element",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 3 },
"update": { "$set": { "y.$": { "replaced": true } } }
}
},
{
"description": "$ reaching a field below the matched element",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 5 },
"update": { "$set": { "y.$.c": [] } }
}
},
{
"description": "$ with the filter matching on a different field of the element",
"documents": [{ "_id": 1, "y": [{ "k": "a", "v": 1 }, { "k": "b", "v": 2 }] }],
"operation": "updateOne",
"arguments": {
"filter": { "y.k": "b" },
"update": { "$set": { "y.$.v": 9 } }
}
},
{
"description": "updateMany with $ writes each document's own match",
"operation": "updateMany",
"arguments": {
"filter": { "y.b": 1 },
"update": { "$set": { "y.$.b": 7 } }
}
},
{
"description": "$ over an array of scalars",
"documents": [{ "_id": 1, "y": [1, 2, 3] }],
"operation": "updateOne",
"arguments": {
"filter": { "y": 2 },
"update": { "$set": { "y.$": 9 } }
}
},
{
"description": "$ in first position is refused",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 3 },
"update": { "$set": { "$": 7 } }
}
},
{
"description": "$ twice in one path is refused",
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 5 },
"update": { "$set": { "y.$.c.$.d": 0 } }
}
},
{
"description": "$ on an upsert that inserts",
"documents": [],
"operation": "updateOne",
"arguments": {
"filter": { "y.b": 3 },
"update": { "$set": { "y.$.b": 7 } },
"upsert": true
}
}
]
}

View File

@@ -44,7 +44,9 @@ function opt(name, dflt) {
} }
const VERBOSE = !!opt('verbose', false); const VERBOSE = !!opt('verbose', false);
// The corpus. Defaults to the pinned crud suite; `--suite-dir` points the same // The corpus. Defaults to the pinned crud suite; `--suite-dir` points the same
// runner at another one, which is how `tests/spec/aggregate/` is run. Sharing // 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
// the runner rather than writing a second one is the point: the entity model, // 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 // the matchers, the skip accounting and `expectEvents` all come for free, and a
// second runner would drift from this one exactly where it mattered. // second runner would drift from this one exactly where it mattered.