Files
MultiforaDB/tests/spec/positional/record.js
A.Shakhmatov e5a84c0598 tests/spec: a recorded corpus for the positional operators
M3's gate as named -- "remaining crud coverage; e2e3/e2e4 green" -- cannot
see this work. e2e3 and e2e4 contain zero positional paths, and the pinned
crud corpus covers `$[<identifier>]` only: no `$[]` case, no bare `$` case
anywhere in it. Both stayed green through a bug that replaced an array with
`{"$[i]": {...}}` and answered ok: 1. So M3 brings its own corpus, built the
way M2.5's was: inputs authored in `sources/`, every expectation recorded
from mongod 8.3.7, run through the shared runner with `--suite-dir`.

51 cases across the three spellings. It stands at 15 pass / 36 fail against
the refusal, which is the intended shape -- `expressions.json` was recorded
at 1 pass / 26 fail before the evaluator and is green now. The 15 that pass
are refusals where this server's code already matches; of the 36, 31 are the
constructs answering "not implemented" and 5 are refusals whose code
differs, four of them arrayFilters validation this server cannot do because
it never parses the option.

Every case records its `outcome`, refusals included. That is deliberate and
it is the whole point of the file: a refusal that left the document mangled
is indistinguishable from a clean one in `expectError` alone, and a mangled
document is what this corpus exists to catch.

What recording it settled, none of it guessable, the first contradicting
what the design review assumed:

  - `y.$[i].c.$[i].d`, one identifier reused at two levels, is **accepted**
    -- not a duplicate-identifier error
  - `$[]` over an empty array is a no-op with modifiedCount 0
  - `$[]` over an array with a non-document element is error 28, where every
    other path failure here is 2
  - a positional segment never creates: a missing or non-array path is an
    error, where `$set: {'a.b': 1}` would construct one
  - an upsert gets no special case -- it fails for the same reason
  - `$` writes only the first matching element, and is refused when the query
    never touched the array
  - any of the three in first position is refused, as is `$` twice in a path
  - `arrayFilters` alongside a replacement is ignored rather than refused

A case needing its own documents reseeds through operations rather than
`initialData`, because the format's `initialData` is per file and the runner
seeds it once per test. That keeps one file per operator instead of one file
per document shape.

crud scorecard unchanged at 204/87, aggregation corpus still 70/0.
2026-08-10 19:00:01 +03:00

201 lines
8.6 KiB
JavaScript

// Record a positional-update corpus by asking a real mongod what the answer is.
//
// The pinned crud suite tests `$[<identifier>]` 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 `$[<identifier>]` 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/<name>.json`: documents, and a list of operations
// 2. run this against a real mongod
// 3. it writes `<name>.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 <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: 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
// `$[<id>]` 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 <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);
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);
});