feat(MongoDB Node): Add search index CRUD API to MongoDB CRUD Node (#16490)

This commit is contained in:
Bailey Pearson
2025-08-04 00:23:40 -06:00
committed by GitHub
parent 557e261d67
commit 1554e76500
3 changed files with 765 additions and 267 deletions

View File

@@ -16,6 +16,7 @@ import type {
INodeType,
INodeTypeDescription,
JsonObject,
IPairedItemData,
} from 'n8n-workflow';
import {
@@ -101,19 +102,18 @@ export class MongoDb implements INodeType {
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const credentials = await this.getCredentials('mongoDb');
const { database, connectionString } = validateAndResolveMongoCredentials(this, credentials);
const client = await connectMongoClient(connectionString, credentials);
const mdb = client.db(database);
let returnData: INodeExecutionData[] = [];
try {
const mdb = client.db(database);
const items = this.getInputData();
const operation = this.getNodeParameter('operation', 0);
const nodeVersion = this.getNode().typeVersion;
let itemsLength = items.length ? 1 : 0;
let fallbackPairedItems;
let fallbackPairedItems: IPairedItemData[] | null = null;
if (nodeVersion >= 1.1) {
itemsLength = items.length;
@@ -331,7 +331,11 @@ export class MongoDb implements INodeType {
try {
// Prepare the data to insert and copy it to be returned
const fields = prepareFields(this.getNodeParameter('fields', 0) as string);
const useDotNotation = this.getNodeParameter('options.useDotNotation', 0, false) as boolean;
const useDotNotation = this.getNodeParameter(
'options.useDotNotation',
0,
false,
) as boolean;
const dateFields = prepareFields(
this.getNodeParameter('options.dateFields', 0, '') as string,
);
@@ -418,7 +422,125 @@ export class MongoDb implements INodeType {
);
}
await client.close();
if (operation === 'listSearchIndexes') {
for (let i = 0; i < itemsLength; i++) {
try {
const collection = this.getNodeParameter('collection', i) as string;
const indexName = (() => {
const name = this.getNodeParameter('indexName', i) as string;
return name.length === 0 ? undefined : name;
})();
const cursor = indexName
? mdb.collection(collection).listSearchIndexes(indexName)
: mdb.collection(collection).listSearchIndexes();
const query = await cursor.toArray();
const result = query.map((json) => ({
json,
pairedItem: fallbackPairedItems ?? [{ item: i }],
}));
returnData.push(...result);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: { error: (error as JsonObject).message },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
continue;
}
throw error;
}
}
}
if (operation === 'dropSearchIndex') {
for (let i = 0; i < itemsLength; i++) {
try {
const collection = this.getNodeParameter('collection', i) as string;
const indexName = this.getNodeParameter('indexNameRequired', i) as string;
await mdb.collection(collection).dropSearchIndex(indexName);
returnData.push({
json: {
[indexName]: true,
},
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: { error: (error as JsonObject).message },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
continue;
}
throw error;
}
}
}
if (operation === 'createSearchIndex') {
for (let i = 0; i < itemsLength; i++) {
try {
const collection = this.getNodeParameter('collection', i) as string;
const indexName = this.getNodeParameter('indexNameRequired', i) as string;
const definition = JSON.parse(
this.getNodeParameter('indexDefinition', i) as string,
) as Record<string, unknown>;
await mdb.collection(collection).createSearchIndex({
name: indexName,
definition,
});
returnData.push({
json: { indexName },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: { error: (error as JsonObject).message },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
continue;
}
throw error;
}
}
}
if (operation === 'updateSearchIndex') {
for (let i = 0; i < itemsLength; i++) {
try {
const collection = this.getNodeParameter('collection', i) as string;
const indexName = this.getNodeParameter('indexNameRequired', i) as string;
const definition = JSON.parse(
this.getNodeParameter('indexDefinition', i) as string,
) as Record<string, unknown>;
await mdb.collection(collection).updateSearchIndex(indexName, definition);
returnData.push({
json: { [indexName]: true },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: { error: (error as JsonObject).message },
pairedItem: fallbackPairedItems ?? [{ item: i }],
});
continue;
}
throw error;
}
}
}
} finally {
await client.close().catch(() => {});
}
return [stringifyObjectIDs(returnData)];
}

View File

@@ -1,11 +1,33 @@
import type { INodeProperties } from 'n8n-workflow';
export const nodeProperties: INodeProperties[] = [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Search Index',
value: 'searchIndexes',
},
{
name: 'Document',
value: 'document',
},
],
default: 'document',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['document'],
},
},
options: [
{
name: 'Aggregate',
@@ -52,7 +74,40 @@ export const nodeProperties: INodeProperties[] = [
],
default: 'find',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['searchIndexes'],
},
},
options: [
{
name: 'Create',
value: 'createSearchIndex',
action: 'Create Search Index',
},
{
name: 'Drop',
value: 'dropSearchIndex',
action: 'Drop Search Index',
},
{
name: 'List',
value: 'listSearchIndexes',
action: 'List Search Indexes',
},
{
name: 'Update',
value: 'updateSearchIndex',
action: 'Update Search Index',
},
],
default: 'createSearchIndex',
},
{
displayName: 'Collection',
name: 'collection',
@@ -75,6 +130,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['aggregate'],
resource: ['document'],
},
},
default: '',
@@ -97,6 +153,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['delete'],
resource: ['document'],
},
},
default: '{}',
@@ -115,6 +172,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['find'],
resource: ['document'],
},
},
default: {},
@@ -175,6 +233,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['find'],
resource: ['document'],
},
},
default: '{}',
@@ -193,6 +252,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['insert'],
resource: ['document'],
},
},
default: '',
@@ -210,6 +270,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['update', 'findOneAndReplace', 'findOneAndUpdate'],
resource: ['document'],
},
},
default: 'id',
@@ -225,6 +286,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['update', 'findOneAndReplace', 'findOneAndUpdate'],
resource: ['document'],
},
},
default: '',
@@ -238,6 +300,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['update', 'findOneAndReplace', 'findOneAndUpdate'],
resource: ['document'],
},
},
default: false,
@@ -250,6 +313,7 @@ export const nodeProperties: INodeProperties[] = [
displayOptions: {
show: {
operation: ['update', 'insert', 'findOneAndReplace', 'findOneAndUpdate'],
resource: ['document'],
},
},
placeholder: 'Add option',
@@ -271,4 +335,74 @@ export const nodeProperties: INodeProperties[] = [
},
],
},
{
displayName: 'Index Name',
name: 'indexName',
type: 'string',
displayOptions: {
show: {
operation: ['listSearchIndexes'],
resource: ['searchIndexes'],
},
},
default: '',
description: 'If provided, only lists indexes with the specified name',
},
{
displayName: 'Index Name',
name: 'indexNameRequired',
type: 'string',
displayOptions: {
show: {
operation: ['createSearchIndex', 'dropSearchIndex', 'updateSearchIndex'],
resource: ['searchIndexes'],
},
},
default: '',
required: true,
description: 'The name of the search index',
},
{
displayName: 'Index Definition',
name: 'indexDefinition',
type: 'json',
displayOptions: {
show: {
operation: ['createSearchIndex', 'updateSearchIndex'],
resource: ['searchIndexes'],
},
},
typeOptions: {
alwaysOpenEditWindow: true,
},
placeholder: '{ "type": "vectorSearch", "definition": {} }',
hint: 'Learn more about search index definitions <a href="https://www.mongodb.com/docs/atlas/atlas-search/index-definitions/">here</a>',
default: '{}',
required: true,
description: 'The search index definition',
},
{
displayName: 'Index Type',
name: 'indexType',
type: 'options',
displayOptions: {
show: {
operation: ['createSearchIndex'],
resource: ['searchIndexes'],
},
},
options: [
{
value: 'vectorSearch',
name: 'Vector Search',
},
{
name: 'Search',
value: 'search',
},
],
default: 'vectorSearch',
required: true,
description: 'The search index index type',
},
];

View File

@@ -0,0 +1,242 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import { Collection, MongoClient } from 'mongodb';
import type { INodeParameters, WorkflowTestData } from 'n8n-workflow';
MongoClient.connect = async function () {
const client = new MongoClient('mongodb://localhost:27017');
return client;
};
function buildWorkflow({
parameters,
expectedResult,
}: { parameters: INodeParameters; expectedResult: unknown[] }) {
const test: WorkflowTestData = {
description: 'should pass test',
input: {
workflowData: {
nodes: [
{
parameters: {},
id: '8b7bb389-e4ef-424a-bca1-e7ead60e43eb',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [740, 380],
},
{
parameters,
id: '8b7bb389-e4ef-424a-bca1-e7ead60e43ec',
name: 'mongoDb',
type: 'n8n-nodes-base.mongoDb',
typeVersion: 1.2,
position: [1260, 360],
credentials: {
mongoDb: {
id: 'mongodb://localhost:27017',
name: 'Connection String',
},
},
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [
[
{
node: 'mongoDb',
type: 'main',
index: 0,
},
],
],
},
},
},
},
output: {
assertBinaryData: true,
nodeData: {
mongoDb: [expectedResult],
},
},
};
return test;
}
describe('MongoDB CRUD Node', () => {
const testHarness = new NodeTestHarness();
describe('createSearchIndex operation', () => {
const spy: jest.SpyInstance = jest.spyOn(Collection.prototype, 'createSearchIndex');
afterAll(() => jest.restoreAllMocks());
beforeAll(() => {
spy.mockResolvedValueOnce('my-index');
});
testHarness.setupTest(
buildWorkflow({
parameters: {
operation: 'createSearchIndex',
resource: 'searchIndexes',
collection: 'foo',
indexType: 'vectorSearch',
indexDefinition: JSON.stringify({ mappings: {} }),
indexNameRequired: 'my-index',
},
expectedResult: [{ json: { indexName: 'my-index' } }],
}),
);
it('calls the spy with the expected arguments', function () {
expect(spy).toBeCalledWith({ name: 'my-index', definition: { mappings: {} } });
});
});
describe('listSearchIndexes operation', () => {
describe('no index name provided', function () {
let spy: jest.SpyInstance;
beforeAll(() => {
spy = jest.spyOn(Collection.prototype, 'listSearchIndexes');
const mockCursor = {
toArray: async () => [],
};
spy.mockReturnValue(mockCursor);
});
afterAll(() => jest.restoreAllMocks());
testHarness.setupTest(
buildWorkflow({
parameters: {
resource: 'searchIndexes',
operation: 'listSearchIndexes',
collection: 'foo',
},
expectedResult: [],
}),
);
it('calls the spy with the expected arguments', function () {
expect(spy).toHaveBeenCalledWith();
});
});
describe('index name provided', function () {
let spy: jest.SpyInstance;
beforeAll(() => {
spy = jest.spyOn(Collection.prototype, 'listSearchIndexes');
const mockCursor = {
toArray: async () => [],
};
spy.mockReturnValue(mockCursor);
});
afterAll(() => jest.restoreAllMocks());
testHarness.setupTest(
buildWorkflow({
parameters: {
resource: 'searchIndexes',
operation: 'listSearchIndexes',
collection: 'foo',
indexName: 'my-index',
},
expectedResult: [],
}),
);
it('calls the spy with the expected arguments', function () {
expect(spy).toHaveBeenCalledWith('my-index');
});
});
describe('return values are transformed into the expected return type', function () {
let spy: jest.SpyInstance;
beforeAll(() => {
spy = jest.spyOn(Collection.prototype, 'listSearchIndexes');
const mockCursor = {
toArray: async () => [{ name: 'my-index' }, { name: 'my-index-2' }],
};
spy.mockReturnValue(mockCursor);
});
afterAll(() => jest.restoreAllMocks());
testHarness.setupTest(
buildWorkflow({
parameters: {
operation: 'listSearchIndexes',
resource: 'searchIndexes',
collection: 'foo',
indexName: 'my-index',
},
expectedResult: [
{
json: { name: 'my-index' },
},
{
json: { name: 'my-index-2' },
},
],
}),
);
});
});
describe('dropSearchIndex operation', () => {
let spy: jest.SpyInstance;
afterAll(() => jest.restoreAllMocks());
beforeAll(() => {
spy = jest.spyOn(Collection.prototype, 'dropSearchIndex');
spy.mockResolvedValueOnce(undefined);
});
testHarness.setupTest(
buildWorkflow({
parameters: {
operation: 'dropSearchIndex',
resource: 'searchIndexes',
collection: 'foo',
indexNameRequired: 'my-index',
},
expectedResult: [{ json: { 'my-index': true } }],
}),
);
it('calls the spy with the expected arguments', function () {
expect(spy).toBeCalledWith('my-index');
});
});
describe('updateSearchIndex operation', () => {
let spy: jest.SpyInstance;
afterAll(() => jest.restoreAllMocks());
beforeAll(() => {
spy = jest.spyOn(Collection.prototype, 'updateSearchIndex');
spy.mockResolvedValueOnce(undefined);
});
testHarness.setupTest(
buildWorkflow({
parameters: {
operation: 'updateSearchIndex',
resource: 'searchIndexes',
collection: 'foo',
indexNameRequired: 'my-index',
indexDefinition: JSON.stringify({
mappings: {
dynamic: true,
},
}),
},
expectedResult: [{ json: { 'my-index': true } }],
}),
);
it('calls the spy with the expected arguments', function () {
expect(spy).toBeCalledWith('my-index', { mappings: { dynamic: true } });
});
});
});