From 756f24da4df85fee8a48fe5d25c01250ee08ad41 Mon Sep 17 00:00:00 2001 From: Sean Milligan Date: Mon, 27 Jul 2026 19:27:54 -0700 Subject: [PATCH] Allow passthrough options on createIndexes --- src/collection.ts | 35 +- src/gridfs/upload.ts | 6 + src/index.ts | 1 + src/operations/indexes.ts | 64 ++- src/utils.ts | 6 +- .../create_indexes_option_validation.test.ts | 475 ++++++++++++++++++ test/integration/index_management.test.ts | 41 ++ test/unit/operations/indexes.test.ts | 57 +++ 8 files changed, 658 insertions(+), 27 deletions(-) create mode 100644 test/integration/index-management/create_indexes_option_validation.test.ts diff --git a/src/collection.ts b/src/collection.ts index e3a52057363..40667fcbd36 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -56,6 +56,7 @@ import { import { CreateIndexesOperation, type CreateIndexesOptions, + type CreateIndexesCommandOptions, type DropIndexesOptions, DropIndexOperation, type IndexDescription, @@ -609,7 +610,7 @@ export class Collection { * Creates an index on the db and collection collection. * * @param indexSpec - The field name or index specification to create an index for - * @param options - Optional settings for the command + * @param indexOptions - Optional settings for the command * * @example * ```ts @@ -635,16 +636,32 @@ export class Collection { */ async createIndex( indexSpec: IndexSpecification, - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + commandOptions?: CreateIndexesCommandOptions ): Promise { - const indexes = await executeOperation( - this.client, - CreateIndexesOperation.fromIndexSpecification( + let operation; + if (commandOptions) { + // v2 path: index and command options are separated + let indexOptions = options; + operation = CreateIndexesOperation.fromIndexSpecification( + this, + this.collectionName, + indexSpec, + indexOptions, + resolveOptions(this, commandOptions) + ); + } else { + // v1 path: index and command options are merged in the indexOptions + operation = CreateIndexesOperation.fromIndexSpecification( this, this.collectionName, indexSpec, resolveOptions(this, options) - ) + ); + } + const indexes = await executeOperation( + this.client, + operation ); return indexes[0]; @@ -683,7 +700,8 @@ export class Collection { */ async createIndexes( indexSpecs: IndexDescription[], - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + commandOptions?: CreateIndexesCommandOptions ): Promise { return await executeOperation( this.client, @@ -691,7 +709,8 @@ export class Collection { this, this.collectionName, indexSpecs, - resolveOptions(this, { ...options, maxTimeMS: undefined }) + resolveOptions(this, { ...options, maxTimeMS: undefined }), + commandOptions ) ); } diff --git a/src/gridfs/upload.ts b/src/gridfs/upload.ts index 13359cad4fb..e9464c89762 100644 --- a/src/gridfs/upload.ts +++ b/src/gridfs/upload.ts @@ -272,6 +272,10 @@ async function checkChunksIndex(stream: GridFSBucketWriteStream): Promise remainingTimeMS = stream.timeoutContext?.getRemainingTimeMSOrThrow( `Upload timed out after ${stream.timeoutContext?.timeoutMS}ms` ); + // TODO(NODE-6893): this is a mixed bag of index options (background, unique) and command + // options (the write concern fields, timeoutMS), which the two parameter overload sorts via + // the index option allowlist. When validateOptions defaults to false, move the command + // options into the third parameter. await stream.chunks.createIndex(index, { ...stream.writeConcern, background: true, @@ -379,6 +383,8 @@ async function checkIndexes(stream: GridFSBucketWriteStream): Promise { `Upload timed out after ${stream.timeoutContext?.timeoutMS}ms` ); + // TODO(NODE-6893): timeoutMS is a command option; move it into the third parameter when + // validateOptions defaults to false. await stream.files.createIndex(index, { background: false, timeoutMS: remainingTimeMS }); } diff --git a/src/index.ts b/src/index.ts index 542a0c17406..2e5bfeb9495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -529,6 +529,7 @@ export type { export type { IndexInformationOptions } from './operations/indexes'; export type { CreateIndexesOptions, + CreateIndexesCommandOptions, DropIndexesOptions, IndexDescription, IndexDescriptionCompact, diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index f6354dcf1ad..afc54ea2406 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -163,6 +163,15 @@ export interface CreateIndexesOptions extends Omit { + /** ...votingMembers etc. */ + commitQuorum?: number | string; + /** @deprecated will default to false in a future release */ + validateOptions?: boolean; +} + function isSingleIndexTuple(t: unknown): t is [string, IndexDirection] { return Array.isArray(t) && t.length === 2 && isIndexDirection(t[1]); } @@ -198,19 +207,24 @@ function constructIndexDescriptionMap(indexSpec: IndexSpecification): Map { - const validProvidedOptions = Object.entries(description).filter(([optionName]) => - VALID_INDEX_OPTIONS.has(optionName) + const providedOptions = Object.entries(description).filter( + ([optionName]) => allowUnknownIndexOptions || VALID_INDEX_OPTIONS.has(optionName) ); return Object.fromEntries( // we support the `version` option, but the `createIndexes` command expects it to be the `v` - validProvidedOptions.map(([name, value]) => (name === 'version' ? ['v', value] : [name, value])) + providedOptions.map(([name, value]) => (name === 'version' ? ['v', value] : [name, value])) ); } @@ -252,11 +266,17 @@ export class CreateIndexesOperation extends CommandOperation { parent: OperationParent, collectionName: string, indexes: IndexDescription[], - options?: CreateIndexesOptions + indexOptions?: CreateIndexesOptions, + commandOptions?: CreateIndexesCommandOptions ) { - super(parent, options); + // When commandOptions is supplied the caller has separated the two kinds of options, so the + // command reads from it exclusively. Otherwise indexOptions is both, and the command reads + // from it as it always has. There is deliberately no fallback between the two. + const optionsForCommand: CreateIndexesOptions = commandOptions ?? indexOptions ?? {}; - this.options = options ?? {}; + super(parent, optionsForCommand); + + this.options = { ...optionsForCommand }; // collation is set on each index, it should not be defined at the root this.options.collation = undefined; this.collectionName = collectionName; @@ -265,9 +285,13 @@ export class CreateIndexesOperation extends CommandOperation { const key = userIndex.key instanceof Map ? userIndex.key : new Map(Object.entries(userIndex.key)); const name = userIndex.name ?? Array.from(key).flat().join('_'); - const validIndexOptions = resolveIndexDescription(userIndex); + const validIndexOptions = resolveIndexDescription( + userIndex, + // TODO(seanrmilligan): set to false in a future release + commandOptions?.validateOptions ?? true + ); return { - ...validIndexOptions, + ...indexOptions, name, key }; @@ -279,20 +303,28 @@ export class CreateIndexesOperation extends CommandOperation { parent: OperationParent, collectionName: string, indexes: IndexDescription[], - options?: CreateIndexesOptions + indexOptions?: CreateIndexesOptions, + commandOptions?: CreateIndexesCommandOptions ): CreateIndexesOperation { - return new CreateIndexesOperation(parent, collectionName, indexes, options); + return new CreateIndexesOperation(parent, collectionName, indexes, indexOptions, commandOptions); } static fromIndexSpecification( parent: OperationParent, collectionName: string, indexSpec: IndexSpecification, - options: CreateIndexesOptions = {} + indexOptions: CreateIndexesOptions = {}, + commandOptions?: CreateIndexesCommandOptions ): CreateIndexesOperation { const key = constructIndexDescriptionMap(indexSpec); - const description: IndexDescription = { ...options, key }; - return new CreateIndexesOperation(parent, collectionName, [description], options); + const description: IndexDescription = { ...indexOptions, key }; + return new CreateIndexesOperation( + parent, + collectionName, + [description], + indexOptions, + commandOptions + ); } override get commandName() { diff --git a/src/utils.ts b/src/utils.ts index 50c0ddf0a20..bbea3949bd7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -851,7 +851,7 @@ export class BufferPool { // TODO(NODE-4732): alloc API should change based on raw option const result = ByteUtils.allocateUnsafe(size); - for (let bytesRead = 0; bytesRead < size; ) { + for (let bytesRead = 0; bytesRead < size;) { const buffer = this.buffers.shift(); if (buffer == null) { break; @@ -1445,8 +1445,8 @@ export async function abortable( const abortListener = signal.aborted ? reject(signal.reason) : addAbortListener(signal, function () { - reject(this.reason); - }); + reject(this.reason); + }); try { return await Promise.race([promise, aborted]); diff --git a/test/integration/index-management/create_indexes_option_validation.test.ts b/test/integration/index-management/create_indexes_option_validation.test.ts new file mode 100644 index 00000000000..e976aa79c81 --- /dev/null +++ b/test/integration/index-management/create_indexes_option_validation.test.ts @@ -0,0 +1,475 @@ +import { expect } from 'chai'; + +import { + type Collection, + type CommandStartedEvent, + type Db, + type Document, + type MongoClient, + MongoServerError +} from '../../mongodb'; + +/** + * By default the driver filters index options against an allowlist before sending them + * to the server, so options the server supports but the driver has not learned about yet are + * silently dropped. `{ validateOptions: false }` turns the filter off. + * + * `createIndex` and `createIndexes` are separated here because they build their index descriptions + * by different routes. `createIndexes` receives `IndexDescription` objects the user wrote directly, + * so index options and command options never mix. `createIndex` takes a single flat options bag + * that is *both*, and only becomes an index description via a merge in `fromIndexSpecification` — + * which is why turning the allowlist off is far more delicate on that path. + * + * The allowlist is what sorts that mixed bag today: index options survive into the description and + * everything else is dropped. Turning it off removes the only mechanism doing that sorting, so on + * the three parameter overload the separation becomes the caller's responsibility — index options + * in the second parameter, command options in the third. A command option left in the second + * parameter is forwarded to the server verbatim and the server rejects it, which is the intended + * (and loud) outcome rather than something the driver silently repairs. + */ + +/** + * The `key` of an index description is a Map by the time it reaches the wire, so that index key + * ordering is preserved. Convert it back to a plain object so descriptions can be compared with + * `deep.equal`. + */ +function indexesSentBy(event: CommandStartedEvent): Document[] { + return event.command.indexes.map(({ key, ...rest }: Document) => ({ + ...rest, + key: Object.fromEntries(key) + })); +} + +describe('createIndex option validation', function () { + let client: MongoClient; + let db: Db; + let collection: Collection; + let commands: CommandStartedEvent[]; + + /** The `indexes` array as it appeared on the wire for the last createIndexes command. */ + function sentIndexes(): Document[] { + expect(commands).to.have.lengthOf.at.least(1); + return indexesSentBy(commands[commands.length - 1]); + } + + /** The last createIndexes command itself, without its `indexes` array. */ + function sentCommand(): Document { + expect(commands).to.have.lengthOf.at.least(1); + const { indexes: _indexes, ...rest } = commands[commands.length - 1].command; + return rest; + } + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + commands = []; + client.on('commandStarted', ev => { + if (ev.commandName === 'createIndexes') commands.push(ev); + }); + db = client.db('node6893_create_index'); + collection = db.collection('c'); + }); + + afterEach(async function () { + await db.dropDatabase().catch(() => null); + await client.close(); + }); + + // The two parameter overload keeps the historical behaviour: one flat options bag, sorted by the + // allowlist. These tests guard that the filter is still in place and still doing the sorting. + describe('when validateOptions is not specified', function () { + it('sends only the key and a generated name for a bare call', async function () { + await collection.createIndex({ a: 1 }); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('sends index options and maps version to v', async function () { + await collection.createIndex( + { b: 1 }, + { unique: true, sparse: true, name: 'b_ix', version: 2 } + ); + + expect(sentIndexes()).to.deep.equal([ + { unique: true, sparse: true, name: 'b_ix', v: 2, key: { b: 1 } } + ]); + }); + + it('sends text index options', async function () { + await collection.createIndex( + { c: 'text' }, + { weights: { c: 5 }, default_language: 'english', textIndexVersion: 3 } + ); + + expect(sentIndexes()).to.deep.equal([ + { + weights: { c: 5 }, + default_language: 'english', + textIndexVersion: 3, + name: 'c_text', + key: { c: 'text' } + } + ]); + }); + + it('drops an unknown option from the options bag', async function () { + // @ts-expect-error CreateIndexesOptions is a closed interface + await collection.createIndex({ d: 1 }, { unique: true, notARealOption: true }); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'd_1', key: { d: 1 } }]); + }); + + it('keeps user-supplied command options out of the index description', async function () { + await collection.createIndex( + { e: 1 }, + { unique: true, comment: 'a comment', maxTimeMS: 1000, expireAfterSeconds: 100 } + ); + + expect(sentIndexes()).to.deep.equal([ + { unique: true, expireAfterSeconds: 100, name: 'e_1', key: { e: 1 } } + ]); + expect(sentCommand()).to.have.property('maxTimeMS', 1000); + }); + + it('keeps command options out of the index description for db.createIndex', async function () { + await db.createIndex('c', { f: 1 }, { unique: true, comment: 'a comment' }); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'f_1', key: { f: 1 } }]); + }); + }); + + describe('when validateOptions is true', function () { + it('sends only the key and a generated name for a bare call', async function () { + await collection.createIndex({ a: 1 }, {}, { validateOptions: true }); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('drops an unknown option from the options bag', async function () { + await collection.createIndex( + { d: 1 }, + // @ts-expect-error CreateIndexesOptions is a closed interface + { unique: true, notARealOption: true }, + { validateOptions: true } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'd_1', key: { d: 1 } }]); + }); + + it('keeps user-supplied command options out of the index description', async function () { + await collection.createIndex( + { e: 1 }, + { unique: true, comment: 'a comment', maxTimeMS: 1000 }, + { validateOptions: true } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'e_1', key: { e: 1 } }]); + }); + }); + + describe('when validateOptions is false', function () { + it('does not send driver options the user never supplied', async function () { + await collection.createIndex({ a: 1 }, {}, { validateOptions: false }); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('sends an unknown option to the server', async function () { + const error = await collection + // @ts-expect-error CreateIndexesOptions is a closed interface + .createIndex({ d: 1 }, { notARealOption: true }, { validateOptions: false }) + .catch(error => error); + + // the driver forwards the option; the server is what rejects it + expect(sentIndexes()[0]).to.have.property('notARealOption', true); + expect(error).to.be.instanceOf(MongoServerError); + expect(error.message).to.match(/not valid for an index specification/); + }); + + it( + 'creates an index using a server option the driver does not know about', + { metadata: { requires: { mongodb: '>=5.3' } } }, + async function () { + // `prepareUnique` is supported by the server but is not in the driver's allowlist + await collection.createIndex( + { e: 1 }, + // @ts-expect-error CreateIndexesOptions is a closed interface + { prepareUnique: true }, + { validateOptions: false } + ); + + expect(sentIndexes()[0]).to.have.property('prepareUnique', true); + const indexes = await collection.listIndexes().toArray(); + expect(indexes.find(index => index.name === 'e_1')).to.have.property('prepareUnique', true); + } + ); + + it('sends index options as normal', async function () { + await collection.createIndex( + { f: 1 }, + { unique: true, sparse: true, version: 2 }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([ + { unique: true, sparse: true, v: 2, name: 'f_1', key: { f: 1 } } + ]); + }); + + describe('and command options are passed in the third parameter', function () { + it('keeps a comment out of the index description', async function () { + await collection.createIndex( + { g: 1 }, + { unique: true }, + { validateOptions: false, comment: 'a comment' } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'g_1', key: { g: 1 } }]); + // `comment` is accepted by CommandOperationOptions but createIndexes has never written it + // into its command document, so it does not reach the wire on either overload. This + // asserts only that the third parameter does not leak it into the index description. + expect(sentCommand()).to.not.have.property('comment'); + }); + + it('sends maxTimeMS on the command and not in the index description', async function () { + await collection.createIndex( + { h: 1 }, + { unique: true }, + { validateOptions: false, maxTimeMS: 1000 } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'h_1', key: { h: 1 } }]); + expect(sentCommand()).to.have.property('maxTimeMS', 1000); + }); + + it('sends a session on the command and not in the index description', async function () { + const session = client.startSession(); + try { + await collection.createIndex( + { i: 1 }, + { unique: true }, + { validateOptions: false, session } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'i_1', key: { i: 1 } }]); + expect(sentCommand()).to.have.property('lsid'); + } finally { + await session.endSession(); + } + }); + + it('sends a writeConcern on the command and not in the index description', async function () { + await collection.createIndex( + { j: 1 }, + { unique: true }, + { validateOptions: false, writeConcern: { w: 1 } } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'j_1', key: { j: 1 } }]); + expect(sentCommand()).to.have.property('writeConcern'); + }); + + it('sends index options and command options together', async function () { + await collection.createIndex( + { k: 1 }, + { unique: true, sparse: true, expireAfterSeconds: 100 }, + { validateOptions: false, maxTimeMS: 1000, writeConcern: { w: 1 } } + ); + + expect(sentIndexes()).to.deep.equal([ + { unique: true, sparse: true, expireAfterSeconds: 100, name: 'k_1', key: { k: 1 } } + ]); + expect(sentCommand()).to.have.property('maxTimeMS', 1000); + expect(sentCommand()).to.have.property('writeConcern'); + }); + }); + + describe('and a command option is left in the index options', function () { + it('forwards a comment to the server, which rejects it', async function () { + const error = await collection + .createIndex({ l: 1 }, { unique: true, comment: 'a comment' }, { validateOptions: false }) + .catch(error => error); + + expect(sentIndexes()[0]).to.have.property('comment', 'a comment'); + expect(error).to.be.instanceOf(MongoServerError); + expect(error.message).to.match(/not valid for an index specification/); + }); + + it('forwards maxTimeMS to the server, which rejects it', async function () { + const error = await collection + .createIndex({ m: 1 }, { unique: true, maxTimeMS: 1000 }, { validateOptions: false }) + .catch(error => error); + + expect(sentIndexes()[0]).to.have.property('maxTimeMS', 1000); + expect(error).to.be.instanceOf(MongoServerError); + expect(error.message).to.match(/not valid for an index specification/); + }); + }); + }); +}); + +describe('createIndexes option validation', function () { + let client: MongoClient; + let db: Db; + let collection: Collection; + let commands: CommandStartedEvent[]; + + function sentIndexes(): Document[] { + expect(commands).to.have.lengthOf.at.least(1); + return indexesSentBy(commands[commands.length - 1]); + } + + function sentCommand(): Document { + expect(commands).to.have.lengthOf.at.least(1); + const { indexes: _indexes, ...rest } = commands[commands.length - 1].command; + return rest; + } + + beforeEach(async function () { + client = this.configuration.newClient({}, { monitorCommands: true }); + commands = []; + client.on('commandStarted', ev => { + if (ev.commandName === 'createIndexes') commands.push(ev); + }); + db = client.db('node6893_create_indexes'); + collection = db.collection('c'); + }); + + afterEach(async function () { + await db.dropDatabase().catch(() => null); + await client.close(); + }); + + describe('when validateOptions is not specified', function () { + it('sends only the key and a generated name for a bare description', async function () { + await collection.createIndexes([{ key: { a: 1 } }]); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('sends index options and maps version to v', async function () { + await collection.createIndexes([ + { key: { b: 1 }, name: 'b_ix', unique: true, version: 2 }, + { key: { c: -1 }, hidden: true, expireAfterSeconds: 60 } + ]); + + expect(sentIndexes()).to.deep.equal([ + { name: 'b_ix', unique: true, v: 2, key: { b: 1 } }, + { hidden: true, expireAfterSeconds: 60, name: 'c_-1', key: { c: -1 } } + ]); + }); + + it('drops an unknown option from an index description', async function () { + await collection.createIndexes([ + // @ts-expect-error IndexDescription is a closed interface + { key: { d: 1 }, name: 'd_1', unique: true, notARealOption: true } + ]); + + expect(sentIndexes()).to.deep.equal([{ name: 'd_1', unique: true, key: { d: 1 } }]); + }); + + it('keeps user-supplied command options out of the index description', async function () { + await collection.createIndexes([{ key: { e: 1 } }], { writeConcern: { w: 1 } }); + + expect(sentIndexes()).to.deep.equal([{ key: { e: 1 }, name: 'e_1' }]); + expect(sentCommand()).to.have.property('writeConcern'); + }); + }); + + describe('when validateOptions is true', function () { + it('sends only the key and a generated name for a bare description', async function () { + await collection.createIndexes([{ key: { a: 1 } }], {}, { validateOptions: true }); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('drops an unknown option from an index description', async function () { + await collection.createIndexes( + // @ts-expect-error IndexDescription is a closed interface + [{ key: { d: 1 }, name: 'd_1', unique: true, notARealOption: true }], + {}, + { validateOptions: true } + ); + + expect(sentIndexes()).to.deep.equal([{ name: 'd_1', unique: true, key: { d: 1 } }]); + }); + }); + + describe('when validateOptions is false', function () { + it('does not send driver options the user never supplied', async function () { + await collection.createIndexes([{ key: { a: 1 } }], {}, { validateOptions: false }); + + expect(sentIndexes()).to.deep.equal([{ key: { a: 1 }, name: 'a_1' }]); + }); + + it('sends an unknown option to the server', async function () { + const error = await collection + .createIndexes( + // @ts-expect-error IndexDescription is a closed interface + [{ key: { d: 1 }, name: 'd_1', notARealOption: true }], + {}, + { validateOptions: false } + ) + .catch(error => error); + + expect(sentIndexes()[0]).to.have.property('notARealOption', true); + expect(error).to.be.instanceOf(MongoServerError); + expect(error.message).to.match(/not valid for an index specification/); + }); + + it( + 'creates an index using a server option the driver does not know about', + { metadata: { requires: { mongodb: '>=5.3' } } }, + async function () { + await collection.createIndexes( + // @ts-expect-error IndexDescription is a closed interface + [{ key: { e: 1 }, name: 'e_1', prepareUnique: true }], + {}, + { validateOptions: false } + ); + + expect(sentIndexes()[0]).to.have.property('prepareUnique', true); + const indexes = await collection.listIndexes().toArray(); + expect(indexes.find(index => index.name === 'e_1')).to.have.property('prepareUnique', true); + } + ); + + it('sends index options as normal', async function () { + await collection.createIndexes( + [{ key: { f: 1 }, unique: true, sparse: true, version: 2 }], + {}, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([ + { unique: true, sparse: true, v: 2, name: 'f_1', key: { f: 1 } } + ]); + }); + + it('keeps user-supplied command options out of the index description', async function () { + await collection.createIndexes( + [{ key: { g: 1 }, unique: true }], + { writeConcern: { w: 1 } }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'g_1', key: { g: 1 } }]); + expect(sentCommand()).to.have.property('writeConcern'); + }); + + it('keeps a user-supplied session out of the index description', async function () { + const session = client.startSession(); + try { + await collection.createIndexes( + [{ key: { i: 1 }, unique: true }], + { session }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'i_1', key: { i: 1 } }]); + expect(sentCommand()).to.have.property('lsid'); + } finally { + await session.endSession(); + } + }); + }); +}); diff --git a/test/integration/index_management.test.ts b/test/integration/index_management.test.ts index 100cb1c9a5a..d9d04619c37 100644 --- a/test/integration/index_management.test.ts +++ b/test/integration/index_management.test.ts @@ -252,6 +252,47 @@ describe('Indexes', function () { }); } ); + + context('when an unknown index option is provided', function () { + context('and allowUnknownIndexOptions is unset (default)', function () { + it('silently drops the unknown option and creates the index', async () => { + const [name] = await collection.createIndexes([ + // @ts-expect-error: intentionally providing an unknown option + { key: { loc: '2dsphere' }, thisOptionDoesNotExist: true } + ]); + expect(started[0].command.indexes[0]).to.not.have.property('thisOptionDoesNotExist'); + const indexes = await collection.listIndexes().toArray(); + expect(indexes.map(i => i.name)).to.include(name); + }); + }); + + context('and allowUnknownIndexOptions is false', function () { + it('silently drops the unknown option and creates the index', async () => { + const [name] = await collection.createIndexes( + // @ts-expect-error: intentionally providing an unknown option + [{ key: { loc: '2dsphere' }, thisOptionDoesNotExist: true }], + { allowUnknownIndexOptions: false } + ); + expect(started[0].command.indexes[0]).to.not.have.property('thisOptionDoesNotExist'); + const indexes = await collection.listIndexes().toArray(); + expect(indexes.map(i => i.name)).to.include(name); + }); + }); + + context('and allowUnknownIndexOptions is true', function () { + it('passes the option through and surfaces the server error', async () => { + const error = await collection + .createIndexes( + // @ts-expect-error: intentionally providing an unknown option + [{ key: { loc: '2dsphere' }, thisOptionDoesNotExist: true }], + { allowUnknownIndexOptions: true } + ) + .catch(error => error); + expect(error).to.be.instanceOf(MongoServerError); + expect(started[0].command.indexes[0]).to.have.property('thisOptionDoesNotExist', true); + }); + }); + }); }); describe('Collection.indexExists()', function () { diff --git a/test/unit/operations/indexes.test.ts b/test/unit/operations/indexes.test.ts index 585da82356a..7cf020a859c 100644 --- a/test/unit/operations/indexes.test.ts +++ b/test/unit/operations/indexes.test.ts @@ -107,6 +107,14 @@ describe('class CreateIndexesOperation', () => { options ); + const makeIndexesOperation = (indexes, options: CreateIndexesOptions = {}) => + CreateIndexesOperation.fromIndexDescriptionArray( + { s: { namespace: ns('a.b') } }, + 'b', + indexes, + options + ); + describe('#constructor()', () => { for (const { description, input, mapData, name } of testCases) { it(`should create fieldHash correctly when input is: ${description}`, () => { @@ -152,4 +160,53 @@ describe('class CreateIndexesOperation', () => { expect(indexOutput.indexes[0]).to.not.have.property('randomOptionThatWillNeverBeAdded'); }); }); + + describe('allowUnknownIndexOptions (createIndexes passthrough)', () => { + const indexDescription = () => ({ + key: { a: 1 }, + // @ts-expect-error: Testing that unknown options are passed through when enabled + finestIndexedLevel: 15, + randomOptionThatWillNeverBeAdded: true + }); + + it('drops unknown options when the flag is unset (default behavior)', () => { + const output = makeIndexesOperation([indexDescription()]); + expect(output.indexes[0]).to.not.have.property('finestIndexedLevel'); + expect(output.indexes[0]).to.not.have.property('randomOptionThatWillNeverBeAdded'); + }); + + it('drops unknown options when the flag is set to false', () => { + const output = makeIndexesOperation([indexDescription()], { + allowUnknownIndexOptions: false + }); + expect(output.indexes[0]).to.not.have.property('finestIndexedLevel'); + expect(output.indexes[0]).to.not.have.property('randomOptionThatWillNeverBeAdded'); + }); + + it('retains unknown options when the flag is set to true', () => { + const output = makeIndexesOperation([indexDescription()], { + allowUnknownIndexOptions: true + }); + expect(output.indexes[0]).to.have.property('finestIndexedLevel', 15); + expect(output.indexes[0]).to.have.property('randomOptionThatWillNeverBeAdded', true); + }); + + it('still maps `version` to `v` when the flag is set to true', () => { + const output = makeIndexesOperation([{ key: { a: 1 }, version: 1 }], { + allowUnknownIndexOptions: true + }); + expect(output.indexes[0]).to.have.property('v', 1); + expect(output.indexes[0]).to.not.have.property('version'); + }); + + it('does not enable passthrough for createIndex even when the flag is set to true', () => { + const output = makeIndexOperation( + { a: 1 }, + // @ts-expect-error: Testing bad options get filtered + { allowUnknownIndexOptions: true, randomOptionThatWillNeverBeAdded: true } + ); + expect(output.indexes[0]).to.not.have.property('randomOptionThatWillNeverBeAdded'); + expect(output.indexes[0]).to.not.have.property('allowUnknownIndexOptions'); + }); + }); });