PLAN §3 lists eight operators for M3 -- `$setOnInsert`, `$addToSet`, `$mul`,
`$min`, `$max`, `$pop`, `$pullAll`, `$currentDate` -- and the pinned crud
corpus says almost nothing about any of them. A probe running the identical
update against mongod 8.3.7 and this server found all eight answering
`bad update`, code 2, one message for every question.
It also found the thing this directory exists for: `$push`'s `$slice`,
`$position` and `$sort` are **silently ignored**. `{$each: [3, 4], $slice: -3}`
appends both values, slices nothing, and answers ok: 1 with modifiedCount: 1.
A missing operator is an error the client can see; a modifier that is parsed,
accepted and then dropped is the same class of wrong answer the positional
operators were.
103 cases in six files, 18 pass / 84 fail -- red by construction, like
`tests/spec/aggregate/expressions.json` at 1/26 and the positional corpus at
15/36. Inputs authored in `sources/`, every expectation measured.
Two things here cannot be recorded as values, and both become a `$$type`
assertion rather than being left out: a `$currentDate` field is whatever the
clock said (named per case in `volatile`, so a real stored date can still be
pinned one day), and an upsert that inserts gets a generated ObjectId (that
one automatic -- no source authors an ObjectId). Everything else is compared
exactly. Files are canonical extended JSON, which the runner already parses
that way: `$mul` overflowing an int32 produces an int64, and writing
`4000000000` as a bare number would not have said so.
What recording it settled, none of it guessable:
- `$mul` of a missing field writes **0**, not the operand; of a non-numeric
field, or by one, TypeMismatch (14).
- `$min`/`$max` are not numeric operators. They compare in BSON canonical
order, so `$min: {s: 5}` on `s: "b"` writes 5, and a missing field is
always written.
- two operators writing one field is **ConflictingUpdateOperators (40)** --
`$min`+`$max`, `$set`+`$inc`, `$setOnInsert`+`$set`. A whole error class
this server does not have.
- `$addToSet` compares documents whole, **field order included**:
`{a:1,b:2}` and `{b:2,a:1}` are two values. But `2` and `2.0` are one.
- `$push` modifiers are only modifiers when `$each` is there: `{$slice: 1}`
alone is a value to push. With it, the order is position, then sort the
whole array, then slice.
- `$currentDate` with `false` still writes a date.
- `$setOnInsert` may write `_id` on an insert, where `$set` may not.
- an unknown modifier is FailedToParse (9), not BadValue.
One case was authored and then removed: `{b: 1, $set: {c: 1}}` never reaches a
server -- the driver rejects it -- so there was no answer to record and the
case would have asserted nothing.
223 lines
9.0 KiB
JavaScript
223 lines
9.0 KiB
JavaScript
// Record an update-operator corpus by asking a real mongod what the answer is.
|
|
//
|
|
// Eight operators PLAN §3 lists for M3 -- `$setOnInsert`, `$addToSet`, `$mul`,
|
|
// `$min`, `$max`, `$pop`, `$pullAll`, `$currentDate` -- plus `$push`'s
|
|
// `$slice`/`$position`/`$sort` modifiers. The pinned crud corpus contains
|
|
// almost nothing about any of them, and a probe against both servers found
|
|
// every one of the eight answering `bad update` here, and the three `$push`
|
|
// modifiers *silently ignored*: `{$each: [3, 4], $slice: -3}` appended without
|
|
// slicing and answered ok: 1. Same discipline as `tests/spec/positional/` and
|
|
// `tests/spec/aggregate/`: inputs are authored in `sources/`, expectations are
|
|
// measured here.
|
|
//
|
|
// node tests/spec/operators/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.
|
|
const DRIVER = path.join(__dirname, '..', '..', 'e2e', 'node_modules', 'mongodb');
|
|
const { MongoClient } = require(DRIVER);
|
|
const { EJSON, ObjectId, Timestamp } = require(path.join(DRIVER, 'lib', 'bson.js'));
|
|
|
|
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 = 'operator-corpus';
|
|
const COLL = 'coll';
|
|
|
|
// Every construct here predates 4.0, so nothing depends on a server newer than
|
|
// the 4.4 this one reports.
|
|
const SCHEMA_VERSION = '1.4';
|
|
|
|
const UPDATE_KEYS = ['matchedCount', 'modifiedCount', 'upsertedCount'];
|
|
|
|
/// Values nobody can predict, replaced by an assertion about their type.
|
|
///
|
|
/// Two kinds, and both are the corpus staying honest rather than the corpus
|
|
/// looking away: a `$currentDate` field is whatever the clock said, and an
|
|
/// upsert that inserts gets a generated ObjectId. Everything else about the
|
|
/// document -- which fields exist, in what order, holding what -- is still
|
|
/// compared exactly, so a case that lost a field still fails.
|
|
///
|
|
/// The ObjectId rule is automatic because it is unambiguous: no source file
|
|
/// authors one. A date is not: `volatile` names those per case, so a corpus
|
|
/// that one day wants to pin a real stored date still can.
|
|
function maskGenerated(value) {
|
|
if (value instanceof ObjectId) return { $$type: 'objectId' };
|
|
if (Array.isArray(value)) return value.map(maskGenerated);
|
|
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(value)) out[k] = maskGenerated(v);
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function maskVolatile(docs, paths) {
|
|
for (const p of paths) {
|
|
for (const doc of docs) {
|
|
const segs = p.split('.');
|
|
let cur = doc;
|
|
for (const s of segs.slice(0, -1)) cur = cur === undefined ? undefined : cur[s];
|
|
const last = segs[segs.length - 1];
|
|
if (cur === undefined || !(last in cur)) {
|
|
throw new Error(`volatile path '${p}' is not in the recorded document ` +
|
|
`${JSON.stringify(doc)} -- the case did not write what it said it would`);
|
|
}
|
|
const v = cur[last];
|
|
if (v instanceof Date) cur[last] = { $$type: 'date' };
|
|
else if (v instanceof Timestamp) cur[last] = { $$type: 'timestamp' };
|
|
else throw new Error(`volatile path '${p}' holds ${v}, which is neither a date nor a timestamp`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
const text = EJSON.stringify(out, { relaxed: false, indent: 2 });
|
|
fs.writeFileSync(path.join(__dirname, `${name}.json`), text + '\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) {
|
|
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*:
|
|
// the unified format's `initialData` is per file, and the runner seeds
|
|
// it once per test.
|
|
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 = maskGenerated(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 several of these embed a rendering of the
|
|
// offending BSON that no formatter here produces.
|
|
delete op.expectResult;
|
|
op.expectError = { isError: true, errorCode: e.code };
|
|
}
|
|
|
|
// Recorded for every case including the refusals: a refusal that left
|
|
// the document half-written would look identical to a clean one in
|
|
// `expectError` alone.
|
|
const after = await coll.find({}, { sort: { _id: 1 } }).toArray();
|
|
if (c.volatile) maskVolatile(after, c.volatile);
|
|
|
|
tests.push({
|
|
description: c.description,
|
|
operations: [...setup, op],
|
|
outcome: [{
|
|
collectionName: COLL,
|
|
databaseName: DB_NAME,
|
|
documents: after.map(maskGenerated),
|
|
}],
|
|
});
|
|
}
|
|
await coll.drop().catch(() => {});
|
|
|
|
return {
|
|
description: name,
|
|
schemaVersion: SCHEMA_VERSION,
|
|
// Recorded, not authored. Regenerate with tests/spec/operators/record.js.
|
|
createEntities: [
|
|
{ client: { id: 'client0' } },
|
|
{ database: { id: 'database0', client: 'client0', databaseName: DB_NAME } },
|
|
{ collection: { id: 'collection0', database: 'database0', collectionName: COLL } },
|
|
],
|
|
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);
|
|
});
|