diff --git a/PLAN.md b/PLAN.md index 7eadef4..502bc41 100644 --- a/PLAN.md +++ b/PLAN.md @@ -467,7 +467,7 @@ answers, and the trade is only acceptable because the lie is removed first. | M1 | **Cursors + wire polish** | getMore / killCursors / batchSize; server-side cursor state with idle timeout; sessions plumbing (lsid accepted) as drivers send it; hello advertisement updates; **`moreToCome` on requests** (see the bug below); command-monitoring assertions in the spec runner | crud spec suite green; e2e green | | 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**); 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 `$`/`$[]`/`$[]` 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 | | 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 | @@ -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 this crash, but the two drops disagree about which lock protects the 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 `$[]` + 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 whole missing command with no dependencies, and measuring it turned up three things worth keeping, none of which are `distinct`'s to fix: diff --git a/tests/spec/positional/README.md b/tests/spec/positional/README.md new file mode 100644 index 0000000..2059254 --- /dev/null +++ b/tests/spec/positional/README.md @@ -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 | +|---|---|---| +| `$[]` 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 +`$[]` 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 `.json` for every `sources/.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. diff --git a/tests/spec/positional/all-positional.json b/tests/spec/positional/all-positional.json new file mode 100644 index 0000000..ef987f8 --- /dev/null +++ b/tests/spec/positional/all-positional.json @@ -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 + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/spec/positional/filtered.json b/tests/spec/positional/filtered.json new file mode 100644 index 0000000..3816d42 --- /dev/null +++ b/tests/spec/positional/filtered.json @@ -0,0 +1,2000 @@ +{ + "description": "filtered", + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ], + "tests": [ + { + "description": "one element matches the filter", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 1 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "no element matches the filter", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 4 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 0, + "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": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "every element of the array matches", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": { + "$gte": 0 + } + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 2 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "updateMany where no document has a matching element", + "operations": [ + { + "object": "collection0", + "name": "updateMany", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 4 + } + ] + }, + "expectResult": { + "matchedCount": 3, + "modifiedCount": 0, + "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": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "updateMany where one document has a matching element", + "operations": [ + { + "object": "collection0", + "name": "updateMany", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectResult": { + "matchedCount": 3, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 1 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "updateMany where several documents match", + "operations": [ + { + "object": "collection0", + "name": "updateMany", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 1 + } + ] + }, + "expectResult": { + "matchedCount": 3, + "modifiedCount": 2, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 3 + }, + { + "b": 2 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 2 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "two identifiers down a nested path", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "_id": 3 + }, + "update": { + "$set": { + "y.$[i].c.$[j].d": 0 + } + }, + "arrayFilters": [ + { + "i.b": 5 + }, + { + "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": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 0 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "the outer identifier matches nothing, so the inner never runs", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "_id": 3 + }, + "update": { + "$set": { + "y.$[i].c.$[j].d": 0 + } + }, + "arrayFilters": [ + { + "i.b": 99 + }, + { + "j.d": 2 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 0, + "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": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "findOneAndUpdate returns the document and reports the array", + "operations": [ + { + "object": "collection0", + "name": "findOneAndUpdate", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ], + "returnDocument": "after" + }, + "expectResult": { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 1 + } + ] + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 1 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "$inc through an identifier adds to the element", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "y.$[i].b": 10 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "$unset through an identifier removes the element's field", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": {} + } + }, + { + "object": "collection0", + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 3, + "e": 9 + }, + { + "b": 1 + } + ] + } + ] + } + }, + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$unset": { + "y.$[i].e": "" + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": 3 + }, + { + "b": 1 + } + ] + } + ] + } + ] + }, + { + "description": "an identifier as the leaf replaces the whole element", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$set": { + "y.$[i]": { + "replaced": true + } + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "a filter reaching a nested field of the element", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": {} + } + }, + { + "object": "collection0", + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 1, + "y": [ + { + "b": { + "q": 1 + } + }, + { + "b": { + "q": 2 + } + } + ] + } + ] + } + }, + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b.q": 0 + } + }, + "arrayFilters": [ + { + "i.b.q": 2 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "b": { + "q": 1 + } + }, + { + "b": { + "q": 0 + } + } + ] + } + ] + } + ] + }, + { + "description": "an array of scalars, filtered on the identifier itself", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": {} + } + }, + { + "object": "collection0", + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 1, + "y": [ + 1, + 2, + 3, + 2 + ] + } + ] + } + }, + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i]": 9 + } + }, + "arrayFilters": [ + { + "i": 2 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + 1, + 9, + 3, + 9 + ] + } + ] + } + ] + }, + { + "description": "an identifier used twice in one path", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": {} + } + }, + { + "object": "collection0", + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 1, + "y": [ + { + "c": [ + { + "d": 1 + }, + { + "d": 2 + } + ] + } + ] + } + ] + } + }, + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].c.$[i].d": 0 + } + }, + "arrayFilters": [ + { + "i.d": 1 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 0, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": [ + { + "c": [ + { + "d": 1 + }, + { + "d": 2 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an identifier naming no array filter is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[k].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an identifier with no arrayFilters at all is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + } + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an array filter the update never uses is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectError": { + "isError": true, + "errorCode": 9 + } + } + ], + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "two array filters with the same identifier are refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + }, + { + "i.b": 1 + } + ] + }, + "expectError": { + "isError": true, + "errorCode": 9 + } + } + ], + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an array filter with no top-level field is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + {} + ] + }, + "expectError": { + "isError": true, + "errorCode": 9 + } + } + ], + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an array filter with two top-level fields is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3, + "j.b": 1 + } + ] + }, + "expectError": { + "isError": true, + "errorCode": 9 + } + } + ], + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an identifier that is not a lowercase alphanumeric name is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[1x].b": 2 + } + }, + "arrayFilters": [ + { + "1x.b": 3 + } + ] + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "an identifier in first position is refused", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "$[i]": 2 + } + }, + "arrayFilters": [ + { + "i": 1 + } + ] + }, + "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 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "description": "the path's array 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.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectError": { + "isError": true, + "errorCode": 2 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "z": 1 + } + ] + } + ] + }, + { + "description": "the path names something that 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.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectError": { + "isError": true, + "errorCode": 2 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "y": 5 + } + ] + } + ] + }, + { + "description": "an upsert gets no special case", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": {} + } + }, + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "k": 1 + }, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ], + "upsert": true + }, + "expectError": { + "isError": true, + "errorCode": 2 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [] + } + ] + }, + { + "description": "arrayFilters alongside a replacement", + "operations": [ + { + "object": "collection0", + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "z": 1 + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + "expectResult": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "positional-corpus", + "documents": [ + { + "_id": 1, + "z": 1 + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + }, + { + "_id": 3, + "y": [ + { + "b": 5, + "c": [ + { + "d": 2 + }, + { + "d": 1 + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/spec/positional/first-positional.json b/tests/spec/positional/first-positional.json new file mode 100644 index 0000000..f93f783 --- /dev/null +++ b/tests/spec/positional/first-positional.json @@ -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": [] + } + ] + } + ] +} diff --git a/tests/spec/positional/record.js b/tests/spec/positional/record.js new file mode 100644 index 0000000..6316f1c --- /dev/null +++ b/tests/spec/positional/record.js @@ -0,0 +1,200 @@ +// Record a positional-update corpus by asking a real mongod what the answer is. +// +// The pinned crud suite tests `$[]` 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 `$[]` 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/.json`: documents, and a list of operations +// 2. run this against a real mongod +// 3. it writes `.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 a running mongod to measure against (default 27099) +// --only 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 +// `$[]` 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 \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); +}); diff --git a/tests/spec/positional/sources/all-positional.json b/tests/spec/positional/sources/all-positional.json new file mode 100644 index 0000000..2be7c5c --- /dev/null +++ b/tests/spec/positional/sources/all-positional.json @@ -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 }] + } + } + ] +} diff --git a/tests/spec/positional/sources/filtered.json b/tests/spec/positional/sources/filtered.json new file mode 100644 index 0000000..4f25e5c --- /dev/null +++ b/tests/spec/positional/sources/filtered.json @@ -0,0 +1,267 @@ +{ + "_comment": [ + "Inputs only. Expectations are measured -- see record.js.", + "`$[]` 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 }] + } + } + ] +} diff --git a/tests/spec/positional/sources/first-positional.json b/tests/spec/positional/sources/first-positional.json new file mode 100644 index 0000000..63b4e40 --- /dev/null +++ b/tests/spec/positional/sources/first-positional.json @@ -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 + } + } + ] +} diff --git a/tests/spec/run.js b/tests/spec/run.js index 16510a5..60e3b38 100644 --- a/tests/spec/run.js +++ b/tests/spec/run.js @@ -44,7 +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/` 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 matchers, the skip accounting and `expectEvents` all come for free, and a // second runner would drift from this one exactly where it mattered.