diff --git a/src/cmap/wire_protocol/query.ts b/src/cmap/wire_protocol/query.ts index 798f79fc29..24d8110367 100644 --- a/src/cmap/wire_protocol/query.ts +++ b/src/cmap/wire_protocol/query.ts @@ -1,12 +1,13 @@ import { command, CommandOptions } from './command'; import { Query } from '../commands'; import { MongoError } from '../../error'; -import { maxWireVersion, collectionNamespace, Callback } from '../../utils'; +import { maxWireVersion, collectionNamespace, Callback, decorateWithExplain } from '../../utils'; import { getReadPreference, isSharded, applyCommonQueryOptions } from './shared'; import { Document, pluckBSONSerializeOptions } from '../../bson'; import type { Server } from '../../sdam/server'; import type { ReadPreferenceLike } from '../../read_preference'; import type { FindOptions } from '../../operations/find'; +import { Explain } from '../../explain'; /** @internal */ export interface QueryOptions extends CommandOptions { @@ -43,7 +44,14 @@ export function query( } const readPreference = getReadPreference(cmd, options); - const findCmd = prepareFindCommand(server, ns, cmd); + let findCmd = prepareFindCommand(server, ns, cmd); + + // If we have explain, we need to rewrite the find command + // to wrap it in the explain command + const explain = Explain.fromOptions(options); + if (explain) { + findCmd = decorateWithExplain(findCmd, explain); + } // NOTE: This actually modifies the passed in cmd, and our code _depends_ on this // side-effect. Change this ASAP @@ -62,7 +70,7 @@ export function query( } function prepareFindCommand(server: Server, ns: string, cmd: Document) { - let findCmd: Document = { + const findCmd: Document = { find: collectionNamespace(ns) }; @@ -146,14 +154,6 @@ function prepareFindCommand(server: Server, ns: string, cmd: Document) { if (cmd.collation) findCmd.collation = cmd.collation; if (cmd.readConcern) findCmd.readConcern = cmd.readConcern; - // If we have explain, we need to rewrite the find command - // to wrap it in the explain command - if (cmd.explain) { - findCmd = { - explain: findCmd - }; - } - return findCmd; } @@ -195,7 +195,7 @@ function prepareLegacyFindQuery( if (typeof cmd.showDiskLoc !== 'undefined') findCmd['$showDiskLoc'] = cmd.showDiskLoc; if (cmd.comment) findCmd['$comment'] = cmd.comment; if (cmd.maxTimeMS) findCmd['$maxTimeMS'] = cmd.maxTimeMS; - if (cmd.explain) { + if (options.explain !== undefined) { // nToReturn must be 0 (match all) or negative (match N and close cursor) // nToReturn > 0 will give explain results equivalent to limit(0) numberToReturn = -Math.abs(cmd.limit || 0); diff --git a/src/cursor/cursor.ts b/src/cursor/cursor.ts index 077ee5befa..b79ecae769 100644 --- a/src/cursor/cursor.ts +++ b/src/cursor/cursor.ts @@ -13,12 +13,13 @@ import { PromiseProvider } from '../promise_provider'; import type { OperationTime, ResumeToken } from '../change_stream'; import type { CloseOptions } from '../cmap/connection_pool'; import type { CollationOptions } from '../cmap/wire_protocol/write_command'; -import type { Hint, OperationBase } from '../operations/operation'; +import { Aspect, Hint, OperationBase } from '../operations/operation'; import type { Topology } from '../sdam/topology'; -import type { CommandOperationOptions } from '../operations/command'; +import { CommandOperation, CommandOperationOptions } from '../operations/command'; import type { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; +import { Explain, ExplainVerbosityLike } from '../explain'; const kCursor = Symbol('cursor'); @@ -1300,26 +1301,22 @@ export class Cursor< /** * Execute the explain for the cursor * + * @param verbosity - The mode in which to run the explain. * @param callback - The result callback. */ - explain(): Promise; - explain(callback: Callback): void; - explain(callback?: Callback): Promise | void { - // NOTE: the next line includes a special case for operations which do not - // subclass `CommandOperationV2`. To be removed asap. - // TODO NODE-2853: This had to be removed during NODE-2852; fix while re-implementing - // cursor explain - // if (this.operation && this.operation.cmd == null) { - // this.operation.options.explain = true; - // return executeOperation(this.topology, this.operation as any, callback); - // } - - this.cmd.explain = true; - - // Do we have a readConcern - if (this.cmd.readConcern) { - delete this.cmd['readConcern']; - } + explain(verbosity?: ExplainVerbosityLike): Promise; + explain(verbosity?: ExplainVerbosityLike, callback?: Callback): Promise | void { + if (typeof verbosity === 'function') (callback = verbosity), (verbosity = true); + if (verbosity === undefined) verbosity = true; + + // TODO: For now, we need to manually do these checks. This will change after cursor refactor. + if ( + !(this.operation instanceof CommandOperation) || + !this.operation.hasAspect(Aspect.EXPLAINABLE) + ) { + throw new MongoError('This command cannot be explained'); + } + this.operation.explain = new Explain(verbosity); return maybePromise(callback, cb => nextFunction(this, cb)); } diff --git a/src/explain.ts b/src/explain.ts index 790efbd600..ca4f1cb905 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -9,8 +9,9 @@ export const ExplainVerbosity = { } as const; /** - * For backwards compatibility, true is interpreted as - * "allPlansExecution" and false as "queryPlanner". + * For backwards compatibility, true is interpreted as "allPlansExecution" + * and false as "queryPlanner". Prior to server version 3.6, aggregate() + * ignores the verbosity parameter and executes in "queryPlanner". * @public */ export type ExplainVerbosityLike = keyof typeof ExplainVerbosity | boolean; diff --git a/src/index.ts b/src/index.ts index 8fbedd3f49..2f8a782173 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,7 +163,7 @@ export type { export type { DbPrivate, DbOptions } from './db'; export type { AutoEncryptionOptions, AutoEncryptionLoggerLevels, AutoEncrypter } from './deps'; export type { AnyError, ErrorDescription } from './error'; -export type { ExplainOptions, ExplainVerbosity, ExplainVerbosityLike } from './explain'; +export type { Explain, ExplainOptions, ExplainVerbosity, ExplainVerbosityLike } from './explain'; export type { GridFSBucketReadStream, GridFSBucketReadStreamOptions, diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index e8ed3ef3bc..a8fee700ba 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -65,10 +65,8 @@ export class AggregateOperation extends CommandOperation extends CommandOperation extends CommandOperation { findCommand.allowDiskUse = options.allowDiskUse; } + if (this.explain) { + // TODO: For now, we need to manually ensure explain is in the options. This will change after cursor refactor. + this.options.explain = this.explain.verbosity; + } + // TODO: use `MongoDBNamespace` through and through server.query( this.ns.toString(), @@ -222,4 +227,4 @@ export class FindOperation extends CommandOperation { } } -defineAspects(FindOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE]); +defineAspects(FindOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE, Aspect.EXPLAINABLE]); diff --git a/src/operations/find_one.ts b/src/operations/find_one.ts index 393e031431..047d08458f 100644 --- a/src/operations/find_one.ts +++ b/src/operations/find_one.ts @@ -5,6 +5,7 @@ import type { FindOptions } from './find'; import { MongoError } from '../error'; import type { Server } from '../sdam/server'; import { CommandOperation } from './command'; +import { Aspect, defineAspects } from './operation'; /** @internal */ export class FindOneOperation extends CommandOperation { @@ -36,3 +37,5 @@ export class FindOneOperation extends CommandOperation { } } } + +defineAspects(FindOneOperation, [Aspect.EXPLAINABLE]); diff --git a/test/functional/aggregation.test.js b/test/functional/aggregation.test.js index a89d09dd5d..7607bed519 100644 --- a/test/functional/aggregation.test.js +++ b/test/functional/aggregation.test.js @@ -386,12 +386,7 @@ describe('Aggregation', function () { * @example-class Collection * @example-method aggregate */ - it.skip('should correctly return a cursor and call explain', { - // TODO NODE-2853: This had to be skipped during NODE-2852; un-skip while re-implementing - // cursor explain - - // Add a tag that our runner can trigger on - // in this case we are setting that node needs to be higher than 0.10.X to run + it('should correctly return a cursor and call explain', { metadata: { requires: { mongodb: '>2.5.3', @@ -461,7 +456,7 @@ describe('Aggregation', function () { cursor.explain(function (err, result) { expect(err).to.not.exist; expect(result.stages).to.have.lengthOf.at.least(1); - expect(result.stages[0]).to.have.key('$cursor'); + expect(result.stages[0]).to.have.property('$cursor'); client.close(done); }); @@ -928,7 +923,7 @@ describe('Aggregation', function () { } }); - it('should fail if you try to use explain flag with readConcern/writeConcern', { + it('should fail if you try to use explain flag with writeConcern', { metadata: { requires: { mongodb: '>3.6.0', @@ -938,12 +933,9 @@ describe('Aggregation', function () { test: function (done) { var databaseName = this.configuration.db; - var client = this.configuration.newClient(this.configuration.writeConcernMax(), { - poolSize: 1 - }); + var client = this.configuration.newClient({ poolSize: 1 }); const testCases = [ - { readConcern: { level: 'local' } }, { writeConcern: { j: true } }, { readConcern: { level: 'local' }, writeConcern: { j: true } } ]; diff --git a/test/functional/explain.test.js b/test/functional/explain.test.js index 152cbd15ee..8a900f0d66 100644 --- a/test/functional/explain.test.js +++ b/test/functional/explain.test.js @@ -458,4 +458,307 @@ describe('Explain', function () { }); }) }); + + it('should honor boolean explain with find', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorBooleanExplainWithFind'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.find({ a: 1 }, { explain: true }).toArray((err, docs) => { + expect(err).to.not.exist; + const explanation = docs[0]; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + }); + }); + }) + }); + + it('should honor string explain with find', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorStringExplainWithFind'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.find({ a: 1 }, { explain: 'executionStats' }).toArray((err, docs) => { + expect(err).to.not.exist; + const explanation = docs[0]; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).property('executionStats').to.exist; + done(); + }); + }); + }) + }); + + it('should honor boolean explain with findOne', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorBooleanExplainWithFindOne'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.findOne({ a: 1 }, { explain: true }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + }); + }); + }) + }); + + it('should honor string explain with findOne', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorStringExplainWithFindOne'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.findOne({ a: 1 }, { explain: 'executionStats' }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).property('executionStats').to.exist; + done(); + }); + }); + }) + }); + + it('should honor boolean explain specified on cursor with find', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorBooleanExplainSpecifiedOnCursor'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.find({ a: 1 }).explain(false, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + }); + }); + }) + }); + + it('should honor string explain specified on cursor with find', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorStringExplainSpecifiedOnCursor'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.find({ a: 1 }).explain('allPlansExecution', (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).property('executionStats').to.exist; + done(); + }); + }); + }) + }); + + it('should honor legacy explain with find', { + metadata: { + requires: { + mongodb: '<3.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorLegacyExplainWithFind'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.find({ a: 1 }).explain((err, result) => { + expect(err).to.not.exist; + expect(result).to.have.property('allPlans'); + done(); + }); + }); + }) + }); + + it( + 'should honor boolean explain with aggregate', + withClient(function (client, done) { + const db = client.db('shouldHonorBooleanExplainWithAggregate'); + const collection = db.collection('test'); + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection + .aggregate([{ $project: { a: 1 } }, { $group: { _id: '$a' } }], { explain: true }) + .toArray((err, docs) => { + expect(err).to.not.exist; + const result = docs[0]; + expect(result).to.have.property('stages'); + expect(result.stages).to.have.lengthOf.at.least(1); + expect(result.stages[0]).to.have.property('$cursor'); + done(); + }); + }); + }) + ); + + it('should honor string explain with aggregate', { + metadata: { + requires: { + mongodb: '>=3.6.0' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorStringExplainWithAggregate'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection + .aggregate([{ $project: { a: 1 } }, { $group: { _id: '$a' } }], { + explain: 'executionStats' + }) + .toArray((err, docs) => { + expect(err).to.not.exist; + const result = docs[0]; + expect(result).to.have.property('stages'); + expect(result.stages).to.have.lengthOf.at.least(1); + expect(result.stages[0]).to.have.property('$cursor'); + expect(result.stages[0].$cursor).to.have.property('queryPlanner'); + expect(result.stages[0].$cursor).to.have.property('executionStats'); + done(); + }); + }); + }) + }); + + it( + 'should honor boolean explain specified on cursor with aggregate', + withClient(function (client, done) { + const db = client.db('shouldHonorBooleanExplainSpecifiedOnCursor'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection + .aggregate([{ $project: { a: 1 } }, { $group: { _id: '$a' } }]) + .explain(false, (err, result) => { + expect(err).to.not.exist; + expect(result).to.have.property('stages'); + expect(result.stages).to.have.lengthOf.at.least(1); + expect(result.stages[0]).to.have.property('$cursor'); + done(); + }); + }); + }) + ); + + it('should honor string explain specified on cursor with aggregate', { + metadata: { + requires: { + mongodb: '>=3.6' + } + }, + test: withClient(function (client, done) { + const db = client.db('shouldHonorStringExplainSpecifiedOnCursor'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection + .aggregate([{ $project: { a: 1 } }, { $group: { _id: '$a' } }]) + .explain('allPlansExecution', (err, result) => { + expect(err).to.not.exist; + expect(result).to.exist; + expect(result).to.have.property('stages'); + expect(result.stages).to.have.lengthOf.at.least(1); + expect(result.stages[0]).to.have.property('$cursor'); + expect(result.stages[0].$cursor).to.have.property('queryPlanner'); + expect(result.stages[0].$cursor).to.have.property('executionStats'); + done(); + }); + }); + }) + }); + + it( + 'should honor legacy explain with aggregate', + withClient(function (client, done) { + const db = client.db('shouldHonorLegacyExplainWithAggregate'); + const collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection + .aggregate([{ $project: { a: 1 } }, { $group: { _id: '$a' } }]) + .explain((err, result) => { + expect(err).to.not.exist; + expect(result).to.have.property('stages'); + expect(result.stages).to.have.lengthOf.at.least(1); + expect(result.stages[0]).to.have.property('$cursor'); + done(); + }); + }); + }) + ); });