update: replacement-style writes

`replaceOne`, `findOneAndReplace` and `bulkWrite`'s `replaceOne` all failed
with "bad update". `update.apply` rejected any update document whose first key
was not `$`-prefixed, so a replacement document -- which by definition has no
operators -- could not get through at all.

MongoDB decides on the first field and nothing else: `$`-prefixed means
operators, anything else means the document *is* the new content. An empty
document is a replacement too, and a legal one. `is_replacement` says which,
`apply_replacement` does the work, and because all three call sites already go
through `apply`, that one branch covers the update command, findAndModify and
the upsert builder.

What a replacement means, precisely:

  - every field is replaced except `_id`, which is immutable and keeps its
    position at the front, where it is stored and where the `_id_` index
    descends on it;
  - a replacement may restate the same `_id` but not a different one -- that
    is `ImmutableId`, because otherwise a rewrite would silently change a
    document's identity while the index entry kept the old key;
  - when the target has no `_id` yet, the replacement supplies it. That is the
    upsert path: `build_upsert_doc` seeds a document from the filter's
    equalities, so `replaceOne({_id: 99}, {u: 1}, {upsert: true})` inserts
    `{_id: 99, u: 1}` and not a generated ObjectId;
  - a mixed document is refused from either side, rather than guessed at.

Two options on update specs are refused rather than ignored:

  - `multi` with a replacement (FailedToParse). A replacement describes one
    document; applying it to many would leave every match identical apart from
    its `_id`.
  - `sort`, a MongoDB 8.0 addition this server does not implement. Ignoring it
    is the worst of the three answers -- `sort` chooses *which* match to write,
    so the client would silently get a different document than it asked for.

  spec scorecard   131 pass / 161 fail  ->  161 pass / 131 fail
  e2e.js           35 checks -> 45

Sixteen spec files improved and none regressed. The two `-sort` files briefly
did: they had been passing on their "server-side error" case, which our
"bad update" failure satisfied by accident, and passing for the wrong reason is
how a gap survives a scorecard.

Five mutations, each verified red: seeding the replacement from the old pairs,
dropping the `_id` comparison, dropping the `_id` a replacement supplies, and
removing the mixed-document guard from either loop.

Note `nModified` is still wrong for a write that changes nothing -- MongoDB
counts a document as modified only if applying the update altered it. That is
the remaining bulkWrite failure and is fixed next, separately.
This commit is contained in:
2026-08-03 23:52:22 +03:00
parent 504179acd1
commit 53f88e6d3b
4 changed files with 304 additions and 66 deletions

View File

@@ -68,6 +68,66 @@ async function main() {
check('upsert', ups.upsertedCount === 1 && ups.matchedCount === 0, ups);
check('upsert doc exists', (await users.findOne({ name: 'erin' }))?.age === 28);
// --- replacement-style writes ---
// A replacement is data, not instructions: it replaces every field except
// `_id`. Distinct enough from operator updates to be worth its own block --
// these all used to fail with "bad update", because the update path required
// a $-prefixed first field.
const repl = db.collection('repl');
await repl.drop().catch(() => {});
await repl.insertMany([{ _id: 1, a: 1, gone: 'x' }, { _id: 2, a: 2 }, { _id: 3, a: 3 }]);
const rp = await repl.replaceOne({ _id: 1 }, { fresh: 9 });
check('replaceOne modifies one', rp.modifiedCount === 1, JSON.stringify(rp));
const rpDoc = await repl.findOne({ _id: 1 });
check(
'replaceOne keeps _id and drops the old fields',
rpDoc._id === 1 && rpDoc.fresh === 9 && rpDoc.a === undefined && rpDoc.gone === undefined,
JSON.stringify(rpDoc),
);
const farRaw = await repl.findOneAndReplace({ _id: 2 }, { swapped: true }, { returnDocument: 'after' });
const far = farRaw.value ?? farRaw;
check('findOneAndReplace returns the new document', far.swapped === true && far._id === 2, JSON.stringify(farRaw));
const bulkRep = await repl.bulkWrite([{ replaceOne: { filter: { _id: 3 }, replacement: { via: 'bulk' } } }]);
check('bulkWrite replaceOne', bulkRep.modifiedCount === 1, String(bulkRep.modifiedCount));
const rpUp = await repl.replaceOne({ _id: 99 }, { u: 1 }, { upsert: true });
check('replacement upsert takes the _id from the filter', String(rpUp.upsertedId) === '99', JSON.stringify(rpUp.upsertedId));
const rpUpDoc = await repl.findOne({ _id: 99 });
check(
'upserted document is the replacement plus _id',
rpUpDoc && rpUpDoc.u === 1 && Object.keys(rpUpDoc).length === 2,
JSON.stringify(rpUpDoc),
);
let idChangeRefused = false;
try { await repl.replaceOne({ _id: 1 }, { _id: 1234, x: 1 }); } catch { idChangeRefused = true; }
check('a replacement may not change _id', idChangeRefused);
const rpEmpty = await repl.replaceOne({ _id: 1 }, {});
const rpEmptyDoc = await repl.findOne({ _id: 1 });
check(
'an empty replacement leaves only _id',
rpEmpty.modifiedCount === 1 && Object.keys(rpEmptyDoc).length === 1,
JSON.stringify(rpEmptyDoc),
);
// Raw command: the driver refuses updateMany-with-a-replacement client side,
// so going through it would test the driver rather than this server.
let multiRefused = null;
try {
await db.command({ update: 'repl', updates: [{ q: {}, u: { a: 1 }, multi: true }] });
} catch (e) { multiRefused = e; }
check(
'multi with a replacement is refused with FailedToParse',
multiRefused?.code === 9 && /replacement-style/.test(multiRefused.message),
`code=${multiRefused?.code} ${multiRefused?.message?.slice(0, 50)}`,
);
const multiOps = await db.command({ update: 'repl', updates: [{ q: {}, u: { $set: { touched: 1 } }, multi: true }] });
check('multi with operators still runs', multiOps.ok === 1 && multiOps.n === 4, JSON.stringify(multiOps));
// --- findOneAndUpdate (findAndModify) ---
const fam = await users.findOneAndUpdate(
{ name: 'bob' },