diff --git a/src/admin.ts b/src/admin.ts index 78d7da63e4f..a6bad5885e0 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -54,6 +54,22 @@ export class Admin { /** * Execute a command * + * The driver will ensure the following fields are attached to the command sent to the server: + * - `lsid` - sourced from an implicit session or options.session + * - `$readPreference` - defaults to primary or can be configured by options.readPreference + * - `$db` - sourced from the name of this database + * + * If the client has a serverApi setting: + * - `apiVersion` + * - `apiStrict` + * - `apiDeprecationErrors` + * + * When in a transaction: + * - `readConcern` - sourced from readConcern set on the TransactionOptions + * - `writeConcern` - sourced from writeConcern set on the TransactionOptions + * + * Attaching any of the above fields to the command will have no effect as the driver will overwrite the value. + * * @param command - The command to execute * @param options - Optional settings for the command */ diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 2d5ff5a8f02..f86e4dc3056 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -484,22 +484,23 @@ export class Connection extends TypedEventEmitter { command( ns: MongoDBNamespace, - cmd: Document, + command: Document, options: CommandOptions | undefined, callback: Callback ): void { - const readPreference = getReadPreference(cmd, options); + let cmd = { ...command }; + + const readPreference = getReadPreference(options); const shouldUseOpMsg = supportsOpMsg(this); const session = options?.session; let clusterTime = this.clusterTime; - let finalCmd = Object.assign({}, cmd); if (this.serverApi) { const { version, strict, deprecationErrors } = this.serverApi; - finalCmd.apiVersion = version; - if (strict != null) finalCmd.apiStrict = strict; - if (deprecationErrors != null) finalCmd.apiDeprecationErrors = deprecationErrors; + cmd.apiVersion = version; + if (strict != null) cmd.apiStrict = strict; + if (deprecationErrors != null) cmd.apiDeprecationErrors = deprecationErrors; } if (hasSessionSupport(this) && session) { @@ -511,7 +512,7 @@ export class Connection extends TypedEventEmitter { clusterTime = session.clusterTime; } - const err = applySession(session, finalCmd, options); + const err = applySession(session, cmd, options); if (err) { return callback(err); } @@ -521,12 +522,12 @@ export class Connection extends TypedEventEmitter { // if we have a known cluster time, gossip it if (clusterTime) { - finalCmd.$clusterTime = clusterTime; + cmd.$clusterTime = clusterTime; } if (isSharded(this) && !shouldUseOpMsg && readPreference && readPreference.mode !== 'primary') { - finalCmd = { - $query: finalCmd, + cmd = { + $query: cmd, $readPreference: readPreference.toJSON() }; } @@ -544,8 +545,8 @@ export class Connection extends TypedEventEmitter { const cmdNs = `${ns.db}.$cmd`; const message = shouldUseOpMsg - ? new Msg(cmdNs, finalCmd, commandOptions) - : new Query(cmdNs, finalCmd, commandOptions); + ? new Msg(cmdNs, cmd, commandOptions) + : new Query(cmdNs, cmd, commandOptions); try { write(this, message, commandOptions, callback); diff --git a/src/cmap/wire_protocol/shared.ts b/src/cmap/wire_protocol/shared.ts index bb88a47e6dd..53375d8ec57 100644 --- a/src/cmap/wire_protocol/shared.ts +++ b/src/cmap/wire_protocol/shared.ts @@ -1,4 +1,3 @@ -import type { Document } from '../../bson'; import { MongoInvalidArgumentError } from '../../error'; import type { ReadPreferenceLike } from '../../read_preference'; import { ReadPreference } from '../../read_preference'; @@ -13,9 +12,9 @@ export interface ReadPreferenceOption { readPreference?: ReadPreferenceLike; } -export function getReadPreference(cmd: Document, options?: ReadPreferenceOption): ReadPreference { +export function getReadPreference(options?: ReadPreferenceOption): ReadPreference { // Default to command version of the readPreference - let readPreference = cmd.readPreference || ReadPreference.primary; + let readPreference = options?.readPreference ?? ReadPreference.primary; // If we have an option readPreference override the command one if (options?.readPreference) { readPreference = options.readPreference; diff --git a/src/db.ts b/src/db.ts index 3d60010f3fa..4d3ddeba245 100644 --- a/src/db.ts +++ b/src/db.ts @@ -232,6 +232,22 @@ export class Db { * @remarks * This command does not inherit options from the MongoClient. * + * The driver will ensure the following fields are attached to the command sent to the server: + * - `lsid` - sourced from an implicit session or options.session + * - `$readPreference` - defaults to primary or can be configured by options.readPreference + * - `$db` - sourced from the name of this database + * + * If the client has a serverApi setting: + * - `apiVersion` + * - `apiStrict` + * - `apiDeprecationErrors` + * + * When in a transaction: + * - `readConcern` - sourced from readConcern set on the TransactionOptions + * - `writeConcern` - sourced from writeConcern set on the TransactionOptions + * + * Attaching any of the above fields to the command will have no effect as the driver will overwrite the value. + * * @param command - The command to run * @param options - Optional settings for the command */ diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index b92237a9a24..f386ae6b9d2 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -1,11 +1,48 @@ -import type { Document } from '../bson'; +import type { BSONSerializeOptions, Document } from '../bson'; +import type { ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { Callback, MongoDBNamespace } from '../utils'; -import { CommandOperation, CommandOperationOptions, OperationParent } from './command'; +import { CommandOperation, OperationParent } from './command'; /** @public */ -export type RunCommandOptions = CommandOperationOptions; +export type RunCommandOptions = { + /** Specify ClientSession for this command */ + session?: ClientSession; + /** The read preference */ + readPreference?: ReadPreferenceLike; + + // The following options were "accidentally" supported + // Since the options are generally supported through inheritance + + /** @deprecated This is an internal option that has undefined behavior for this API */ + willRetryWrite?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + omitReadPreference?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + writeConcern?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + explain?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + readConcern?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + collation?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + maxTimeMS?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + comment?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + retryWrites?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + dbName?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + authdb?: any; + /** @deprecated This is an internal option that has undefined behavior for this API */ + noResponse?: any; + + /** @internal Used for transaction commands */ + bypassPinningCheck?: boolean; +} & BSONSerializeOptions; /** @internal */ export class RunCommandOperation extends CommandOperation { diff --git a/test/integration/run-command/.gitkeep b/test/integration/run-command/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/run-command/run_command.spec.test.ts b/test/integration/run-command/run_command.spec.test.ts new file mode 100644 index 00000000000..c2ca5e91b5f --- /dev/null +++ b/test/integration/run-command/run_command.spec.test.ts @@ -0,0 +1,6 @@ +import { loadSpecTests } from '../../spec'; +import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner'; + +describe('RunCommand spec', () => { + runUnifiedSuite(loadSpecTests('run-command')); +}); diff --git a/test/integration/run-command/run_command.test.ts b/test/integration/run-command/run_command.test.ts new file mode 100644 index 00000000000..393c9ad1641 --- /dev/null +++ b/test/integration/run-command/run_command.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; + +import { + CommandStartedEvent, + Db, + MongoClient, + ReadConcern, + ReadPreference, + WriteConcern +} from '../../mongodb'; + +describe('RunCommand API', () => { + let client: MongoClient; + let db: Db; + let commandsStarted: CommandStartedEvent[]; + beforeEach(async function () { + const options = { + serverApi: { version: '1', strict: true, deprecationErrors: false }, + monitorCommands: true + }; + client = this.configuration.newClient({}, options); + db = client.db(); + commandsStarted = []; + client.on('commandStarted', started => commandsStarted.push(started)); + }); + + afterEach(async function () { + commandsStarted = []; + await client.close(); + }); + + it('does not modify user input', { requires: { mongodb: '>=5.0' } }, async () => { + const command = Object.freeze({ ping: 1 }); + // will throw if it tries to modify command + await db.command(command, { readPreference: ReadPreference.nearest }); + }); + + it('does not support writeConcern in options', { requires: { mongodb: '>=5.0' } }, async () => { + const command = Object.freeze({ insert: 'test', documents: [{ x: 1 }] }); + await db.command(command, { writeConcern: new WriteConcern('majority') }); + expect(commandsStarted).to.not.have.nested.property('[0].command.writeConcern'); + expect(command).to.not.have.property('writeConcern'); + }); + + // TODO(NODE-4936): We do support readConcern in options, the spec forbids this + it.skip( + 'does not support readConcern in options', + { requires: { mongodb: '>=5.0' } }, + async () => { + const command = Object.freeze({ find: 'test', filter: {} }); + const res = await db.command(command, { readConcern: ReadConcern.MAJORITY }); + expect(res).to.have.property('ok', 1); + expect(commandsStarted).to.not.have.nested.property('[0].command.readConcern'); + expect(command).to.not.have.property('readConcern'); + } + ).skipReason = + 'TODO(NODE-4936): Enable this test when readConcern support has been removed from runCommand'; +}); diff --git a/test/spec/run-command/runCommand.json b/test/spec/run-command/runCommand.json new file mode 100644 index 00000000000..0ae0e9d66e0 --- /dev/null +++ b/test/spec/run-command/runCommand.json @@ -0,0 +1,476 @@ +{ + "description": "runCommand", + "schemaVersion": "1.3", + "createEntities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "db", + "client": "client", + "databaseName": "db" + } + }, + { + "collection": { + "id": "collection", + "database": "db", + "collectionName": "collection" + } + }, + { + "database": { + "id": "dbWithRC", + "client": "client", + "databaseName": "dbWithRC", + "databaseOptions": { + "readConcern": { + "level": "local" + } + } + } + }, + { + "database": { + "id": "dbWithWC", + "client": "client", + "databaseName": "dbWithWC", + "databaseOptions": { + "writeConcern": { + "w": 0 + } + } + } + }, + { + "session": { + "id": "session", + "client": "client" + } + }, + { + "client": { + "id": "clientWithStableApi", + "observeEvents": [ + "commandStartedEvent" + ], + "serverApi": { + "version": "1", + "strict": true + } + } + }, + { + "database": { + "id": "dbWithStableApi", + "client": "clientWithStableApi", + "databaseName": "dbWithStableApi" + } + } + ], + "initialData": [ + { + "collectionName": "collection", + "databaseName": "db", + "documents": [] + } + ], + "tests": [ + { + "description": "always attaches $db and implicit lsid to given command and omits default readPreference", + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$db": "db", + "lsid": { + "$$exists": true + }, + "$readPreference": { + "$$exists": false + } + }, + "commandName": "ping" + } + } + ] + } + ] + }, + { + "description": "attaches the provided session lsid to given command", + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + }, + "session": "session" + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "lsid": { + "$$sessionLsid": "session" + }, + "$db": "db" + }, + "commandName": "ping" + } + } + ] + } + ] + }, + { + "description": "attaches the provided $readPreference to given command", + "operations": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + }, + "readPreference": { + "mode": "nearest" + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$readPreference": { + "mode": "nearest" + }, + "$db": "db" + }, + "commandName": "ping" + } + } + ] + } + ] + }, + { + "description": "does not inherit readConcern specified at the db level", + "operations": [ + { + "name": "runCommand", + "object": "dbWithRC", + "arguments": { + "commandName": "aggregate", + "command": { + "aggregate": "collection", + "pipeline": [], + "cursor": {} + } + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection", + "readConcern": { + "$$exists": false + }, + "$db": "dbWithRC" + }, + "commandName": "aggregate" + } + } + ] + } + ] + }, + { + "description": "does not inherit writeConcern specified at the db level", + "operations": [ + { + "name": "runCommand", + "object": "dbWithWC", + "arguments": { + "commandName": "insert", + "command": { + "insert": "collection", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collection", + "writeConcern": { + "$$exists": false + }, + "$db": "dbWithWC" + }, + "commandName": "insert" + } + } + ] + } + ] + }, + { + "description": "does not retry retryable errors on given command", + "runOnRequirements": [ + { + "minServerVersion": "4.2" + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "ping" + ], + "closeConnection": true + } + } + } + }, + { + "name": "runCommand", + "object": "db", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectError": { + "isClientError": true + } + } + ] + }, + { + "description": "attaches transaction fields to given command", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.2", + "topologies": [ + "sharded-replicaset", + "load-balanced" + ] + } + ], + "operations": [ + { + "name": "withTransaction", + "object": "session", + "arguments": { + "callback": [ + { + "name": "runCommand", + "object": "db", + "arguments": { + "session": "session", + "commandName": "insert", + "command": { + "insert": "collection", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collection", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "$$exists": false + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "db" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ] + }, + { + "description": "attaches apiVersion fields to given command when stableApi is configured on the client", + "runOnRequirements": [ + { + "minServerVersion": "5.0" + } + ], + "operations": [ + { + "name": "runCommand", + "object": "dbWithStableApi", + "arguments": { + "commandName": "ping", + "command": { + "ping": 1 + } + }, + "expectResult": { + "ok": 1 + } + } + ], + "expectEvents": [ + { + "client": "clientWithStableApi", + "events": [ + { + "commandStartedEvent": { + "command": { + "ping": 1, + "$db": "dbWithStableApi", + "apiVersion": "1", + "apiStrict": true, + "apiDeprecationErrors": { + "$$unsetOrMatches": false + } + }, + "commandName": "ping" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/run-command/runCommand.yml b/test/spec/run-command/runCommand.yml new file mode 100644 index 00000000000..3c5f2313613 --- /dev/null +++ b/test/spec/run-command/runCommand.yml @@ -0,0 +1,242 @@ +description: runCommand + +schemaVersion: "1.3" + +createEntities: + - client: + id: &client client + useMultipleMongoses: false + observeEvents: [commandStartedEvent] + - database: + id: &db db + client: *client + databaseName: *db + - collection: + id: &collection collection + database: *db + collectionName: *collection + - database: + id: &dbWithRC dbWithRC + client: *client + databaseName: *dbWithRC + databaseOptions: + readConcern: { level: 'local' } + - database: + id: &dbWithWC dbWithWC + client: *client + databaseName: *dbWithWC + databaseOptions: + writeConcern: { w: 0 } + - session: + id: &session session + client: *client + # Stable API test + - client: + id: &clientWithStableApi clientWithStableApi + observeEvents: [commandStartedEvent] + serverApi: + version: "1" + strict: true + - database: + id: &dbWithStableApi dbWithStableApi + client: *clientWithStableApi + databaseName: *dbWithStableApi + +initialData: +- collectionName: *collection + databaseName: *db + documents: [] + +tests: + - description: always attaches $db and implicit lsid to given command and omits default readPreference + operations: + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + ping: 1 + $db: *db + lsid: { $$exists: true } + $readPreference: { $$exists: false } + commandName: ping + + - description: attaches the provided session lsid to given command + operations: + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + session: *session + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + ping: 1 + lsid: { $$sessionLsid: *session } + $db: *db + commandName: ping + + - description: attaches the provided $readPreference to given command + operations: + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + readPreference: &readPreference { mode: 'nearest' } + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + ping: 1 + $readPreference: *readPreference + $db: *db + commandName: ping + + - description: does not inherit readConcern specified at the db level + operations: + - name: runCommand + object: *dbWithRC + # Test with a command that supports a readConcern option. + # expectResult is intentionally omitted because some drivers + # may automatically convert command responses into cursors. + arguments: + commandName: aggregate + command: { aggregate: *collection, pipeline: [], cursor: {} } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + aggregate: *collection + readConcern: { $$exists: false } + $db: *dbWithRC + commandName: aggregate + + - description: does not inherit writeConcern specified at the db level + operations: + - name: runCommand + object: *dbWithWC + arguments: + commandName: insert + command: + insert: *collection + documents: [ { _id: 1 } ] + ordered: true + expectResult: { ok: 1 } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + insert: *collection + writeConcern: { $$exists: false } + $db: *dbWithWC + commandName: insert + + - description: does not retry retryable errors on given command + runOnRequirements: + - minServerVersion: "4.2" + operations: + - name: failPoint + object: testRunner + arguments: + client: *client + failPoint: + configureFailPoint: failCommand + mode: { times: 1 } + data: + failCommands: [ping] + closeConnection: true + - name: runCommand + object: *db + arguments: + commandName: ping + command: { ping: 1 } + expectError: + isClientError: true + + - description: attaches transaction fields to given command + runOnRequirements: + - minServerVersion: "4.0" + topologies: [ replicaset ] + - minServerVersion: "4.2" + topologies: [ sharded-replicaset, load-balanced ] + operations: + - name: withTransaction + object: *session + arguments: + callback: + - name: runCommand + object: *db + arguments: + session: *session + commandName: insert + command: + insert: *collection + documents: [ { _id: 2 } ] + ordered: true + expectResult: { $$unsetOrMatches: { insertedId: { $$unsetOrMatches: 1 } } } + expectEvents: + - client: *client + events: + - commandStartedEvent: + command: + insert: *collection + documents: [ { _id: 2 } ] + ordered: true + lsid: { $$sessionLsid: *session } + txnNumber: 1 + startTransaction: true + autocommit: false + # omitted fields + readConcern: { $$exists: false } + writeConcern: { $$exists: false } + commandName: insert + databaseName: *db + - commandStartedEvent: + command: + commitTransaction: 1 + lsid: { $$sessionLsid: *session } + txnNumber: 1 + autocommit: false + # omitted fields + writeConcern: { $$exists: false } + readConcern: { $$exists: false } + commandName: commitTransaction + databaseName: admin + + - description: attaches apiVersion fields to given command when stableApi is configured on the client + runOnRequirements: + - minServerVersion: "5.0" + operations: + - name: runCommand + object: *dbWithStableApi + arguments: + commandName: ping + command: + ping: 1 + expectResult: { ok: 1 } + expectEvents: + - client: *clientWithStableApi + events: + - commandStartedEvent: + command: + ping: 1 + $db: *dbWithStableApi + apiVersion: "1" + apiStrict: true + apiDeprecationErrors: { $$unsetOrMatches: false } + commandName: ping diff --git a/test/tools/unified-spec-runner/operations.ts b/test/tools/unified-spec-runner/operations.ts index ee83486ef4f..20e12ffef3b 100644 --- a/test/tools/unified-spec-runner/operations.ts +++ b/test/tools/unified-spec-runner/operations.ts @@ -592,11 +592,18 @@ operations.set('withTransaction', async ({ entities, operation, client, testConf maxCommitTimeMS: operation.arguments!.maxCommitTimeMS }; - return session.withTransaction(async () => { - for (const callbackOperation of operation.arguments!.callback) { - await executeOperationAndCheck(callbackOperation, entities, client, testConfig); - } + let errorFromOperations = null; + const result = await session.withTransaction(async () => { + errorFromOperations = await (async () => { + for (const callbackOperation of operation.arguments!.callback) { + await executeOperationAndCheck(callbackOperation, entities, client, testConfig); + } + })().catch(error => error); }, options); + + if (result == null || errorFromOperations) { + throw errorFromOperations ?? Error('transaction not committed'); + } }); operations.set('countDocuments', async ({ entities, operation }) => { @@ -624,8 +631,18 @@ operations.set('estimatedDocumentCount', async ({ entities, operation }) => { operations.set('runCommand', async ({ entities, operation }: OperationFunctionParams) => { const db = entities.getEntity('db', operation.object); - const { command, ...opts } = operation.arguments!; - return db.command(command, opts); + + if (operation.arguments?.command == null) throw new Error('runCommand requires a command'); + const { command } = operation.arguments; + + if (operation.arguments.timeoutMS != null) throw new Error('timeoutMS not supported'); + + const options = { + readPreference: operation.arguments.readPreference, + session: operation.arguments.session + }; + + return db.command(command, options); }); operations.set('updateMany', async ({ entities, operation }) => {