Skip to content

Commit 4472308

Browse files
authored
feat: add explain support for non-cursor commands (#2599)
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
1 parent bd592ec commit 4472308

File tree

17 files changed

+635
-29
lines changed

17 files changed

+635
-29
lines changed

src/cmap/wire_protocol/write_command.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { MongoError } from '../../error';
2-
import { collectionNamespace, Callback } from '../../utils';
2+
import { collectionNamespace, Callback, decorateWithExplain } from '../../utils';
33
import { command, CommandOptions } from './command';
44
import type { Server } from '../../sdam/server';
55
import type { Document, BSONSerializeOptions } from '../../bson';
66
import type { WriteConcern } from '../../write_concern';
7+
import { Explain, ExplainOptions } from '../../explain';
78

89
/** @public */
910
export interface CollationOptions {
@@ -18,7 +19,7 @@ export interface CollationOptions {
1819
}
1920

2021
/** @internal */
21-
export interface WriteCommandOptions extends BSONSerializeOptions, CommandOptions {
22+
export interface WriteCommandOptions extends BSONSerializeOptions, CommandOptions, ExplainOptions {
2223
ordered?: boolean;
2324
writeConcern?: WriteConcern;
2425
collation?: CollationOptions;
@@ -43,7 +44,7 @@ export function writeCommand(
4344
options = options || {};
4445
const ordered = typeof options.ordered === 'boolean' ? options.ordered : true;
4546
const writeConcern = options.writeConcern;
46-
const writeCommand: Document = {};
47+
let writeCommand: Document = {};
4748
writeCommand[type] = collectionNamespace(ns);
4849
writeCommand[opsField] = ops;
4950
writeCommand.ordered = ordered;
@@ -64,6 +65,13 @@ export function writeCommand(
6465
writeCommand.bypassDocumentValidation = options.bypassDocumentValidation;
6566
}
6667

68+
// If a command is to be explained, we need to reformat the command after
69+
// the other command properties are specified.
70+
const explain = Explain.fromOptions(options);
71+
if (explain) {
72+
writeCommand = decorateWithExplain(writeCommand, explain);
73+
}
74+
6775
const commandOptions = Object.assign(
6876
{
6977
checkKeys: type === 'insert',

src/cursor/cursor.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,10 +1307,12 @@ export class Cursor<
13071307
explain(callback?: Callback): Promise<unknown> | void {
13081308
// NOTE: the next line includes a special case for operations which do not
13091309
// subclass `CommandOperationV2`. To be removed asap.
1310-
if (this.operation && this.operation.cmd == null) {
1311-
this.operation.options.explain = true;
1312-
return executeOperation(this.topology, this.operation as any, callback);
1313-
}
1310+
// TODO NODE-2853: This had to be removed during NODE-2852; fix while re-implementing
1311+
// cursor explain
1312+
// if (this.operation && this.operation.cmd == null) {
1313+
// this.operation.options.explain = true;
1314+
// return executeOperation(this.topology, this.operation as any, callback);
1315+
// }
13141316

13151317
this.cmd.explain = true;
13161318

src/explain.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { MongoError } from './error';
2+
3+
/** @public */
4+
export const ExplainVerbosity = {
5+
queryPlanner: 'queryPlanner',
6+
queryPlannerExtended: 'queryPlannerExtended',
7+
executionStats: 'executionStats',
8+
allPlansExecution: 'allPlansExecution'
9+
} as const;
10+
11+
/**
12+
* For backwards compatibility, true is interpreted as
13+
* "allPlansExecution" and false as "queryPlanner".
14+
* @public
15+
*/
16+
export type ExplainVerbosityLike = keyof typeof ExplainVerbosity | boolean;
17+
18+
/** @public */
19+
export interface ExplainOptions {
20+
/** Specifies the verbosity mode for the explain output. */
21+
explain?: ExplainVerbosityLike;
22+
}
23+
24+
/** @internal */
25+
export class Explain {
26+
verbosity: keyof typeof ExplainVerbosity;
27+
28+
constructor(verbosity: ExplainVerbosityLike) {
29+
if (typeof verbosity === 'boolean') {
30+
this.verbosity = verbosity
31+
? ExplainVerbosity.allPlansExecution
32+
: ExplainVerbosity.queryPlanner;
33+
} else {
34+
this.verbosity = ExplainVerbosity[verbosity];
35+
}
36+
}
37+
38+
static fromOptions(options?: ExplainOptions): Explain | undefined {
39+
if (options?.explain === undefined) return;
40+
41+
const explain = options.explain;
42+
if (typeof explain === 'boolean' || explain in ExplainVerbosity) {
43+
return new Explain(explain);
44+
}
45+
46+
throw new MongoError(`explain must be one of ${Object.keys(ExplainVerbosity)} or a boolean`);
47+
}
48+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export type {
163163
export type { DbPrivate, DbOptions } from './db';
164164
export type { AutoEncryptionOptions, AutoEncryptionLoggerLevels, AutoEncrypter } from './deps';
165165
export type { AnyError, ErrorDescription } from './error';
166+
export type { ExplainOptions, ExplainVerbosity, ExplainVerbosityLike } from './explain';
166167
export type {
167168
GridFSBucketReadStream,
168169
GridFSBucketReadStreamOptions,

src/operations/aggregate.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ export interface AggregateOptions extends CommandOperationOptions {
2222
bypassDocumentValidation?: boolean;
2323
/** Return the query as cursor, on 2.6 \> it returns as a real cursor on pre 2.6 it returns as an emulated cursor. */
2424
cursor?: Document;
25-
/** Explain returns the aggregation execution plan (requires mongodb 2.6 \>) */
26-
explain?: boolean;
2725
/** specifies a cumulative time limit in milliseconds for processing operations on the cursor. MongoDB interrupts the operation at the earliest following interrupt point. */
2826
maxTimeMS?: number;
2927
/** The maximum amount of time for the server to wait on new documents to satisfy a tailable cursor query. */

src/operations/command.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Aspect, OperationBase, OperationOptions } from './operation';
22
import { ReadConcern } from '../read_concern';
33
import { WriteConcern, WriteConcernOptions } from '../write_concern';
4-
import { maxWireVersion, MongoDBNamespace, Callback } from '../utils';
4+
import { maxWireVersion, MongoDBNamespace, Callback, decorateWithExplain } from '../utils';
55
import type { ReadPreference } from '../read_preference';
66
import { commandSupportsReadConcern } from '../sessions';
77
import { MongoError } from '../error';
@@ -10,11 +10,15 @@ import type { Server } from '../sdam/server';
1010
import type { BSONSerializeOptions, Document } from '../bson';
1111
import type { CollationOptions } from '../cmap/wire_protocol/write_command';
1212
import type { ReadConcernLike } from './../read_concern';
13+
import { Explain, ExplainOptions } from '../explain';
1314

1415
const SUPPORTS_WRITE_CONCERN_AND_COLLATION = 5;
1516

1617
/** @public */
17-
export interface CommandOperationOptions extends OperationOptions, WriteConcernOptions {
18+
export interface CommandOperationOptions
19+
extends OperationOptions,
20+
WriteConcernOptions,
21+
ExplainOptions {
1822
/** Return the full server response for the command */
1923
fullResponse?: boolean;
2024
/** Specify a read concern and level for the collection. (only MongoDB 3.2 or higher supported) */
@@ -51,7 +55,7 @@ export abstract class CommandOperation<
5155
ns: MongoDBNamespace;
5256
readConcern?: ReadConcern;
5357
writeConcern?: WriteConcern;
54-
explain: boolean;
58+
explain?: Explain;
5559
fullResponse?: boolean;
5660
logger?: Logger;
5761

@@ -73,14 +77,26 @@ export abstract class CommandOperation<
7377
this.readConcern = ReadConcern.fromOptions(options);
7478
this.writeConcern = WriteConcern.fromOptions(options);
7579

76-
this.explain = false;
7780
this.fullResponse =
7881
options && typeof options.fullResponse === 'boolean' ? options.fullResponse : false;
7982

8083
// TODO(NODE-2056): make logger another "inheritable" property
8184
if (parent && parent.logger) {
8285
this.logger = parent.logger;
8386
}
87+
88+
if (this.hasAspect(Aspect.EXPLAINABLE)) {
89+
this.explain = Explain.fromOptions(options);
90+
} else if (options?.explain !== undefined) {
91+
throw new MongoError(`explain is not supported on this command`);
92+
}
93+
}
94+
95+
get canRetryWrite(): boolean {
96+
if (this.hasAspect(Aspect.EXPLAINABLE)) {
97+
return this.explain === undefined;
98+
}
99+
return true;
84100
}
85101

86102
abstract execute(server: Server, callback: Callback<TResult>): void;
@@ -128,6 +144,10 @@ export abstract class CommandOperation<
128144
this.logger.debug(`executing command ${JSON.stringify(cmd)} against ${this.ns}`);
129145
}
130146

147+
if (this.hasAspect(Aspect.EXPLAINABLE) && this.explain) {
148+
cmd = decorateWithExplain(cmd, this.explain);
149+
}
150+
131151
server.command(
132152
this.ns.toString(),
133153
cmd,

src/operations/common_functions.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { MongoError } from '../error';
2-
import { applyRetryableWrites, decorateWithCollation, Callback, getTopology } from '../utils';
2+
import {
3+
applyRetryableWrites,
4+
decorateWithCollation,
5+
Callback,
6+
getTopology,
7+
maxWireVersion
8+
} from '../utils';
39
import type { Document } from '../bson';
410
import type { Db } from '../db';
511
import type { ClientSession } from '../sessions';
@@ -155,6 +161,12 @@ export function removeDocuments(
155161
return callback ? callback(err, null) : undefined;
156162
}
157163

164+
if (options.explain !== undefined && maxWireVersion(server) < 3) {
165+
return callback
166+
? callback(new MongoError(`server ${server.name} does not support explain on remove`))
167+
: undefined;
168+
}
169+
158170
// Execute the remove
159171
server.remove(
160172
coll.s.namespace.toString(),
@@ -240,6 +252,12 @@ export function updateDocuments(
240252
return callback(err, null);
241253
}
242254

255+
if (options.explain !== undefined && maxWireVersion(server) < 3) {
256+
return callback
257+
? callback(new MongoError(`server ${server.name} does not support explain on update`))
258+
: undefined;
259+
}
260+
243261
// Update options
244262
server.update(
245263
coll.s.namespace.toString(),

src/operations/delete.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,5 @@ export class DeleteManyOperation extends CommandOperation<DeleteOptions, DeleteR
120120
}
121121

122122
defineAspects(DeleteOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION]);
123-
defineAspects(DeleteOneOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION]);
124-
defineAspects(DeleteManyOperation, [Aspect.WRITE_OPERATION]);
123+
defineAspects(DeleteOneOperation, [Aspect.RETRYABLE, Aspect.WRITE_OPERATION, Aspect.EXPLAINABLE]);
124+
defineAspects(DeleteManyOperation, [Aspect.WRITE_OPERATION, Aspect.EXPLAINABLE]);

src/operations/distinct.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Aspect, defineAspects } from './operation';
22
import { CommandOperation, CommandOperationOptions } from './command';
3-
import { decorateWithCollation, decorateWithReadConcern, Callback } from '../utils';
3+
import { decorateWithCollation, decorateWithReadConcern, Callback, maxWireVersion } from '../utils';
44
import type { Document } from '../bson';
55
import type { Server } from '../sdam/server';
66
import type { Collection } from '../collection';
7+
import { MongoError } from '../error';
78

89
/** @public */
910
export type DistinctOptions = CommandOperationOptions;
@@ -63,15 +64,20 @@ export class DistinctOperation extends CommandOperation<DistinctOptions, Documen
6364
return callback(err);
6465
}
6566

67+
if (this.explain && maxWireVersion(server) < 4) {
68+
callback(new MongoError(`server ${server.name} does not support explain on distinct`));
69+
return;
70+
}
71+
6672
super.executeCommand(server, cmd, (err, result) => {
6773
if (err) {
6874
callback(err);
6975
return;
7076
}
7177

72-
callback(undefined, this.options.fullResponse ? result : result.values);
78+
callback(undefined, this.options.fullResponse || this.explain ? result : result.values);
7379
});
7480
}
7581
}
7682

77-
defineAspects(DistinctOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE]);
83+
defineAspects(DistinctOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE, Aspect.EXPLAINABLE]);

src/operations/find.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ export interface FindOptions extends QueryOptions, CommandOperationOptions {
2121
skip?: number;
2222
/** Tell the query to use specific indexes in the query. Object of indexes to use, `{'_id':1}` */
2323
hint?: Hint;
24-
/** Explain the query instead of returning the data. */
25-
explain?: boolean;
2624
/** Specify if the cursor can timeout. */
2725
timeout?: boolean;
2826
/** Specify if the cursor is tailable. */

src/operations/find_and_modify.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ export class FindAndModifyOperation extends CommandOperation<FindAndModifyOption
141141
cmd.hint = options.hint;
142142
}
143143

144+
if (this.explain && maxWireVersion(server) < 4) {
145+
callback(new MongoError(`server ${server.name} does not support explain on findAndModify`));
146+
return;
147+
}
148+
144149
// Execute the command
145150
super.executeCommand(server, cmd, (err, result) => {
146151
if (err) return callback(err);
@@ -229,4 +234,8 @@ export class FindOneAndUpdateOperation extends FindAndModifyOperation {
229234
}
230235
}
231236

232-
defineAspects(FindAndModifyOperation, [Aspect.WRITE_OPERATION, Aspect.RETRYABLE]);
237+
defineAspects(FindAndModifyOperation, [
238+
Aspect.WRITE_OPERATION,
239+
Aspect.RETRYABLE,
240+
Aspect.EXPLAINABLE
241+
]);

src/operations/map_reduce.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
decorateWithCollation,
66
decorateWithReadConcern,
77
isObject,
8-
Callback
8+
Callback,
9+
maxWireVersion
910
} from '../utils';
1011
import { ReadPreference, ReadPreferenceMode } from '../read_preference';
1112
import { CommandOperation, CommandOperationOptions } from './command';
@@ -14,8 +15,10 @@ import type { Collection } from '../collection';
1415
import type { Sort } from '../sort';
1516
import { MongoError } from '../error';
1617
import type { ObjectId } from '../bson';
18+
import { Aspect, defineAspects } from './operation';
1719

1820
const exclusionList = [
21+
'explain',
1922
'readPreference',
2023
'readConcern',
2124
'session',
@@ -156,6 +159,11 @@ export class MapReduceOperation extends CommandOperation<MapReduceOptions, Docum
156159
return callback(err);
157160
}
158161

162+
if (this.explain && maxWireVersion(server) < 9) {
163+
callback(new MongoError(`server ${server.name} does not support explain on mapReduce`));
164+
return;
165+
}
166+
159167
// Execute command
160168
super.executeCommand(server, mapCommandHash, (err, result) => {
161169
if (err) return callback(err);
@@ -164,6 +172,9 @@ export class MapReduceOperation extends CommandOperation<MapReduceOptions, Docum
164172
return callback(new MongoError(result));
165173
}
166174

175+
// If an explain option was executed, don't process the server results
176+
if (this.explain) return callback(undefined, result);
177+
167178
// Create statistics value
168179
const stats: MapReduceStats = {};
169180
if (result.timeMillis) stats['processtime'] = result.timeMillis;
@@ -227,3 +238,5 @@ function processScope(scope: Document | ObjectId) {
227238

228239
return newScope;
229240
}
241+
242+
defineAspects(MapReduceOperation, [Aspect.EXPLAINABLE]);

src/operations/operation.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import type { Server } from '../sdam/server';
77
export const Aspect = {
88
READ_OPERATION: Symbol('READ_OPERATION'),
99
WRITE_OPERATION: Symbol('WRITE_OPERATION'),
10-
RETRYABLE: Symbol('RETRYABLE')
10+
RETRYABLE: Symbol('RETRYABLE'),
11+
EXPLAINABLE: Symbol('EXPLAINABLE')
1112
} as const;
1213

1314
/** @public */
@@ -21,8 +22,6 @@ export interface OperationConstructor extends Function {
2122
export interface OperationOptions extends BSONSerializeOptions {
2223
/** Specify ClientSession for this command */
2324
session?: ClientSession;
24-
25-
explain?: boolean;
2625
willRetryWrites?: boolean;
2726

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

0 commit comments

Comments
 (0)