diff --git a/src/cursor/aggregation_cursor.ts b/src/cursor/aggregation_cursor.ts index 622bce14aa..9762c8a03b 100644 --- a/src/cursor/aggregation_cursor.ts +++ b/src/cursor/aggregation_cursor.ts @@ -1,5 +1,5 @@ import type { Document } from '../bson'; -import type { ExplainVerbosityLike } from '../explain'; +import type { ExplainCommandOptions, ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import { AggregateOperation, type AggregateOptions } from '../operations/aggregate'; import { executeOperation } from '../operations/execute_operation'; @@ -66,7 +66,7 @@ export class AggregationCursor extends AbstractCursor { } /** Execute the explain for the cursor */ - async explain(verbosity?: ExplainVerbosityLike): Promise { + async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise { return ( await executeOperation( this.client, diff --git a/src/cursor/find_cursor.ts b/src/cursor/find_cursor.ts index ef21cea290..83a12818bd 100644 --- a/src/cursor/find_cursor.ts +++ b/src/cursor/find_cursor.ts @@ -1,7 +1,7 @@ import { type Document } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; import { MongoInvalidArgumentError, MongoTailableCursorError } from '../error'; -import { type ExplainVerbosityLike } from '../explain'; +import { type ExplainCommandOptions, type ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import type { CollationOptions } from '../operations/command'; import { CountOperation, type CountOptions } from '../operations/count'; @@ -133,7 +133,7 @@ export class FindCursor extends AbstractCursor { } /** Execute the explain for the cursor */ - async explain(verbosity?: ExplainVerbosityLike): Promise { + async explain(verbosity?: ExplainVerbosityLike | ExplainCommandOptions): Promise { return ( await executeOperation( this.client, diff --git a/src/explain.ts b/src/explain.ts index 0d08e694a6..c60a2e4628 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -1,5 +1,3 @@ -import { MongoInvalidArgumentError } from './error'; - /** @public */ export const ExplainVerbosity = Object.freeze({ queryPlanner: 'queryPlanner', @@ -20,16 +18,53 @@ export type ExplainVerbosity = string; export type ExplainVerbosityLike = ExplainVerbosity | boolean; /** @public */ +export interface ExplainCommandOptions { + /** The explain verbosity for the command. */ + verbosity: ExplainVerbosity; + /** The maxTimeMS setting for the command. */ + maxTimeMS?: number; +} + +/** + * @public + * + * When set, this configures an explain command. Valid values are boolean (for legacy compatibility, + * see {@link ExplainVerbosityLike}), a string containing the explain verbosity, or an object containing the verbosity and + * an optional maxTimeMS. + * + * Examples of valid usage: + * + * ```typescript + * collection.find({ name: 'john doe' }, { explain: true }); + * collection.find({ name: 'john doe' }, { explain: false }); + * collection.find({ name: 'john doe' }, { explain: 'queryPlanner' }); + * collection.find({ name: 'john doe' }, { explain: { verbosity: 'queryPlanner' } }); + * ``` + * + * maxTimeMS can be configured to limit the amount of time the server + * spends executing an explain by providing an object: + * + * ```typescript + * // limits the `explain` command to no more than 2 seconds + * collection.find({ name: 'john doe' }, { + * explain: { + * verbosity: 'queryPlanner', + * maxTimeMS: 2000 + * } + * }); + * ``` + */ export interface ExplainOptions { /** Specifies the verbosity mode for the explain output. */ - explain?: ExplainVerbosityLike; + explain?: ExplainVerbosityLike | ExplainCommandOptions; } /** @internal */ export class Explain { - verbosity: ExplainVerbosity; + readonly verbosity: ExplainVerbosity; + readonly maxTimeMS?: number; - constructor(verbosity: ExplainVerbosityLike) { + private constructor(verbosity: ExplainVerbosityLike, maxTimeMS?: number) { if (typeof verbosity === 'boolean') { this.verbosity = verbosity ? ExplainVerbosity.allPlansExecution @@ -37,16 +72,18 @@ export class Explain { } else { this.verbosity = verbosity; } + + this.maxTimeMS = maxTimeMS; } - static fromOptions(options?: ExplainOptions): Explain | undefined { - if (options?.explain == null) return; + static fromOptions({ explain }: ExplainOptions = {}): Explain | undefined { + if (explain == null) return; - const explain = options.explain; if (typeof explain === 'boolean' || typeof explain === 'string') { return new Explain(explain); } - throw new MongoInvalidArgumentError('Field "explain" must be a string or a boolean'); + const { verbosity, maxTimeMS } = explain; + return new Explain(verbosity, maxTimeMS); } } diff --git a/src/index.ts b/src/index.ts index eeebbd1154..dda026323a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -368,7 +368,12 @@ export type { RunCursorCommandOptions } from './cursor/run_command_cursor'; export type { DbOptions, DbPrivate } from './db'; export type { Encrypter, EncrypterOptions } from './encrypter'; export type { AnyError, ErrorDescription, MongoNetworkErrorOptions } from './error'; -export type { Explain, ExplainOptions, ExplainVerbosityLike } from './explain'; +export type { + Explain, + ExplainCommandOptions, + ExplainOptions, + ExplainVerbosityLike +} from './explain'; export type { GridFSBucketReadStreamOptions, GridFSBucketReadStreamOptionsWithRevision, diff --git a/src/utils.ts b/src/utils.ts index 07fe8e56a6..5ad754c932 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,7 +25,7 @@ import { MongoParseError, MongoRuntimeError } from './error'; -import type { Explain } from './explain'; +import type { Explain, ExplainVerbosity } from './explain'; import type { MongoClient } from './mongo_client'; import type { CommandOperationOptions, OperationParent } from './operations/command'; import type { Hint, OperationOptions } from './operations/operation'; @@ -251,12 +251,23 @@ export function decorateWithReadConcern( * @param command - the command on which to apply the explain * @param options - the options containing the explain verbosity */ -export function decorateWithExplain(command: Document, explain: Explain): Document { - if (command.explain) { - return command; +export function decorateWithExplain( + command: Document, + explain: Explain +): { + explain: Document; + verbosity: ExplainVerbosity; + maxTimeMS?: number; +} { + type ExplainCommand = ReturnType; + const { verbosity, maxTimeMS } = explain; + const baseCommand: ExplainCommand = { explain: command, verbosity }; + + if (typeof maxTimeMS === 'number') { + baseCommand.maxTimeMS = maxTimeMS; } - return { explain: command, verbosity: explain.verbosity }; + return baseCommand; } /** diff --git a/test/integration/crud/crud.prose.test.ts b/test/integration/crud/crud.prose.test.ts index 5682c72043..3ddc126d33 100644 --- a/test/integration/crud/crud.prose.test.ts +++ b/test/integration/crud/crud.prose.test.ts @@ -1,7 +1,14 @@ import { expect } from 'chai'; import { once } from 'events'; -import { MongoBulkWriteError, type MongoClient, MongoServerError } from '../../mongodb'; +import { type CommandStartedEvent } from '../../../mongodb'; +import { + type Collection, + MongoBulkWriteError, + type MongoClient, + MongoServerError +} from '../../mongodb'; +import { filterForCommands } from '../shared'; describe('CRUD Prose Spec Tests', () => { let client: MongoClient; @@ -143,4 +150,42 @@ describe('CRUD Prose Spec Tests', () => { } }); }); + + describe('14. `explain` helpers allow users to specify `maxTimeMS`', function () { + let client: MongoClient; + const commands: CommandStartedEvent[] = []; + let collection: Collection; + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + await client.connect(); + + await client.db('explain-test').dropDatabase(); + collection = await client.db('explain-test').createCollection('collection'); + + client.on('commandStarted', filterForCommands('explain', commands)); + commands.length = 0; + }); + + afterEach(async function () { + await client.close(); + }); + + it('sets maxTimeMS on explain commands, when specified', async function () { + await collection + .find( + { name: 'john doe' }, + { + explain: { + maxTimeMS: 2000, + verbosity: 'queryPlanner' + } + } + ) + .toArray(); + + const [{ command }] = commands; + expect(command).to.have.property('maxTimeMS', 2000); + }); + }); }); diff --git a/test/integration/crud/explain.test.ts b/test/integration/crud/explain.test.ts index 189a1ca387..44fe381303 100644 --- a/test/integration/crud/explain.test.ts +++ b/test/integration/crud/explain.test.ts @@ -8,6 +8,7 @@ import { type MongoClient, MongoServerError } from '../../mongodb'; +import { filterForCommands } from '../shared'; const explain = [true, false, 'queryPlanner', 'allPlansExecution', 'executionStats', 'invalid']; @@ -117,6 +118,184 @@ describe('CRUD API explain option', function () { }); } } + + describe('explain helpers w/ maxTimeMS', function () { + let client: MongoClient; + const commands: CommandStartedEvent[] = []; + let collection: Collection; + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + await client.connect(); + + await client.db('explain-test').dropDatabase(); + collection = await client.db('explain-test').createCollection('bar'); + + client.on('commandStarted', filterForCommands('explain', commands)); + commands.length = 0; + }); + + afterEach(async function () { + await client.close(); + }); + + describe('maxTimeMS provided to explain, not to command', function () { + describe('cursor commands', function () { + describe('options API', function () { + beforeEach(async function () { + await collection + .find({}, { explain: { maxTimeMS: 1000, verbosity: 'queryPlanner' } }) + .toArray(); + }); + + it('attaches maxTimeMS to the explain command', expectOnExplain(1000)); + + it('does not attach maxTimeMS to the find command', expectNotOnCommand()); + }); + + describe('fluent API', function () { + beforeEach(async function () { + await collection.find({}).explain({ maxTimeMS: 1000, verbosity: 'queryPlanner' }); + }); + + it('attaches maxTimeMS to the explain command', expectOnExplain(1000)); + + it('does not attach maxTimeMS to the find command', expectNotOnCommand()); + }); + }); + + describe('non-cursor commands', function () { + beforeEach(async function () { + await collection.deleteMany( + {}, + { explain: { maxTimeMS: 1000, verbosity: 'queryPlanner' } } + ); + }); + + it('attaches maxTimeMS to the explain command', expectOnExplain(1000)); + + it('does not attach maxTimeMS to the explained command', expectNotOnCommand()); + }); + }); + + describe('maxTimeMS provided to command, not explain', function () { + describe('cursor commands', function () { + describe('options API', function () { + beforeEach(async function () { + await collection + .find({}, { maxTimeMS: 1000, explain: { verbosity: 'queryPlanner' } }) + .toArray(); + }); + + it('does not attach maxTimeMS to the explain command', expectNotOnExplain()); + + it('attaches maxTimeMS to the find command', expectOnCommand(1000)); + }); + + describe('fluent API', function () { + beforeEach(async function () { + await collection.find({}, { maxTimeMS: 1000 }).explain({ verbosity: 'queryPlanner' }); + }); + + it('does not attach maxTimeMS to the explain command', expectNotOnExplain()); + + it('attaches maxTimeMS to the find command', expectOnCommand(1000)); + }); + }); + + describe('non-cursor commands', function () { + beforeEach(async function () { + await collection.deleteMany( + {}, + { maxTimeMS: 1000, explain: { verbosity: 'queryPlanner' } } + ); + }); + + it('does nto attach maxTimeMS to the explain command', expectNotOnExplain()); + + it('attaches maxTimeMS to the explained command', expectOnCommand(1000)); + }); + }); + + describe('maxTimeMS specified in command options and explain options', function () { + describe('cursor commands', function () { + describe('options API', function () { + beforeEach(async function () { + await collection + .find( + {}, + { maxTimeMS: 1000, explain: { maxTimeMS: 2000, verbosity: 'queryPlanner' } } + ) + .toArray(); + }); + + it('attaches maxTimeMS from the explain options to explain', expectOnExplain(2000)); + + it('attaches maxTimeMS from the find options to the find command', expectOnCommand(1000)); + }); + + describe('fluent API', function () { + beforeEach(async function () { + await collection + .find({}, { maxTimeMS: 1000 }) + .explain({ maxTimeMS: 2000, verbosity: 'queryPlanner' }); + }); + + it('attaches maxTimeMS from the explain options to explain', expectOnExplain(2000)); + + it('attaches maxTimeMS from the find options to the find command', expectOnCommand(1000)); + }); + }); + + describe('non-cursor commands', function () { + beforeEach(async function () { + await collection.deleteMany( + {}, + { maxTimeMS: 1000, explain: { maxTimeMS: 2000, verbosity: 'queryPlanner' } } + ); + }); + + it('attaches maxTimeMS to the explain command', expectOnExplain(2000)); + + it('attaches maxTimeMS to the explained command', expectOnCommand(1000)); + }); + }); + + function expectOnExplain(value: number) { + return function () { + const [{ command }] = commands; + expect(command).to.have.property('maxTimeMS', value); + }; + } + + function expectNotOnExplain() { + return function () { + const [{ command }] = commands; + expect(command).not.to.have.property('maxTimeMS'); + }; + } + + function expectOnCommand(value: number) { + return function () { + const [ + { + command: { explain } + } + ] = commands; + expect(explain).to.have.property('maxTimeMS', value); + }; + } + function expectNotOnCommand() { + return function () { + const [ + { + command: { explain } + } + ] = commands; + expect(explain).not.to.have.property('maxTimeMS'); + }; + } + }); }); function explainValueToExpectation(explainValue: boolean | string) { diff --git a/test/integration/shared.js b/test/integration/shared.js index b4ae4030b0..6bbbff6beb 100644 --- a/test/integration/shared.js +++ b/test/integration/shared.js @@ -42,6 +42,26 @@ function dropCollection(dbObj, collectionName, options = {}) { return dbObj.dropCollection(collectionName, options).catch(ignoreNsNotFound); } +/** + * Given a set of commands to look for when command monitoring and a destination to store them, returns an event handler + * that collects the specified events. + * + * ```typescript + * const commands = []; + * + * // one command + * client.on('commandStarted', filterForCommands('ping', commands)); + * // multiple commands + * client.on('commandStarted', filterForCommands(['ping', 'find'], commands)); + * // custom predicate + * client.on('commandStarted', filterForCommands((command) => command.commandName === 'find', commands)); + * ``` + * @param {string | string[] | (arg0: string) => boolean} commands A set of commands to look for. Either + * a single command name (string), a list of command names (string[]) or a predicate function that + * determines whether or not a command should be kept. + * @param {Array} bag the output for the filtered commands + * @returns a function that collects the specified comment events + */ function filterForCommands(commands, bag) { if (typeof commands === 'function') { return function (event) { diff --git a/test/unit/explain.test.ts b/test/unit/explain.test.ts new file mode 100644 index 0000000000..8d71197a81 --- /dev/null +++ b/test/unit/explain.test.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { it } from 'mocha'; + +import { Explain, ExplainVerbosity } from '../mongodb'; + +describe('class Explain {}', function () { + describe('static .fromOptions()', function () { + it('when no options are provided, it returns undefined', function () { + expect(Explain.fromOptions()).to.be.undefined; + }); + + it('explain=true constructs an allPlansExecution explain', function () { + const explain = Explain.fromOptions({ explain: true }); + expect(explain).to.have.property('verbosity', ExplainVerbosity.allPlansExecution); + expect(explain).to.have.property('maxTimeMS').to.be.undefined; + }); + + it('explain=false constructs an allPlansExecution explain', function () { + const explain = Explain.fromOptions({ explain: false }); + expect(explain).to.have.property('verbosity', ExplainVerbosity.queryPlanner); + expect(explain).to.have.property('maxTimeMS').to.be.undefined; + }); + + it('explain= constructs an explain with verbosity set to the string', function () { + const explain = Explain.fromOptions({ explain: 'some random string' }); + expect(explain).to.have.property('verbosity', 'some random string'); + expect(explain).to.have.property('maxTimeMS').to.be.undefined; + }); + + describe('when explain is an object', function () { + it('uses the verbosity from the object', function () { + const explain = Explain.fromOptions({ + explain: { + verbosity: 'some random string' + } + }); + expect(explain).to.have.property('verbosity', 'some random string'); + expect(explain).to.have.property('maxTimeMS').to.be.undefined; + }); + + it('when a maxTimeMS is provided, it constructs an explain with the maxTImeMS value', function () { + const explain = Explain.fromOptions({ + explain: { + verbosity: 'some random string', + maxTimeMS: 2000 + } + }); + expect(explain).to.have.property('verbosity', 'some random string'); + expect(explain).to.have.property('maxTimeMS', 2000); + }); + }); + }); +}); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index f796a65280..7494fb080f 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -4,6 +4,8 @@ import { BufferPool, ByteUtils, compareObjectId, + decorateWithExplain, + Explain, HostAddress, hostMatchesWildcards, isHello, @@ -1003,4 +1005,42 @@ describe('driver utils', function () { describe('when given an object that does not respond to Symbol.toStringTag', () => it('returns false', () => expect(isUint8Array(Object.create(null))).to.be.false)); }); + + describe('decorateWithExplain()', function () { + it('when the command is a valid explain command, the command is still wrapped', function () { + const command = { explain: { hello: 'world' } }; + const result = decorateWithExplain(command, Explain.fromOptions({ explain: true })); + + expect(result).to.deep.equal({ explain: command, verbosity: 'allPlansExecution' }); + }); + + it('when the options have a maxTimeMS, it is attached to the explain command', function () { + const command = { ping: 1 }; + const result = decorateWithExplain( + command, + Explain.fromOptions({ + explain: { verbosity: 'queryPlanner', maxTimeMS: 1000 } + }) + ); + expect(result).to.deep.equal({ + explain: { ping: 1 }, + verbosity: 'queryPlanner', + maxTimeMS: 1000 + }); + }); + + it('when the options have do not have a maxTimeMS, it is not attached to the explain command', function () { + const command = { ping: 1 }; + const result = decorateWithExplain( + command, + Explain.fromOptions({ + explain: { verbosity: 'queryPlanner' } + }) + ); + expect(result).to.deep.equal({ + explain: { ping: 1 }, + verbosity: 'queryPlanner' + }); + }); + }); });