From 9bcfdc908f988c5673ac787bea7042e6c9bc423d Mon Sep 17 00:00:00 2001 From: Sean Milligan Date: Fri, 7 Aug 2026 10:18:42 -0700 Subject: [PATCH 1/2] Add driver option to toggle driver-side validation of createIndex parameters --- src/collection.ts | 13 ++++++--- src/index.ts | 1 + src/operations/indexes.ts | 55 ++++++++++++++++++++++++++++++--------- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/collection.ts b/src/collection.ts index e3a52057363..0eb02196972 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -56,6 +56,7 @@ import { import { CreateIndexesOperation, type CreateIndexesOptions, + type DriverIndexesOptions, type DropIndexesOptions, DropIndexOperation, type IndexDescription, @@ -635,7 +636,8 @@ export class Collection { */ async createIndex( indexSpec: IndexSpecification, - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + driverOptions?: DriverIndexesOptions ): Promise { const indexes = await executeOperation( this.client, @@ -643,7 +645,8 @@ export class Collection { this, this.collectionName, indexSpec, - resolveOptions(this, options) + resolveOptions(this, options), + driverOptions ) ); @@ -683,7 +686,8 @@ export class Collection { */ async createIndexes( indexSpecs: IndexDescription[], - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + driverOptions?: DriverIndexesOptions ): Promise { return await executeOperation( this.client, @@ -691,7 +695,8 @@ export class Collection { this, this.collectionName, indexSpecs, - resolveOptions(this, { ...options, maxTimeMS: undefined }) + resolveOptions(this, { ...options, maxTimeMS: undefined }), + driverOptions ) ); } diff --git a/src/index.ts b/src/index.ts index ef151a64424..c8c553a9be3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -530,6 +530,7 @@ export type { export type { IndexInformationOptions } from './operations/indexes'; export type { CreateIndexesOptions, + DriverIndexesOptions, DropIndexesOptions, IndexDescription, IndexDescriptionCompact, diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index d6ae371f702..e23b23f25b6 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -162,6 +162,17 @@ export interface CreateIndexesOptions extends Omit { - const validProvidedOptions = Object.entries(description).filter(([optionName]) => - VALID_INDEX_OPTIONS.has(optionName) - ); + let options: [string, any][]; + + if (validateOptions) { + options = Object.entries(description).filter(([optionName]) => + VALID_INDEX_OPTIONS.has(optionName) + ); + } else { + options = Object.entries(description); + } 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])) + // we support the `version` option, but the `createIndexes` command expects it to be `v` + options.map(([name, value]) => (name === 'version' ? ['v', value] : [name, value])) ); } @@ -251,7 +269,8 @@ export class CreateIndexesOperation extends CommandOperation { parent: OperationParent, collectionName: string, indexes: IndexDescription[], - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + driverOptions?: DriverIndexesOptions ) { super(parent, options); @@ -264,7 +283,11 @@ 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 + driverOptions?.validateOptions ?? true + ); return { ...validIndexOptions, name, @@ -278,20 +301,28 @@ export class CreateIndexesOperation extends CommandOperation { parent: OperationParent, collectionName: string, indexes: IndexDescription[], - options?: CreateIndexesOptions + options?: CreateIndexesOptions, + driverOptions?: DriverIndexesOptions ): CreateIndexesOperation { - return new CreateIndexesOperation(parent, collectionName, indexes, options); + return new CreateIndexesOperation(parent, collectionName, indexes, options, driverOptions); } static fromIndexSpecification( parent: OperationParent, collectionName: string, indexSpec: IndexSpecification, - options: CreateIndexesOptions = {} + options: CreateIndexesOptions = {}, + driverOptions?: DriverIndexesOptions ): CreateIndexesOperation { const key = constructIndexDescriptionMap(indexSpec); const description: IndexDescription = { ...options, key }; - return new CreateIndexesOperation(parent, collectionName, [description], options); + return new CreateIndexesOperation( + parent, + collectionName, + [description], + options, + driverOptions + ); } override get commandName() { From 38b2f1f1e64b9f073f069e4606193b6b94fd96d2 Mon Sep 17 00:00:00 2001 From: Sean Milligan Date: Mon, 10 Aug 2026 10:58:45 -0700 Subject: [PATCH 2/2] test --- .../create_indexes_option_validation.test.ts | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 test/integration/index-management/create_indexes_option_validation.test.ts 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..9417447900f --- /dev/null +++ b/test/integration/index-management/create_indexes_option_validation.test.ts @@ -0,0 +1,430 @@ +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 `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(); + }); + + 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 () { + // the options bag has been through `resolveOptions`, which injects the BSON options, + // `readPreference` and `timeoutMS` whether or not the user asked for them + 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 } } + ]); + }); + + // TODO(NODE-6893): the options bag is both index options and command options, so turning the + // allowlist off currently lets user-supplied command options through to the index description. + // These capture the intended behaviour and fail today. + it('keeps a user-supplied comment out of the index description', async function () { + await collection.createIndex( + { g: 1 }, + { unique: true, comment: 'a comment' }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'g_1', key: { g: 1 } }]); + expect(sentCommand()).to.have.property('comment', 'a comment'); + }); + + it('keeps a user-supplied maxTimeMS out of the index description', async function () { + await collection.createIndex( + { h: 1 }, + { unique: true, maxTimeMS: 1000 }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'h_1', key: { h: 1 } }]); + expect(sentCommand()).to.have.property('maxTimeMS', 1000); + }); + + it('keeps a user-supplied session out of the index description', async function () { + const session = client.startSession(); + try { + await collection.createIndex( + { 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(); + } + }); + + it('keeps a user-supplied writeConcern out of the index description', async function () { + await collection.createIndex( + { j: 1 }, + { unique: true, writeConcern: { w: 1 } }, + { validateOptions: false } + ); + + expect(sentIndexes()).to.deep.equal([{ unique: true, name: 'j_1', key: { j: 1 } }]); + expect(sentCommand()).to.have.property('writeConcern'); + }); + }); +}); + +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(); + } + }); + }); +});