diff --git a/src/cmap/wire_protocol/write_command.ts b/src/cmap/wire_protocol/write_command.ts index 18fbcecf3d..7ec75b5d50 100644 --- a/src/cmap/wire_protocol/write_command.ts +++ b/src/cmap/wire_protocol/write_command.ts @@ -1,9 +1,10 @@ import { MongoError } from '../../error'; -import { collectionNamespace, Callback } from '../../utils'; +import { collectionNamespace, Callback, decorateWithExplain } from '../../utils'; import { command, CommandOptions } from './command'; import type { Server } from '../../sdam/server'; import type { Document, BSONSerializeOptions } from '../../bson'; import type { WriteConcern } from '../../write_concern'; +import { Explain, ExplainOptions } from '../../explain'; /** @public */ export interface CollationOptions { @@ -18,7 +19,7 @@ export interface CollationOptions { } /** @internal */ -export interface WriteCommandOptions extends BSONSerializeOptions, CommandOptions { +export interface WriteCommandOptions extends BSONSerializeOptions, CommandOptions, ExplainOptions { ordered?: boolean; writeConcern?: WriteConcern; collation?: CollationOptions; @@ -43,7 +44,7 @@ export function writeCommand( options = options || {}; const ordered = typeof options.ordered === 'boolean' ? options.ordered : true; const writeConcern = options.writeConcern; - const writeCommand: Document = {}; + let writeCommand: Document = {}; writeCommand[type] = collectionNamespace(ns); writeCommand[opsField] = ops; writeCommand.ordered = ordered; @@ -64,6 +65,13 @@ export function writeCommand( writeCommand.bypassDocumentValidation = options.bypassDocumentValidation; } + // If a command is to be explained, we need to reformat the command after + // the other command properties are specified. + const explain = Explain.fromOptions(options); + if (explain) { + writeCommand = decorateWithExplain(writeCommand, explain); + } + const commandOptions = Object.assign( { checkKeys: type === 'insert', diff --git a/src/cursor/cursor.ts b/src/cursor/cursor.ts index 2529b43a04..077ee5befa 100644 --- a/src/cursor/cursor.ts +++ b/src/cursor/cursor.ts @@ -1307,10 +1307,12 @@ export class Cursor< explain(callback?: Callback): Promise | void { // NOTE: the next line includes a special case for operations which do not // subclass `CommandOperationV2`. To be removed asap. - if (this.operation && this.operation.cmd == null) { - this.operation.options.explain = true; - return executeOperation(this.topology, this.operation as any, callback); - } + // 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; diff --git a/src/explain.ts b/src/explain.ts new file mode 100644 index 0000000000..790efbd600 --- /dev/null +++ b/src/explain.ts @@ -0,0 +1,48 @@ +import { MongoError } from './error'; + +/** @public */ +export const ExplainVerbosity = { + queryPlanner: 'queryPlanner', + queryPlannerExtended: 'queryPlannerExtended', + executionStats: 'executionStats', + allPlansExecution: 'allPlansExecution' +} as const; + +/** + * For backwards compatibility, true is interpreted as + * "allPlansExecution" and false as "queryPlanner". + * @public + */ +export type ExplainVerbosityLike = keyof typeof ExplainVerbosity | boolean; + +/** @public */ +export interface ExplainOptions { + /** Specifies the verbosity mode for the explain output. */ + explain?: ExplainVerbosityLike; +} + +/** @internal */ +export class Explain { + verbosity: keyof typeof ExplainVerbosity; + + constructor(verbosity: ExplainVerbosityLike) { + if (typeof verbosity === 'boolean') { + this.verbosity = verbosity + ? ExplainVerbosity.allPlansExecution + : ExplainVerbosity.queryPlanner; + } else { + this.verbosity = ExplainVerbosity[verbosity]; + } + } + + static fromOptions(options?: ExplainOptions): Explain | undefined { + if (options?.explain === undefined) return; + + const explain = options.explain; + if (typeof explain === 'boolean' || explain in ExplainVerbosity) { + return new Explain(explain); + } + + throw new MongoError(`explain must be one of ${Object.keys(ExplainVerbosity)} or a boolean`); + } +} diff --git a/src/index.ts b/src/index.ts index 4316800f53..8fbedd3f49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,6 +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 { GridFSBucketReadStream, GridFSBucketReadStreamOptions, diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index fb779de121..e8ed3ef3bc 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -22,8 +22,6 @@ export interface AggregateOptions extends CommandOperationOptions { bypassDocumentValidation?: boolean; /** Return the query as cursor, on 2.6 \> it returns as a real cursor on pre 2.6 it returns as an emulated cursor. */ cursor?: Document; - /** Explain returns the aggregation execution plan (requires mongodb 2.6 \>) */ - explain?: boolean; /** specifies a cumulative time limit in milliseconds for processing operations on the cursor. MongoDB interrupts the operation at the earliest following interrupt point. */ maxTimeMS?: number; /** The maximum amount of time for the server to wait on new documents to satisfy a tailable cursor query. */ diff --git a/src/operations/command.ts b/src/operations/command.ts index f1527980f7..e6573f2ee0 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -1,7 +1,7 @@ import { Aspect, OperationBase, OperationOptions } from './operation'; import { ReadConcern } from '../read_concern'; import { WriteConcern, WriteConcernOptions } from '../write_concern'; -import { maxWireVersion, MongoDBNamespace, Callback } from '../utils'; +import { maxWireVersion, MongoDBNamespace, Callback, decorateWithExplain } from '../utils'; import type { ReadPreference } from '../read_preference'; import { commandSupportsReadConcern } from '../sessions'; import { MongoError } from '../error'; @@ -10,11 +10,15 @@ import type { Server } from '../sdam/server'; import type { BSONSerializeOptions, Document } from '../bson'; import type { CollationOptions } from '../cmap/wire_protocol/write_command'; import type { ReadConcernLike } from './../read_concern'; +import { Explain, ExplainOptions } from '../explain'; const SUPPORTS_WRITE_CONCERN_AND_COLLATION = 5; /** @public */ -export interface CommandOperationOptions extends OperationOptions, WriteConcernOptions { +export interface CommandOperationOptions + extends OperationOptions, + WriteConcernOptions, + ExplainOptions { /** Return the full server response for the command */ fullResponse?: boolean; /** Specify a read concern and level for the collection. (only MongoDB 3.2 or higher supported) */ @@ -51,7 +55,7 @@ export abstract class CommandOperation< ns: MongoDBNamespace; readConcern?: ReadConcern; writeConcern?: WriteConcern; - explain: boolean; + explain?: Explain; fullResponse?: boolean; logger?: Logger; @@ -73,7 +77,6 @@ export abstract class CommandOperation< this.readConcern = ReadConcern.fromOptions(options); this.writeConcern = WriteConcern.fromOptions(options); - this.explain = false; this.fullResponse = options && typeof options.fullResponse === 'boolean' ? options.fullResponse : false; @@ -81,6 +84,19 @@ export abstract class CommandOperation< if (parent && parent.logger) { this.logger = parent.logger; } + + if (this.hasAspect(Aspect.EXPLAINABLE)) { + this.explain = Explain.fromOptions(options); + } else if (options?.explain !== undefined) { + throw new MongoError(`explain is not supported on this command`); + } + } + + get canRetryWrite(): boolean { + if (this.hasAspect(Aspect.EXPLAINABLE)) { + return this.explain === undefined; + } + return true; } abstract execute(server: Server, callback: Callback): void; @@ -128,6 +144,10 @@ export abstract class CommandOperation< this.logger.debug(`executing command ${JSON.stringify(cmd)} against ${this.ns}`); } + if (this.hasAspect(Aspect.EXPLAINABLE) && this.explain) { + cmd = decorateWithExplain(cmd, this.explain); + } + server.command( this.ns.toString(), cmd, diff --git a/src/operations/common_functions.ts b/src/operations/common_functions.ts index 3b24a996a0..65dd379dee 100644 --- a/src/operations/common_functions.ts +++ b/src/operations/common_functions.ts @@ -1,5 +1,11 @@ import { MongoError } from '../error'; -import { applyRetryableWrites, decorateWithCollation, Callback, getTopology } from '../utils'; +import { + applyRetryableWrites, + decorateWithCollation, + Callback, + getTopology, + maxWireVersion +} from '../utils'; import type { Document } from '../bson'; import type { Db } from '../db'; import type { ClientSession } from '../sessions'; @@ -155,6 +161,12 @@ export function removeDocuments( return callback ? callback(err, null) : undefined; } + if (options.explain !== undefined && maxWireVersion(server) < 3) { + return callback + ? callback(new MongoError(`server ${server.name} does not support explain on remove`)) + : undefined; + } + // Execute the remove server.remove( coll.s.namespace.toString(), @@ -240,6 +252,12 @@ export function updateDocuments( return callback(err, null); } + if (options.explain !== undefined && maxWireVersion(server) < 3) { + return callback + ? callback(new MongoError(`server ${server.name} does not support explain on update`)) + : undefined; + } + // Update options server.update( coll.s.namespace.toString(), diff --git a/src/operations/delete.ts b/src/operations/delete.ts index 5ed6b750b1..27e71cfc48 100644 --- a/src/operations/delete.ts +++ b/src/operations/delete.ts @@ -120,5 +120,5 @@ export class DeleteManyOperation extends CommandOperation { if (err) { callback(err); return; } - callback(undefined, this.options.fullResponse ? result : result.values); + callback(undefined, this.options.fullResponse || this.explain ? result : result.values); }); } } -defineAspects(DistinctOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE]); +defineAspects(DistinctOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE, Aspect.EXPLAINABLE]); diff --git a/src/operations/find.ts b/src/operations/find.ts index 4e3ef22f17..600bd4c90c 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -21,8 +21,6 @@ export interface FindOptions extends QueryOptions, CommandOperationOptions { skip?: number; /** Tell the query to use specific indexes in the query. Object of indexes to use, `{'_id':1}` */ hint?: Hint; - /** Explain the query instead of returning the data. */ - explain?: boolean; /** Specify if the cursor can timeout. */ timeout?: boolean; /** Specify if the cursor is tailable. */ diff --git a/src/operations/find_and_modify.ts b/src/operations/find_and_modify.ts index 05794c64d6..37356f476c 100644 --- a/src/operations/find_and_modify.ts +++ b/src/operations/find_and_modify.ts @@ -141,6 +141,11 @@ export class FindAndModifyOperation extends CommandOperation { if (err) return callback(err); @@ -229,4 +234,8 @@ export class FindOneAndUpdateOperation extends FindAndModifyOperation { } } -defineAspects(FindAndModifyOperation, [Aspect.WRITE_OPERATION, Aspect.RETRYABLE]); +defineAspects(FindAndModifyOperation, [ + Aspect.WRITE_OPERATION, + Aspect.RETRYABLE, + Aspect.EXPLAINABLE +]); diff --git a/src/operations/map_reduce.ts b/src/operations/map_reduce.ts index 2534958d4a..c6d36cfc8f 100644 --- a/src/operations/map_reduce.ts +++ b/src/operations/map_reduce.ts @@ -5,7 +5,8 @@ import { decorateWithCollation, decorateWithReadConcern, isObject, - Callback + Callback, + maxWireVersion } from '../utils'; import { ReadPreference, ReadPreferenceMode } from '../read_preference'; import { CommandOperation, CommandOperationOptions } from './command'; @@ -14,8 +15,10 @@ import type { Collection } from '../collection'; import type { Sort } from '../sort'; import { MongoError } from '../error'; import type { ObjectId } from '../bson'; +import { Aspect, defineAspects } from './operation'; const exclusionList = [ + 'explain', 'readPreference', 'readConcern', 'session', @@ -156,6 +159,11 @@ export class MapReduceOperation extends CommandOperation { if (err) return callback(err); @@ -164,6 +172,9 @@ export class MapReduceOperation extends CommandOperation { if (err || !r) return callback(err); + // If an explain option was executed, don't process the server results + if (this.explain) return callback(undefined, r); + const result: UpdateResult = { modifiedCount: r.nModified != null ? r.nModified : r.n, upsertedId: @@ -136,6 +139,9 @@ export class UpdateManyOperation extends CommandOperation { if (err || !r) return callback(err); + // If an explain option was executed, don't process the server results + if (this.explain) return callback(undefined, r); + const result: UpdateResult = { modifiedCount: r.nModified != null ? r.nModified : r.n, upsertedId: @@ -153,5 +159,5 @@ export class UpdateManyOperation extends CommandOperation=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithDeleteOne'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.deleteOne({ 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 boolean explain with delete many', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithDeleteMany'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.deleteMany({ 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 boolean explain with update one', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithUpdateOne'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.updateOne( + { a: 1 }, + { $inc: { a: 2 } }, + { explain: true }, + (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + } + ); + }); + }) + }); + + it('should honor boolean explain with update many', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithUpdateMany'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.updateMany( + { a: 1 }, + { $inc: { a: 2 } }, + { explain: true }, + (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).nested.property('queryPlanner').to.exist; + done(); + } + ); + }); + }) + }); + + it('should honor boolean explain with remove one', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithRemoveOne'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.removeOne({ 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 boolean explain with remove many', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithRemoveMany'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.removeMany({ 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 boolean explain with distinct', { + metadata: { + requires: { + mongodb: '>=3.2' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithDistinct'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.distinct('a', {}, { explain: true }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + }); + }); + }) + }); + + it('should honor boolean explain with findOneAndModify', { + metadata: { + requires: { + mongodb: '>=3.2' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithFindOneAndModify'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.findOneAndDelete({ 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 boolean explain with mapReduce', { + metadata: { + requires: { + mongodb: '>=4.4' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorBooleanExplainWithMapReduce'); + var collection = db.collection('test'); + + collection.insertMany([{ user_id: 1 }, { user_id: 2 }], (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + var map = 'function() { emit(this.user_id, 1); }'; + var reduce = 'function(k,vals) { return 1; }'; + + collection.mapReduce( + map, + reduce, + { out: { replace: 'tempCollection' }, explain: true }, + (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('stages').to.exist; + done(); + } + ); + }); + }) + }); + + it('should use allPlansExecution as true explain verbosity', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldUseAllPlansExecutionAsTrueExplainVerbosity'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + // Verify explanation result contains properties of allPlansExecution output + collection.deleteOne({ a: 1 }, { explain: true }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).nested.property('executionStats.allPlansExecution').to.exist; + done(); + }); + }); + }) + }); + + it('should use queryPlanner as false explain verbosity', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldUseQueryPlannerAsFalseExplainVerbosity'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + // Verify explanation result contains properties of queryPlanner output + collection.deleteOne({ a: 1 }, { explain: false }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).to.not.have.property('executionStats'); + done(); + }); + }); + }) + }); + + it('should honor queryPlanner string explain', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorQueryPlannerStringExplain'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + // Verify explanation result contains properties of queryPlanner output + collection.deleteOne({ a: 1 }, { explain: 'queryPlanner' }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).to.not.have.property('executionStats'); + done(); + }); + }); + }) + }); + + it('should honor executionStats string explain', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorExecutionStatsStringExplain'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + // Verify explanation result contains properties of executionStats output + collection.deleteMany({ 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; + expect(explanation.executionStats).to.not.have.property('allPlansExecution'); + done(); + }); + }); + }) + }); + + it('should honor allPlansExecution string explain', { + metadata: { + requires: { + mongodb: '>=3.0' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorAllPlansStringExplain'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + // Verify explanation result contains properties of allPlansExecution output + collection.removeOne({ a: 1 }, { explain: 'allPlansExecution' }, (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + expect(explanation).nested.property('executionStats.allPlansExecution').to.exist; + done(); + }); + }); + }) + }); + + it('should honor string explain with distinct', { + metadata: { + requires: { + mongodb: '>=3.2' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorStringExplainWithDistinct'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.distinct('a', {}, { 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 string explain with findOneAndModify', { + metadata: { + requires: { + mongodb: '>=3.2' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorStringExplainWithFindOneAndModify'); + var collection = db.collection('test'); + + collection.insertOne({ a: 1 }, (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + collection.findOneAndReplace( + { a: 1 }, + { a: 2 }, + { explain: 'queryPlanner' }, + (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('queryPlanner').to.exist; + done(); + } + ); + }); + }) + }); + + it('should honor string explain with mapReduce', { + metadata: { + requires: { + mongodb: '>=4.4' + } + }, + test: withClient(function (client, done) { + var db = client.db('shouldHonorStringExplainWithMapReduce'); + var collection = db.collection('test'); + + collection.insertMany([{ user_id: 1 }, { user_id: 2 }], (err, res) => { + expect(err).to.not.exist; + expect(res).to.exist; + + var map = 'function() { emit(this.user_id, 1); }'; + var reduce = 'function(k,vals) { return 1; }'; + + collection.mapReduce( + map, + reduce, + { out: { replace: 'tempCollection' }, explain: 'executionStats' }, + (err, explanation) => { + expect(err).to.not.exist; + expect(explanation).to.exist; + expect(explanation).property('stages').to.exist; + done(); + } + ); + }); + }) + }); +});