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