`tests/spec/indexes/` is 42/42, so all four recorded corpora are green:
positional 51, operators 125, indexes 42, aggregate 70.
Records what implementing the row taught, including the three things the
design review got wrong. Two were already noted when the corpus was
recorded ($in is allowed; a differing filter is 86); the third is new and
cheaper than the review's version: hashed needs no flags bit, because it
belongs to a key *component* and the per-component direction byte was
already there holding 0 or 1.
Also fixes a corpus case that did not measure what it said. "equality
across numeric types" sent an int32, because a source is plain JSON and
`5.0` is `5` after JSON.parse -- it was a second copy of the case above
it. Reading sources as EJSON was tried and reverted, and the recorder now
says why: EJSON's wrapper namespace collides with the query operators
these sources are made of, so `{"$regex": "x"}` became a BSONRegExp,
`structuredClone` flattened it to `{pattern, options}`, and "a filter
using $regex is refused" silently became a filter mongod accepts. The
cross-type property is a unit test instead, which is where it belongs --
it is about how this server hashes, and mongod's hash is a different
function, so a corpus could only ever check the answer.
The case is renamed to what it does measure rather than deleted: two
documents sharing a value is still the read a hashed index exists for.
Verified: 256/256 unit tests in ReleaseFast and ReleaseSafe, 87/87 fuzz,
all four corpora 0 fail, pinned scorecard unchanged at 228/63/196, the
full e2e matrix and crash-fuzz green.
196 lines
8.3 KiB
JavaScript
196 lines
8.3 KiB
JavaScript
// Record an index corpus by asking a real mongod what the answer is.
|
|
//
|
|
// Partial and hashed indexes: M3's last row. `docs/M3_INDEX_TYPES_DESIGN_REVIEW.md`
|
|
// found that `partialFilterExpression` was accepted and ignored here, which
|
|
// made a *unique* partial index refuse inserts mongod accepts -- and that no
|
|
// test in this repository covered the row, in any suite. The pinned corpus is
|
|
// crud and aggregate; `e2e5.js` and `e2e6.js` test indexes but write neither
|
|
// a partial nor a hashed spec.
|
|
//
|
|
// Unlike `tests/spec/operators/`, a case here is a *sequence*: create an
|
|
// index, insert against it, read back, list it. So the source format carries
|
|
// a list of operations rather than one, and the recorder walks them in order,
|
|
// stopping at the first that throws -- which is what a client would see.
|
|
//
|
|
// node tests/spec/indexes/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');
|
|
const DRIVER = path.join(__dirname, '..', '..', 'e2e', 'node_modules', 'mongodb');
|
|
const { MongoClient } = require(DRIVER);
|
|
const { EJSON, ObjectId } = 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 = 'index-corpus';
|
|
const COLL = 'coll';
|
|
const SCHEMA_VERSION = '1.4';
|
|
|
|
/// An ObjectId is the one value no source authors, so masking it is
|
|
/// unambiguous. Nothing else here is unpredictable: an index name, a key
|
|
/// specification and a document count are all decided by the input.
|
|
function mask(value) {
|
|
if (value instanceof ObjectId) return { $$type: 'objectId' };
|
|
if (Array.isArray(value)) return value.map(mask);
|
|
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(value)) out[k] = mask(v);
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function main() {
|
|
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();
|
|
for (const file of sources) {
|
|
// Plain JSON, deliberately, and the cost is worth stating: a source
|
|
// cannot name a BSON type the JSON grammar has no syntax for, so
|
|
// `5.0` reaches the driver as an int32 and no source here can ask a
|
|
// cross-type question. Reading sources as EJSON instead was tried and
|
|
// reverted -- EJSON's wrapper namespace collides with the query
|
|
// operators these sources are made of. `{"$regex": "x"}` parses to a
|
|
// BSONRegExp, `structuredClone` below then flattens it to
|
|
// `{pattern, options}`, and "a filter using $regex is refused"
|
|
// silently became a filter mongod accepts.
|
|
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`), EJSON.stringify(out, { relaxed: false, indent: 2 }) + '\n');
|
|
const errs = out.tests.filter((t) => t.operations.some((o) => o.expectError)).length;
|
|
console.log(` ${name}: ${out.tests.length} cases, ${errs} of them reaching an error`);
|
|
}
|
|
await client.close();
|
|
console.log('RECORDED');
|
|
}
|
|
|
|
async function record(client, name, src) {
|
|
const db = client.db(DB_NAME);
|
|
const coll = db.collection(COLL);
|
|
const tests = [];
|
|
|
|
for (const c of src.cases) {
|
|
const documents = c.documents === undefined ? src.documents : c.documents;
|
|
// Dropped rather than emptied: an index outlives a `deleteMany`, and
|
|
// every case here is about which indexes exist.
|
|
await coll.drop().catch(() => {});
|
|
if (documents.length) await coll.insertMany(structuredClone(documents));
|
|
|
|
const operations = [];
|
|
// A case that brings its own documents reseeds through operations,
|
|
// because `initialData` is per file and seeded once per test.
|
|
if (c.documents !== undefined) {
|
|
operations.push({ object: 'collection0', name: 'deleteMany', arguments: { filter: {} } });
|
|
if (documents.length) {
|
|
operations.push({
|
|
object: 'collection0',
|
|
name: 'insertMany',
|
|
arguments: { documents: structuredClone(documents) },
|
|
});
|
|
}
|
|
}
|
|
for (const op of c.ops) {
|
|
const rec = { object: 'collection0', name: op.name, arguments: structuredClone(op.arguments) };
|
|
try {
|
|
rec.expectResult = mask(await invoke(coll, op.name, structuredClone(op.arguments)));
|
|
} catch (e) {
|
|
// The code, not the message. mongod's index-specification
|
|
// errors embed a rendering of the whole spec, which no
|
|
// formatter here produces.
|
|
delete rec.expectResult;
|
|
rec.expectError = { isError: true, errorCode: e.code };
|
|
operations.push(rec);
|
|
break; // a client would stop here too
|
|
}
|
|
operations.push(rec);
|
|
}
|
|
|
|
const after = await coll.find({}, { sort: { _id: 1 } }).toArray();
|
|
tests.push({
|
|
description: c.description,
|
|
operations,
|
|
outcome: [{ collectionName: COLL, databaseName: DB_NAME, documents: after.map(mask) }],
|
|
});
|
|
}
|
|
await coll.drop().catch(() => {});
|
|
|
|
return {
|
|
description: name,
|
|
schemaVersion: SCHEMA_VERSION,
|
|
// Recorded, not authored. Regenerate with tests/spec/indexes/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) {
|
|
switch (name) {
|
|
case 'createIndex': {
|
|
const { keys, ...rest } = args;
|
|
return await coll.createIndex(keys, rest);
|
|
}
|
|
case 'listIndexes':
|
|
return await coll.listIndexes().toArray();
|
|
case 'dropIndex':
|
|
return plain(await coll.dropIndex(args.name));
|
|
case 'insertMany':
|
|
return plain(await coll.insertMany(args.documents));
|
|
case 'insertOne':
|
|
return plain(await coll.insertOne(args.document));
|
|
case 'find':
|
|
return await coll.find(args.filter || {}, { sort: args.sort }).toArray();
|
|
case 'countDocuments':
|
|
return await coll.countDocuments(args.filter || {});
|
|
case 'deleteMany':
|
|
return plain(await coll.deleteMany(args.filter || {}));
|
|
case 'updateOne':
|
|
return plain(await coll.updateOne(args.filter, args.update, args));
|
|
default:
|
|
throw new Error(`record.js does not know the operation '${name}'`);
|
|
}
|
|
}
|
|
|
|
/// Driver results carry `acknowledged` and driver-side types; the unified
|
|
/// format's expectations are the plain counts and ids.
|
|
function plain(result) {
|
|
const out = {};
|
|
for (const k of ['insertedCount', 'insertedId', 'insertedIds', 'deletedCount', 'matchedCount', 'modifiedCount', 'upsertedCount']) {
|
|
if (result[k] !== undefined) out[k] = result[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('RECORD_FAIL', e);
|
|
process.exit(1);
|
|
});
|