Skip to content

Commit

Permalink
feat: add explain support for non-cursor commands (#2599)
Browse files Browse the repository at this point in the history
Explain support for specific commands is accessible via a
new `explain` option, either a boolean or a string specifying
the requested verbosity, at the operation level. A new 
`Explainable` aspect was added to limit support to the 
relevant commands.

NODE-2852
  • Loading branch information
HanaPearlman committed Nov 16, 2020
1 parent bd592ec commit 4472308
Show file tree
Hide file tree
Showing 17 changed files with 635 additions and 29 deletions.
14 changes: 11 additions & 3 deletions src/cmap/wire_protocol/write_command.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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',
Expand Down
10 changes: 6 additions & 4 deletions src/cursor/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,10 +1307,12 @@ export class Cursor<
explain(callback?: Callback): Promise<unknown> | 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;

Expand Down
48 changes: 48 additions & 0 deletions src/explain.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/operations/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
28 changes: 24 additions & 4 deletions src/operations/command.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) */
Expand Down Expand Up @@ -51,7 +55,7 @@ export abstract class CommandOperation<
ns: MongoDBNamespace;
readConcern?: ReadConcern;
writeConcern?: WriteConcern;
explain: boolean;
explain?: Explain;
fullResponse?: boolean;
logger?: Logger;

Expand All @@ -73,14 +77,26 @@ 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;

// TODO(NODE-2056): make logger another "inheritable" property
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<TResult>): void;
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion src/operations/common_functions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions src/operations/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,5 @@ export class DeleteManyOperation extends CommandOperation<DeleteOptions, DeleteR
}

defineAspects(DeleteOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION]);
defineAspects(DeleteOneOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION]);
defineAspects(DeleteManyOperation, [Aspect.WRITE_OPERATION]);
defineAspects(DeleteOneOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION, Aspect.EXPLAINABLE]);
defineAspects(DeleteManyOperation, [Aspect.WRITE_OPERATION, Aspect.EXPLAINABLE]);
12 changes: 9 additions & 3 deletions src/operations/distinct.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Aspect, defineAspects } from './operation';
import { CommandOperation, CommandOperationOptions } from './command';
import { decorateWithCollation, decorateWithReadConcern, Callback } from '../utils';
import { decorateWithCollation, decorateWithReadConcern, Callback, maxWireVersion } from '../utils';
import type { Document } from '../bson';
import type { Server } from '../sdam/server';
import type { Collection } from '../collection';
import { MongoError } from '../error';

/** @public */
export type DistinctOptions = CommandOperationOptions;
Expand Down Expand Up @@ -63,15 +64,20 @@ export class DistinctOperation extends CommandOperation<DistinctOptions, Documen
return callback(err);
}

if (this.explain && maxWireVersion(server) < 4) {
callback(new MongoError(`server ${server.name} does not support explain on distinct`));
return;
}

super.executeCommand(server, cmd, (err, result) => {
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]);
2 changes: 0 additions & 2 deletions src/operations/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
11 changes: 10 additions & 1 deletion src/operations/find_and_modify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export class FindAndModifyOperation extends CommandOperation<FindAndModifyOption
cmd.hint = options.hint;
}

if (this.explain && maxWireVersion(server) < 4) {
callback(new MongoError(`server ${server.name} does not support explain on findAndModify`));
return;
}

// Execute the command
super.executeCommand(server, cmd, (err, result) => {
if (err) return callback(err);
Expand Down Expand Up @@ -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
]);
15 changes: 14 additions & 1 deletion src/operations/map_reduce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
decorateWithCollation,
decorateWithReadConcern,
isObject,
Callback
Callback,
maxWireVersion
} from '../utils';
import { ReadPreference, ReadPreferenceMode } from '../read_preference';
import { CommandOperation, CommandOperationOptions } from './command';
Expand All @@ -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',
Expand Down Expand Up @@ -156,6 +159,11 @@ export class MapReduceOperation extends CommandOperation<MapReduceOptions, Docum
return callback(err);
}

if (this.explain && maxWireVersion(server) < 9) {
callback(new MongoError(`server ${server.name} does not support explain on mapReduce`));
return;
}

// Execute command
super.executeCommand(server, mapCommandHash, (err, result) => {
if (err) return callback(err);
Expand All @@ -164,6 +172,9 @@ export class MapReduceOperation extends CommandOperation<MapReduceOptions, Docum
return callback(new MongoError(result));
}

// If an explain option was executed, don't process the server results
if (this.explain) return callback(undefined, result);

// Create statistics value
const stats: MapReduceStats = {};
if (result.timeMillis) stats['processtime'] = result.timeMillis;
Expand Down Expand Up @@ -227,3 +238,5 @@ function processScope(scope: Document | ObjectId) {

return newScope;
}

defineAspects(MapReduceOperation, [Aspect.EXPLAINABLE]);
5 changes: 2 additions & 3 deletions src/operations/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type { Server } from '../sdam/server';
export const Aspect = {
READ_OPERATION: Symbol('READ_OPERATION'),
WRITE_OPERATION: Symbol('WRITE_OPERATION'),
RETRYABLE: Symbol('RETRYABLE')
RETRYABLE: Symbol('RETRYABLE'),
EXPLAINABLE: Symbol('EXPLAINABLE')
} as const;

/** @public */
Expand All @@ -21,8 +22,6 @@ export interface OperationConstructor extends Function {
export interface OperationOptions extends BSONSerializeOptions {
/** Specify ClientSession for this command */
session?: ClientSession;

explain?: boolean;
willRetryWrites?: boolean;

/** The preferred read preference (ReadPreference.primary, ReadPreference.primary_preferred, ReadPreference.secondary, ReadPreference.secondary_preferred, ReadPreference.nearest). */
Expand Down

0 comments on commit 4472308

Please sign in to comment.