Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions src/operations/indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ export interface CreateIndexesOptions extends Omit<CommandOperationOptions, 'wri
wildcardProjection?: Document;
/** Specifies that the index should exist on the target collection but should not be used by the query planner when executing operations. (MongoDB 4.4 or higher) */
hidden?: boolean;
/**
* When `true`, index options that are unknown to the driver are passed through to the server
* for validation rather than being filtered out by the driver's allowlist. The server returns
* an error for any option it does not recognize. Only applies to {@link Collection#createIndexes}.
* @defaultValue false
*/
allowUnknownIndexOptions?: boolean;
}

function isSingleIndexTuple(t: unknown): t is [string, IndexDirection] {
Expand Down Expand Up @@ -198,19 +205,24 @@ function constructIndexDescriptionMap(indexSpec: IndexSpecification): Map<string
}

/**
* Receives an index description and returns a modified index description which has had invalid options removed
* from the description and has mapped the `version` option to the `v` option.
* Receives an index description and returns a modified index description which has mapped the
* `version` option to the `v` option.
*
* When `allowUnknownIndexOptions` is `false` (the default), options that are not in the driver's
* `VALID_INDEX_OPTIONS` allowlist are removed from the description. When `true`, all options are
* retained and passed through to the server for validation.
*/
function resolveIndexDescription(
description: IndexDescription
description: IndexDescription,
allowUnknownIndexOptions: boolean
): Omit<ResolvedIndexDescription, 'key'> {
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]))
);
}

Expand Down Expand Up @@ -252,7 +264,8 @@ export class CreateIndexesOperation extends CommandOperation<string[]> {
parent: OperationParent,
collectionName: string,
indexes: IndexDescription[],
options?: CreateIndexesOptions
options: CreateIndexesOptions | undefined,
allowUnknownIndexOptions: boolean
) {
super(parent, options);

Expand All @@ -265,9 +278,9 @@ export class CreateIndexesOperation extends CommandOperation<string[]> {
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 indexOptions = resolveIndexDescription(userIndex, allowUnknownIndexOptions);
return {
...validIndexOptions,
...indexOptions,
name,
key
};
Expand All @@ -281,7 +294,15 @@ export class CreateIndexesOperation extends CommandOperation<string[]> {
indexes: IndexDescription[],
options?: CreateIndexesOptions
): CreateIndexesOperation {
return new CreateIndexesOperation(parent, collectionName, indexes, options);
// `allowUnknownIndexOptions` passthrough is only supported via `createIndexes`, where each
// index is a user-provided description that does not carry command/driver-level options.
return new CreateIndexesOperation(
parent,
collectionName,
indexes,
options,
options?.allowUnknownIndexOptions ?? false
);
}

static fromIndexSpecification(
Expand All @@ -292,7 +313,9 @@ export class CreateIndexesOperation extends CommandOperation<string[]> {
): CreateIndexesOperation {
const key = constructIndexDescriptionMap(indexSpec);
const description: IndexDescription = { ...options, key };
return new CreateIndexesOperation(parent, collectionName, [description], options);
// The allowlist is always enforced for `createIndex` because command/driver-level options are
// merged into the index description above and must not be passed through to the server.
return new CreateIndexesOperation(parent, collectionName, [description], options, false);
}

override get commandName() {
Expand Down
41 changes: 41 additions & 0 deletions test/integration/index_management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
57 changes: 57 additions & 0 deletions test/unit/operations/indexes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, () => {
Expand Down Expand Up @@ -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');
});
});
});
Loading