diff --git a/.eslintrc b/.eslintrc index fb93d387c5..433bc1c78c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,6 +21,7 @@ "jsdoc/require-jsdoc": "off", "jsdoc/no-undefined-types": "off", + "jsdoc/require-param": "off", "jsdoc/require-param-description": "off", "jsdoc/require-returns": "off", "jsdoc/require-returns-description": "off", diff --git a/lib/collection.js b/lib/collection.js index 4c8299877a..b6d972fce5 100644 --- a/lib/collection.js +++ b/lib/collection.js @@ -39,7 +39,6 @@ const { removeDocuments, updateDocuments } = require('./operations/common_functi const AggregateOperation = require('./operations/aggregate'); const BulkWriteOperation = require('./operations/bulk_write'); const CountDocumentsOperation = require('./operations/count_documents'); -const CreateIndexOperation = require('./operations/create_index'); const CreateIndexesOperation = require('./operations/create_indexes'); const DeleteManyOperation = require('./operations/delete_many'); const DeleteOneOperation = require('./operations/delete_one'); @@ -1233,6 +1232,7 @@ Collection.prototype.isCapped = function(options, callback) { * @param {object} [options.partialFilterExpression] Creates a partial index based on the given filter object (MongoDB 3.2 or higher) * @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields). * @param {ClientSession} [options.session] optional session to use for this operation + * @param {(number|string)} [options.commitQuorum] (MongoDB 4.4. or higher) Specifies how many data-bearing members of a replica set, including the primary, must complete the index builds successfully before the primary marks the indexes as ready. This option accepts the same values for the "w" field in a write concern plus "votingMembers", which indicates all voting data-bearing nodes. * @param {Collection~resultCallback} [callback] The command result callback * @returns {Promise} returns Promise if no callback passed * @example @@ -1259,14 +1259,14 @@ Collection.prototype.createIndex = function(fieldOrSpec, options, callback) { if (typeof options === 'function') (callback = options), (options = {}); options = options || {}; - const createIndexOperation = new CreateIndexOperation( - this.s.db, + const createIndexesOperation = new CreateIndexesOperation( + this, this.collectionName, fieldOrSpec, options ); - return executeOperation(this.s.topology, createIndexOperation, callback); + return executeOperation(this.s.topology, createIndexesOperation, callback); }; /** @@ -1281,6 +1281,7 @@ Collection.prototype.createIndex = function(fieldOrSpec, options, callback) { * @param {Collection~IndexDefinition[]} indexSpecs An array of index specifications to be created * @param {object} [options] Optional settings * @param {ClientSession} [options.session] optional session to use for this operation + * @param {(number|string)} [options.commitQuorum] (MongoDB 4.4. or higher) Specifies how many data-bearing members of a replica set, including the primary, must complete the index builds successfully before the primary marks the indexes as ready. This option accepts the same values for the "w" field in a write concern plus "votingMembers", which indicates all voting data-bearing nodes. * @param {Collection~resultCallback} [callback] The command result callback * @returns {Promise} returns Promise if no callback passed * @example @@ -1305,9 +1306,15 @@ Collection.prototype.createIndexes = function(indexSpecs, options, callback) { if (typeof options === 'function') (callback = options), (options = {}); options = options ? Object.assign({}, options) : {}; + if (typeof options.maxTimeMS !== 'number') delete options.maxTimeMS; - const createIndexesOperation = new CreateIndexesOperation(this, indexSpecs, options); + const createIndexesOperation = new CreateIndexesOperation( + this, + this.collectionName, + indexSpecs, + options + ); return executeOperation(this.s.topology, createIndexesOperation, callback); }; diff --git a/lib/db.js b/lib/db.js index 680c4e76d4..33162a8fbc 100644 --- a/lib/db.js +++ b/lib/db.js @@ -38,7 +38,7 @@ const AddUserOperation = require('./operations/add_user'); const CollectionsOperation = require('./operations/collections'); const CommandOperation = require('./operations/command'); const CreateCollectionOperation = require('./operations/create_collection'); -const CreateIndexOperation = require('./operations/create_index'); +const CreateIndexesOperation = require('./operations/create_indexes'); const { DropCollectionOperation, DropDatabaseOperation } = require('./operations/drop'); const ExecuteDbAdminCommandOperation = require('./operations/execute_db_admin_command'); const IndexInformationOperation = require('./operations/index_information'); @@ -713,6 +713,7 @@ Db.prototype.executeDbAdminCommand = function(selector, options, callback) { * @param {string} [options.name] Override the autogenerated index name (useful if the resulting name is larger than 128 bytes) * @param {object} [options.partialFilterExpression] Creates a partial index based on the given filter object (MongoDB 3.2 or higher) * @param {ClientSession} [options.session] optional session to use for this operation + * @param {(number|string)} [options.commitQuorum] (MongoDB 4.4. or higher) Specifies how many data-bearing members of a replica set, including the primary, must complete the index builds successfully before the primary marks the indexes as ready. This option accepts the same values for the "w" field in a write concern plus "votingMembers", which indicates all voting data-bearing nodes. * @param {Db~resultCallback} [callback] The command result callback * @returns {Promise} returns Promise if no callback passed */ @@ -720,9 +721,9 @@ Db.prototype.createIndex = function(name, fieldOrSpec, options, callback) { if (typeof options === 'function') (callback = options), (options = {}); options = options ? Object.assign({}, options) : {}; - const createIndexOperation = new CreateIndexOperation(this, name, fieldOrSpec, options); + const createIndexesOperation = new CreateIndexesOperation(this, name, fieldOrSpec, options); - return executeOperation(this.s.topology, createIndexOperation, callback); + return executeOperation(this.s.topology, createIndexesOperation, callback); }; /** diff --git a/lib/operations/create_index.js b/lib/operations/create_index.js deleted file mode 100644 index d1e10aa7bb..0000000000 --- a/lib/operations/create_index.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -const CommandOperation = require('./command'); -const { Aspect, defineAspects } = require('./operation'); -const { handleCallback, parseIndexOptions } = require('../utils'); -const { MongoError } = require('../error'); - -const keysToOmit = new Set([ - 'name', - 'key', - 'writeConcern', - 'w', - 'wtimeout', - 'j', - 'fsync', - 'readPreference', - 'session' -]); - -class CreateIndexOperation extends CommandOperation { - constructor(db, name, fieldOrSpec, options) { - super(db, options); - - // Build the index - const indexParameters = parseIndexOptions(fieldOrSpec); - // Generate the index name - const indexName = typeof options.name === 'string' ? options.name : indexParameters.name; - // Set up the index - const indexesObject = { name: indexName, key: indexParameters.fieldHash }; - - this.name = name; - this.fieldOrSpec = fieldOrSpec; - this.indexes = indexesObject; - } - - _buildCommand() { - const options = this.options; - const name = this.name; - const indexes = this.indexes; - - // merge all the options - for (let optionName in options) { - if (!keysToOmit.has(optionName)) { - indexes[optionName] = options[optionName]; - } - } - - // Create command, apply write concern to command - const cmd = { createIndexes: name, indexes: [indexes] }; - - return cmd; - } - - execute(callback) { - const db = this.db; - const options = this.options; - const indexes = this.indexes; - - // Get capabilities - const capabilities = db.s.topology.capabilities(); - - // Did the user pass in a collation, check if our write server supports it - if (options.collation && capabilities && !capabilities.commandsTakeCollation) { - // Create a new error - const error = new MongoError('server/primary/mongos does not support collation'); - error.code = 67; - // Return the error - return callback(error); - } - - // Ensure we have a callback - if (options.writeConcern && typeof callback !== 'function') { - throw MongoError.create({ - message: 'Cannot use a writeConcern without a provided callback', - driver: true - }); - } - - // Attempt to run using createIndexes command - super.execute((err, result) => { - if (err == null) return handleCallback(callback, err, indexes.name); - - return handleCallback(callback, err, result); - }); - } -} - -defineAspects(CreateIndexOperation, Aspect.WRITE_OPERATION); - -module.exports = CreateIndexOperation; diff --git a/lib/operations/create_indexes.js b/lib/operations/create_indexes.js index f71a5d75e6..12eba1b4a1 100644 --- a/lib/operations/create_indexes.js +++ b/lib/operations/create_indexes.js @@ -1,59 +1,108 @@ 'use strict'; -const ReadPreference = require('../read_preference'); -const { Aspect, defineAspects, OperationBase } = require('./operation'); -const { executeCommand } = require('./db_ops'); +const { Aspect, defineAspects } = require('./operation'); const { MongoError } = require('../error'); +const CommandOperationV2 = require('./command_v2'); +const { maxWireVersion, parseIndexOptions } = require('../utils'); -class CreateIndexesOperation extends OperationBase { - constructor(collection, indexSpecs, options) { - super(options); +const validIndexOptions = new Set([ + 'unique', + 'partialFilterExpression', + 'sparse', + 'background', + 'expireAfterSeconds', + 'storageEngine', + 'collation' +]); +class CreateIndexesOperation extends CommandOperationV2 { + constructor(parent, collection, indexes, options) { + super(parent, options); this.collection = collection; - this.indexSpecs = indexSpecs; + + // createIndex can be called with a variety of styles: + // coll.createIndex('a'); + // coll.createIndex({ a: 1 }); + // coll.createIndex([['a', 1]]); + // createIndexes is always called with an array of index spec objects + if (!Array.isArray(indexes) || Array.isArray(indexes[0])) { + this.onlyReturnNameOfCreatedIndex = true; + // TODO: remove in v4 (breaking change); make createIndex return full response as createIndexes does + + const indexParameters = parseIndexOptions(indexes); + // Generate the index name + const name = typeof options.name === 'string' ? options.name : indexParameters.name; + // Set up the index + const indexSpec = { name, key: indexParameters.fieldHash }; + // merge valid index options into the index spec + for (let optionName in options) { + if (validIndexOptions.has(optionName)) { + indexSpec[optionName] = options[optionName]; + } + } + this.indexes = [indexSpec]; + return; + } + + this.indexes = indexes; } - execute(callback) { - const coll = this.collection; - const indexSpecs = this.indexSpecs; - let options = this.options; + execute(server, callback) { + const options = this.options; + const indexes = this.indexes; - const capabilities = coll.s.topology.capabilities(); + const serverWireVersion = maxWireVersion(server); // Ensure we generate the correct name if the parameter is not set - for (let i = 0; i < indexSpecs.length; i++) { - if (indexSpecs[i].name == null) { - const keys = []; + for (let i = 0; i < indexes.length; i++) { + // Did the user pass in a collation, check if our write server supports it + if (indexes[i].collation && serverWireVersion < 5) { + callback( + new MongoError( + `Server ${server.name}, which reports wire version ${serverWireVersion}, does not support collation` + ) + ); + return; + } - // Did the user pass in a collation, check if our write server supports it - if (indexSpecs[i].collation && capabilities && !capabilities.commandsTakeCollation) { - return callback(new MongoError('server/primary/mongos does not support collation')); - } + if (indexes[i].name == null) { + const keys = []; - for (let name in indexSpecs[i].key) { - keys.push(`${name}_${indexSpecs[i].key[name]}`); + for (let name in indexes[i].key) { + keys.push(`${name}_${indexes[i].key[name]}`); } // Set the name - indexSpecs[i].name = keys.join('_'); + indexes[i].name = keys.join('_'); + } + } + + const cmd = { createIndexes: this.collection, indexes }; + + if (options.commitQuorum != null) { + if (serverWireVersion < 9) { + callback( + new MongoError('`commitQuorum` option for `createIndexes` not supported on servers < 4.4') + ); + return; } + cmd.commitQuorum = options.commitQuorum; } - options = Object.assign({}, options, { readPreference: ReadPreference.PRIMARY }); - - // Execute the index - executeCommand( - coll.s.db, - { - createIndexes: coll.collectionName, - indexes: indexSpecs - }, - options, - callback - ); + // collation is set on each index, it should not be defined at the root + this.options.collation = undefined; + + super.executeCommand(server, cmd, (err, result) => { + if (err) { + callback(err); + return; + } + + callback(null, this.onlyReturnNameOfCreatedIndex ? indexes[0].name : result); + }); } } -defineAspects(CreateIndexesOperation, Aspect.WRITE_OPERATION); +defineAspects(CreateIndexesOperation, [Aspect.WRITE_OPERATION, Aspect.EXECUTE_WITH_SELECTION]); module.exports = CreateIndexesOperation; diff --git a/test/functional/collations.test.js b/test/functional/collations.test.js index 06e7f1c5a6..5a4f3d3108 100644 --- a/test/functional/collations.test.js +++ b/test/functional/collations.test.js @@ -716,7 +716,7 @@ describe('Collation', function() { .then(() => Promise.reject('should not succeed')) .catch(err => { expect(err).to.exist; - expect(err.message).to.equal('server/primary/mongos does not support collation'); + expect(err.message).to.match(/does not support collation$/); }) .then(() => client.close()); }); @@ -750,7 +750,7 @@ describe('Collation', function() { .createIndexes([{ key: { a: 1 }, collation: { caseLevel: true } }]) .then(() => Promise.reject('should not succeed')) .catch(err => { - expect(err.message).to.equal('server/primary/mongos does not support collation'); + expect(err.message).to.match(/does not support collation$/); return client.close(); }); }); diff --git a/test/functional/index.test.js b/test/functional/index.test.js index 3289fcc4b0..83772ee665 100644 --- a/test/functional/index.test.js +++ b/test/functional/index.test.js @@ -1,6 +1,9 @@ 'use strict'; var test = require('./shared').assert; var setupDatabase = require('./shared').setupDatabase; +const expect = require('chai').expect; +const withClient = require('./shared').withClient; +const withMonitoredClient = require('./shared').withMonitoredClient; describe('Indexes', function() { before(function() { @@ -86,6 +89,7 @@ describe('Indexes', function() { ], configuration.writeConcernMax(), function(err, indexName) { + expect(err).to.not.exist; test.equal('a_-1_b_1_c_-1', indexName); // Let's fetch the index information db.indexInformation(collection.collectionName, function(err, collectionInfo) { @@ -1199,4 +1203,92 @@ describe('Indexes', function() { }); } }); + + context('commitQuorum', function() { + function throwErrorTest(testCommand) { + return { + metadata: { requires: { mongodb: '<4.4' } }, + test: function() { + return withClient(this.configuration.newClient(), client => done => { + const db = client.db('test'); + const collection = db.collection('commitQuorum'); + testCommand(db, collection, (err, result) => { + expect(err).to.exist; + expect(err.message).to.equal( + '`commitQuorum` option for `createIndexes` not supported on servers < 4.4' + ); + expect(result).to.not.exist; + done(); + }); + }); + } + }; + } + it( + 'should throw an error if commitQuorum specified on db.createIndex', + throwErrorTest((db, collection, cb) => + db.createIndex(collection.collectionName, 'a', { commitQuorum: 'all' }, cb) + ) + ); + it( + 'should throw an error if commitQuorum specified on collection.createIndex', + throwErrorTest((db, collection, cb) => + collection.createIndex('a', { commitQuorum: 'all' }, cb) + ) + ); + it( + 'should throw an error if commitQuorum specified on collection.createIndexes', + throwErrorTest((db, collection, cb) => + collection.createIndexes( + [{ key: { a: 1 } }, { key: { b: 1 } }], + { commitQuorum: 'all' }, + cb + ) + ) + ); + + function commitQuorumTest(testCommand) { + return { + metadata: { requires: { mongodb: '>=4.4', topology: ['replicaset', 'sharded'] } }, + test: withMonitoredClient('createIndexes', function(client, events, done) { + const db = client.db('test'); + const collection = db.collection('commitQuorum'); + collection.insertOne({ a: 1 }, function(err) { + expect(err).to.not.exist; + testCommand(db, collection, err => { + expect(err).to.not.exist; + expect(events) + .to.be.an('array') + .with.lengthOf(1); + expect(events[0]) + .nested.property('command.commitQuorum') + .to.equal(0); + collection.drop(err => { + expect(err).to.not.exist; + done(); + }); + }); + }); + }) + }; + } + it( + 'should run command with commitQuorum if specified on db.createIndex', + commitQuorumTest((db, collection, cb) => + db.createIndex(collection.collectionName, 'a', { w: 'majority', commitQuorum: 0 }, cb) + ) + ); + it( + 'should run command with commitQuorum if specified on collection.createIndex', + commitQuorumTest((db, collection, cb) => + collection.createIndex('a', { w: 'majority', commitQuorum: 0 }, cb) + ) + ); + it( + 'should run command with commitQuorum if specified on collection.createIndexes', + commitQuorumTest((db, collection, cb) => + collection.createIndexes([{ key: { a: 1 } }], { w: 'majority', commitQuorum: 0 }, cb) + ) + ); + }); });