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.
201 lines
8.6 KiB
JavaScript
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);
|
|
});
|