// 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 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. 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 \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); });