From dd2b55e352b21ea373a1f62880953ba125c1f48c Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Mon, 12 Sep 2022 15:52:06 +0200 Subject: [PATCH 1/6] test: Add more tests to active executions file (#4071) * test: Add more tests to active executions file --- .../cli/test/unit/ActiveExecutions.test.ts | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/unit/ActiveExecutions.test.ts b/packages/cli/test/unit/ActiveExecutions.test.ts index 7be21960ef1c4..452ee4e1b0c46 100644 --- a/packages/cli/test/unit/ActiveExecutions.test.ts +++ b/packages/cli/test/unit/ActiveExecutions.test.ts @@ -2,7 +2,7 @@ import { ActiveExecutions, IWorkflowExecutionDataProcess, Db } from '../../src'; import { mocked } from 'jest-mock'; import PCancelable from 'p-cancelable'; import { v4 as uuid } from 'uuid'; -import type { IRun } from 'n8n-workflow'; +import { createDeferredPromise, IDeferredPromise, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; const FAKE_EXECUTION_ID = '15'; const FAKE_SECOND_EXECUTION_ID = '20'; @@ -37,6 +37,7 @@ describe('ActiveExecutions', () => { test('Should add execution to active execution list', async () => { const newExecution = mockExecutionData(); const executionId = await activeExecutions.add(newExecution); + expect(executionId).toBe(FAKE_EXECUTION_ID); expect(activeExecutions.getActiveExecutions().length).toBe(1); expect(mocked(Db.collections.Execution.save)).toHaveBeenCalledTimes(1); @@ -46,6 +47,7 @@ describe('ActiveExecutions', () => { test('Should update execution if add is called with execution ID', async () => { const newExecution = mockExecutionData(); const executionId = await activeExecutions.add(newExecution, undefined, FAKE_SECOND_EXECUTION_ID); + expect(executionId).toBe(FAKE_SECOND_EXECUTION_ID); expect(activeExecutions.getActiveExecutions().length).toBe(1); expect(mocked(Db.collections.Execution.save)).toHaveBeenCalledTimes(0); @@ -54,6 +56,7 @@ describe('ActiveExecutions', () => { test('Should fail attaching execution to invalid executionId', async () => { const deferredPromise = mockCancelablePromise(); + expect(() => { activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise); }).toThrow(); @@ -63,7 +66,59 @@ describe('ActiveExecutions', () => { const newExecution = mockExecutionData(); await activeExecutions.add(newExecution, undefined, FAKE_EXECUTION_ID); const deferredPromise = mockCancelablePromise(); - activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise); + + expect(() => activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise)).not.toThrow(); + }); + + test('Should attach and resolve response promise to existing execution', async () => { + const newExecution = mockExecutionData(); + await activeExecutions.add(newExecution, undefined, FAKE_EXECUTION_ID); + const deferredPromise = await mockDeferredPromise(); + activeExecutions.attachResponsePromise(FAKE_EXECUTION_ID, deferredPromise); + const fakeResponse = {data: {resultData: {runData: {}}}}; + activeExecutions.resolveResponsePromise(FAKE_EXECUTION_ID, fakeResponse); + + expect(deferredPromise.promise()).resolves.toEqual(fakeResponse); + }); + + test('Should remove an existing execution', async () => { + const newExecution = mockExecutionData(); + const executionId = await activeExecutions.add(newExecution); + activeExecutions.remove(executionId); + + expect(activeExecutions.getActiveExecutions().length).toBe(0); + }); + + test('Should resolve post execute promise on removal', async () => { + const newExecution = mockExecutionData(); + const executionId = await activeExecutions.add(newExecution); + const postExecutePromise = activeExecutions.getPostExecutePromise(executionId); + // Force the above to be executed since we cannot await it + await new Promise((res) => { + setTimeout(res, 100); + }); + const fakeOutput = mockFullRunData(); + activeExecutions.remove(executionId, fakeOutput); + + expect(postExecutePromise).resolves.toEqual(fakeOutput); + }); + + test('Should throw error when trying to create a promise with invalid execution', async() => { + expect( + activeExecutions.getPostExecutePromise(FAKE_EXECUTION_ID) + ).rejects.toThrow(); + }); + + test('Should call function to cancel execution when asked to stop', async () => { + const newExecution = mockExecutionData(); + const executionId = await activeExecutions.add(newExecution); + const cancelExecution = jest.fn(); + const cancellablePromise = mockCancelablePromise(); + cancellablePromise.cancel = cancelExecution; + activeExecutions.attachWorkflowExecution(executionId, cancellablePromise); + activeExecutions.stopExecution(executionId); + + expect(cancelExecution).toHaveBeenCalledTimes(1); }); }); @@ -83,9 +138,25 @@ function mockExecutionData(): IWorkflowExecutionDataProcess { } } +function mockFullRunData(): IRun { + return { + data: { + resultData: { + runData: {} + } + }, + mode: 'manual', + startedAt: new Date(), + }; +} + function mockCancelablePromise(): PCancelable { return new PCancelable(async (resolve) => { resolve(); }); } +function mockDeferredPromise(): Promise> { + return createDeferredPromise(); +} + From 84b56eb48e727389189c517598aadadd6f2ccf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 12 Sep 2022 16:31:49 +0200 Subject: [PATCH 2/6] fix(cli): avoid scanning unnecessary directories on windows (#4082) fixes #4007 Ticket: N8N-4603 --- packages/cli/commands/import/credentials.ts | 2 +- packages/cli/commands/import/workflow.ts | 2 +- packages/cli/src/LoadNodesAndCredentials.ts | 4 +++- .../nodes-base/nodes/ReadBinaryFiles/ReadBinaryFiles.node.ts | 1 - 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/commands/import/credentials.ts b/packages/cli/commands/import/credentials.ts index da6453a650472..9a0170ba59285 100644 --- a/packages/cli/commands/import/credentials.ts +++ b/packages/cli/commands/import/credentials.ts @@ -95,7 +95,7 @@ export class ImportCredentialsCommand extends Command { inputPath = inputPath.replace(/\/$/g, ''); - const files = await glob(`${inputPath}/*.json`); + const files = await glob('*.json', { cwd: inputPath }); totalImported = files.length; diff --git a/packages/cli/commands/import/workflow.ts b/packages/cli/commands/import/workflow.ts index 34a5c97b8cfb5..5b08a4d0ed035 100644 --- a/packages/cli/commands/import/workflow.ts +++ b/packages/cli/commands/import/workflow.ts @@ -117,7 +117,7 @@ export class ImportWorkflowsCommand extends Command { inputPath = inputPath.replace(/\/$/g, ''); - const files = await glob(`${inputPath}/*.json`); + const files = await glob('*.json', { cwd: inputPath }); totalImported = files.length; diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index f302638d07156..7062aad8b05eb 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -493,7 +493,9 @@ class LoadNodesAndCredentialsClass { * @returns {Promise} */ async loadDataFromDirectory(setPackageName: string, directory: string): Promise { - const files = await glob(path.join(directory, '**/*.@(node|credentials).js')); + const files = await glob('**/*.@(node|credentials).js', { + cwd: directory, + }); for (const filePath of files) { const [fileName, type] = path.parse(filePath).name.split('.'); diff --git a/packages/nodes-base/nodes/ReadBinaryFiles/ReadBinaryFiles.node.ts b/packages/nodes-base/nodes/ReadBinaryFiles/ReadBinaryFiles.node.ts index 3741d9344ecdf..4c9ec040e3339 100644 --- a/packages/nodes-base/nodes/ReadBinaryFiles/ReadBinaryFiles.node.ts +++ b/packages/nodes-base/nodes/ReadBinaryFiles/ReadBinaryFiles.node.ts @@ -1,7 +1,6 @@ import { IExecuteFunctions } from 'n8n-core'; import { INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow'; import glob from 'fast-glob'; -import path from 'path'; import { readFile as fsReadFile } from 'fs/promises'; From de4ca3b0cc63b7a568810424fb3deeb34aa4b676 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 12 Sep 2022 18:07:56 +0300 Subject: [PATCH 3/6] test(UM tests): add missing UM tests n8n-3648 (#4077) * :zap: added test for users reinvite --- .../cli/test/integration/users.api.test.ts | 51 +++++++++++++++---- .../nodes/Function/Function.node.ts | 2 +- .../nodes/FunctionItem/FunctionItem.node.ts | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 6207264618035..2d1143a7b8228 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -20,6 +20,9 @@ import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; import { compareHash } from '../../src/UserManagement/UserManagementHelper'; +import * as UserManagementMailer from '../../src/UserManagement/email/UserManagementMailer'; +import { NodeMailer } from '../../src/UserManagement/email/NodeMailer'; + jest.mock('../../src/telemetry'); jest.mock('../../src/UserManagement/email/NodeMailer'); @@ -542,19 +545,45 @@ test('POST /users should ignore an empty payload', async () => { expect(users.length).toBe(1); }); -// TODO: /users/:id/reinvite route tests missing +test('POST /users/:id/reinvite should send reinvite, but fail if user already accepted invite', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); -// TODO: UserManagementMailer is a singleton - cannot reinstantiate with wrong creds -// test('POST /users should error for wrong SMTP config', async () => { -// const owner = await Db.collections.User.findOneOrFail(); -// const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + config.set('userManagement.emails.mode', 'smtp'); -// config.set('userManagement.emails.mode', 'smtp'); -// config.set('userManagement.emails.smtp.host', 'XYZ'); // break SMTP config + // those configs are needed to make sure the reinvite email is sent,because of this check isEmailSetUp() + config.set('userManagement.emails.smtp.host', 'host'); + config.set('userManagement.emails.smtp.auth.user', 'user'); + config.set('userManagement.emails.smtp.auth.pass', 'pass'); -// const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + const email = randomEmail(); + const payload = [{ email }]; + const response = await authOwnerAgent.post('/users').send(payload); + + expect(response.statusCode).toBe(200); + + const { data } = response.body; + const invitedUserId = data[0].user.id; + const reinviteResponse = await authOwnerAgent.post(`/users/${invitedUserId}/reinvite`); -// const response = await authOwnerAgent.post('/users').send(payload); + expect(reinviteResponse.statusCode).toBe(200); -// expect(response.statusCode).toBe(500); -// }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const reinviteMemberResponse = await authOwnerAgent.post(`/users/${member.id}/reinvite`); + + expect(reinviteMemberResponse.statusCode).toBe(400); +}); + +test('UserManagementMailer expect NodeMailer.verifyConnection have been called', async () => { + jest.spyOn(NodeMailer.prototype, 'verifyConnection').mockImplementation(async () => {}); + + // NodeMailer.verifyConnection called 1 time + const userManagementMailer = UserManagementMailer.getInstance(); + // NodeMailer.verifyConnection called 2 time + (await userManagementMailer).verifyConnection(); + + expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(2); + + // @ts-ignore + NodeMailer.prototype.verifyConnection.mockRestore(); +}); diff --git a/packages/nodes-base/nodes/Function/Function.node.ts b/packages/nodes-base/nodes/Function/Function.node.ts index ea2895a1e1749..2b595fe363954 100644 --- a/packages/nodes-base/nodes/Function/Function.node.ts +++ b/packages/nodes-base/nodes/Function/Function.node.ts @@ -95,7 +95,7 @@ return items;`, if (item?.binary && item?.index !== undefined && item?.index !== null) { for (const binaryPropertyName of Object.keys(item.binary)) { item.binary[binaryPropertyName].data = ( - await this.helpers.getBinaryDataBuffer(item.index, binaryPropertyName) + await this.helpers.getBinaryDataBuffer(item.index as number, binaryPropertyName) )?.toString('base64'); } } diff --git a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts index 1d5b7f942d2f5..184378870a661 100644 --- a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts +++ b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts @@ -113,7 +113,7 @@ return item;`, if (item?.binary && item?.index !== undefined && item?.index !== null) { for (const binaryPropertyName of Object.keys(item.binary)) { item.binary[binaryPropertyName].data = ( - await this.helpers.getBinaryDataBuffer(item.index, binaryPropertyName) + await this.helpers.getBinaryDataBuffer(item.index as number, binaryPropertyName) )?.toString('base64'); } } From 3de0e228cb78f292ead4d0103040d2c00943deae Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Mon, 12 Sep 2022 16:30:47 +0100 Subject: [PATCH 4/6] fix(core): fix issue with returnJsonArray helper breaking nodes that return no data --- packages/core/src/NodeExecuteFunctions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 3e621f7063254..0459d61a7298a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1326,7 +1326,7 @@ export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExe } jsonData.forEach((data: IDataObject & { json?: IDataObject }) => { - if (data.json) { + if (data?.json) { // We already have the JSON key so avoid double wrapping returnData.push({ ...data, json: data.json }); } else { From b6c1187922ab6552e303c98341c5732ffa96c55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 12 Sep 2022 18:48:50 +0200 Subject: [PATCH 5/6] fix(cli): load nodes and credentials on windows using the correct file-path (#4084) --- packages/cli/src/CommunityNodes/helpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts index cb634a4aa0399..eb7f1b612e1fa 100644 --- a/packages/cli/src/CommunityNodes/helpers.ts +++ b/packages/cli/src/CommunityNodes/helpers.ts @@ -238,6 +238,9 @@ export function isNpmError(error: unknown): error is { code: number; stdout: str const context = createContext({ require }); export const loadClassInIsolation = (filePath: string, className: string) => { + if (process.platform === 'win32') { + filePath = filePath.replace(/\\/g, '/'); + } const script = new Script(`new (require('${filePath}').${className})()`); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return script.runInContext(context); From f1a569791d5289ced8ac78d97452f6ad5bf8d1b8 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Tue, 13 Sep 2022 08:59:49 +0300 Subject: [PATCH 6/6] feat(Merge Node): overhaul of merge node --- .../nodes/ItemLists/ItemLists.node.ts | 21 + .../nodes-base/nodes/Merge/Merge.node.json | 14 +- packages/nodes-base/nodes/Merge/Merge.node.ts | 481 +---------------- .../nodes-base/nodes/Merge/v1/MergeV1.node.ts | 482 +++++++++++++++++ .../nodes/Merge/v2/GenericFunctions.ts | 364 +++++++++++++ .../nodes-base/nodes/Merge/v2/MergeV2.node.ts | 511 ++++++++++++++++++ .../nodes/Merge/v2/OptionsDescription.ts | 198 +++++++ 7 files changed, 1599 insertions(+), 472 deletions(-) create mode 100644 packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts create mode 100644 packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts create mode 100644 packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts diff --git a/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts b/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts index 11f92d0834221..c4d4d4c499d02 100644 --- a/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts +++ b/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts @@ -150,6 +150,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'A field in the input items to aggregate together', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, ], }, @@ -206,6 +209,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'The name of a field in the input items to aggregate together', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, { displayName: 'Rename Field', @@ -293,6 +299,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'A field in the input to exclude from the object in output array', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, ], }, @@ -326,6 +335,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'Specify fields that will be included in output array', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, ], }, @@ -394,6 +406,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'A field in the input to exclude from the comparison', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, ], }, @@ -426,6 +441,9 @@ export class ItemLists implements INodeType { type: 'string', default: '', description: 'A field in the input to add to the comparison', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, ], }, @@ -479,6 +497,9 @@ export class ItemLists implements INodeType { required: true, default: '', description: 'The field to sort by', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', }, { displayName: 'Order', diff --git a/packages/nodes-base/nodes/Merge/Merge.node.json b/packages/nodes-base/nodes/Merge/Merge.node.json index 8ddb08802599a..a4f862c9ade16 100644 --- a/packages/nodes-base/nodes/Merge/Merge.node.json +++ b/packages/nodes-base/nodes/Merge/Merge.node.json @@ -2,9 +2,7 @@ "node": "n8n-nodes-base.merge", "nodeVersion": "1.0", "codexVersion": "1.0", - "categories": [ - "Core Nodes" - ], + "categories": ["Core Nodes"], "resources": { "primaryDocumentation": [ { @@ -43,14 +41,8 @@ } ] }, - "alias": [ - "Join", - "Concatenate", - "Wait" - ], + "alias": ["Join", "Concatenate", "Wait"], "subcategories": { - "Core Nodes": [ - "Flow" - ] + "Core Nodes": ["Flow"] } } diff --git a/packages/nodes-base/nodes/Merge/Merge.node.ts b/packages/nodes-base/nodes/Merge/Merge.node.ts index 6335240fef098..103d03c90d217 100644 --- a/packages/nodes-base/nodes/Merge/Merge.node.ts +++ b/packages/nodes-base/nodes/Merge/Merge.node.ts @@ -1,469 +1,28 @@ -import { get } from 'lodash'; +import { INodeTypeBaseDescription, INodeVersionedType } from 'n8n-workflow'; -import { IExecuteFunctions } from 'n8n-core'; -import { - GenericValue, - INodeExecutionData, - INodeType, - INodeTypeDescription, - IPairedItemData, -} from 'n8n-workflow'; +import { NodeVersionedType } from '../../src/NodeVersionedType'; -export class Merge implements INodeType { - description: INodeTypeDescription = { - displayName: 'Merge', - name: 'merge', - icon: 'fa:code-branch', - group: ['transform'], - version: 1, - subtitle: '={{$parameter["mode"]}}', - description: 'Merges data of multiple streams once data from both is available', - defaults: { - name: 'Merge', - color: '#00bbcc', - }, - inputs: ['main', 'main'], - outputs: ['main'], - inputNames: ['Input 1', 'Input 2'], - properties: [ - { - displayName: 'Mode', - name: 'mode', - type: 'options', - options: [ - { - name: 'Append', - value: 'append', - description: - 'Combines data of both inputs. The output will contain items of input 1 and input 2.', - }, - { - name: 'Keep Key Matches', - value: 'keepKeyMatches', - description: 'Keeps data of input 1 if it does find a match with data of input 2', - }, - { - name: 'Merge By Index', - value: 'mergeByIndex', - description: - 'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on the index of the items. So first item of input 1 will be merged with first item of input 2 and so on.', - }, - { - name: 'Merge By Key', - value: 'mergeByKey', - description: - 'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on a defined key.', - }, - { - name: 'Multiplex', - value: 'multiplex', - description: - 'Merges each value of one input with each value of the other input. The output will contain (m * n) items where (m) and (n) are lengths of the inputs.', - }, - { - name: 'Pass-Through', - value: 'passThrough', - description: - 'Passes through data of one input. The output will contain only items of the defined input.', - }, - { - name: 'Remove Key Matches', - value: 'removeKeyMatches', - description: 'Keeps data of input 1 if it does NOT find match with data of input 2', - }, - { - name: 'Wait', - value: 'wait', - description: - 'Waits till data of both inputs is available and will then output a single empty item. Source Nodes must connect to both Input 1 and 2. This node only supports 2 Sources, if you need more Sources, connect multiple Merge nodes in series. This node will not output any data.', - }, - ], - default: 'append', - description: 'How data of branches should be merged', - }, - { - displayName: 'Join', - name: 'join', - type: 'options', - displayOptions: { - show: { - mode: ['mergeByIndex'], - }, - }, - options: [ - { - name: 'Inner Join', - value: 'inner', - description: - 'Merges as many items as both inputs contain. (Example: Input1 = 5 items, Input2 = 3 items | Output will contain 3 items).', - }, - { - name: 'Left Join', - value: 'left', - description: - 'Merges as many items as first input contains. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 3 items).', - }, - { - name: 'Outer Join', - value: 'outer', - description: - 'Merges as many items as input contains with most items. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 5 items).', - }, - ], - default: 'left', - description: - 'How many items the output will contain if inputs contain different amount of items', - }, - { - displayName: 'Property Input 1', - name: 'propertyName1', - type: 'string', - default: '', - hint: 'The name of the field as text (e.g. “id”)', - required: true, - displayOptions: { - show: { - mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'], - }, - }, - description: 'Name of property which decides which items to merge of input 1', - }, - { - displayName: 'Property Input 2', - name: 'propertyName2', - type: 'string', - default: '', - hint: 'The name of the field as text (e.g. “id”)', - required: true, - displayOptions: { - show: { - mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'], - }, - }, - description: 'Name of property which decides which items to merge of input 2', - }, - { - displayName: 'Output Data', - name: 'output', - type: 'options', - displayOptions: { - show: { - mode: ['passThrough'], - }, - }, - options: [ - { - name: 'Input 1', - value: 'input1', - }, - { - name: 'Input 2', - value: 'input2', - }, - ], - default: 'input1', - description: 'Defines of which input the data should be used as output of node', - }, - { - displayName: 'Overwrite', - name: 'overwrite', - type: 'options', - displayOptions: { - show: { - mode: ['mergeByKey'], - }, - }, - options: [ - { - name: 'Always', - value: 'always', - description: 'Always overwrites everything', - }, - { - name: 'If Blank', - value: 'blank', - description: 'Overwrites only values of "null", "undefined" or empty string', - }, - { - name: 'If Missing', - value: 'undefined', - description: 'Only adds values which do not exist yet', - }, - ], - default: 'always', - description: 'Select when to overwrite the values from Input1 with values from Input 2', - }, - ], - }; +import { MergeV1 } from './v1/MergeV1.node'; - async execute(this: IExecuteFunctions): Promise { - const returnData: INodeExecutionData[] = []; +import { MergeV2 } from './v2/MergeV2.node'; - const mode = this.getNodeParameter('mode', 0) as string; +export class Merge extends NodeVersionedType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Merge', + name: 'merge', + icon: 'fa:code-branch', + group: ['transform'], + subtitle: '={{$parameter["mode"]}}', + description: 'Merges data of multiple streams once data from both is available', + defaultVersion: 2, + }; - if (mode === 'append') { - // Simply appends the data - for (let i = 0; i < 2; i++) { - returnData.push.apply(returnData, this.getInputData(i)); - } - } else if (mode === 'mergeByIndex') { - // Merges data by index + const nodeVersions: INodeVersionedType['nodeVersions'] = { + 1: new MergeV1(baseDescription), + 2: new MergeV2(baseDescription), + }; - const join = this.getNodeParameter('join', 0) as string; - - const dataInput1 = this.getInputData(0); - const dataInput2 = this.getInputData(1); - - if (dataInput1 === undefined || dataInput1.length === 0) { - if (['inner', 'left'].includes(join)) { - // When "inner" or "left" join return empty if first - // input does not contain any items - return [returnData]; - } - - // For "outer" return data of second input - return [dataInput2]; - } - - if (dataInput2 === undefined || dataInput2.length === 0) { - if (['left', 'outer'].includes(join)) { - // When "left" or "outer" join return data of first input - return [dataInput1]; - } - - // For "inner" return empty - return [returnData]; - } - - // Default "left" - let numEntries = dataInput1.length; - if (join === 'inner') { - numEntries = Math.min(dataInput1.length, dataInput2.length); - } else if (join === 'outer') { - numEntries = Math.max(dataInput1.length, dataInput2.length); - } - - let newItem: INodeExecutionData; - for (let i = 0; i < numEntries; i++) { - if (i >= dataInput1.length) { - returnData.push(dataInput2[i]); - continue; - } - if (i >= dataInput2.length) { - returnData.push(dataInput1[i]); - continue; - } - - newItem = { - json: {}, - pairedItem: [ - dataInput1[i].pairedItem as IPairedItemData, - dataInput2[i].pairedItem as IPairedItemData, - ], - }; - - if (dataInput1[i].binary !== undefined) { - newItem.binary = {}; - // Create a shallow copy of the binary data so that the old - // data references which do not get changed still stay behind - // but the incoming data does not get changed. - Object.assign(newItem.binary, dataInput1[i].binary); - } - - // Create also a shallow copy of the json data - Object.assign(newItem.json, dataInput1[i].json); - - // Copy json data - for (const key of Object.keys(dataInput2[i].json)) { - newItem.json[key] = dataInput2[i].json[key]; - } - - // Copy binary data - if (dataInput2[i].binary !== undefined) { - if (newItem.binary === undefined) { - newItem.binary = {}; - } - - for (const key of Object.keys(dataInput2[i].binary!)) { - newItem.binary[key] = dataInput2[i].binary![key] ?? newItem.binary[key]; - } - } - - returnData.push(newItem); - } - } else if (mode === 'multiplex') { - const dataInput1 = this.getInputData(0); - const dataInput2 = this.getInputData(1); - - if (!dataInput1 || !dataInput2) { - return [returnData]; - } - - let entry1: INodeExecutionData; - let entry2: INodeExecutionData; - - for (entry1 of dataInput1) { - for (entry2 of dataInput2) { - returnData.push({ - json: { - ...entry1.json, - ...entry2.json, - }, - pairedItem: [ - entry1.pairedItem as IPairedItemData, - entry2.pairedItem as IPairedItemData, - ], - }); - } - } - return [returnData]; - } else if (['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'].includes(mode)) { - const dataInput1 = this.getInputData(0); - if (!dataInput1) { - // If it has no input data from first input return nothing - return [returnData]; - } - - const propertyName1 = this.getNodeParameter('propertyName1', 0) as string; - const propertyName2 = this.getNodeParameter('propertyName2', 0) as string; - const overwrite = this.getNodeParameter('overwrite', 0, 'always') as string; - - const dataInput2 = this.getInputData(1); - if (!dataInput2 || !propertyName1 || !propertyName2) { - // Second input does not have any data or the property names are not defined - if (mode === 'keepKeyMatches') { - // For "keepKeyMatches" return nothing - return [returnData]; - } - - // For "mergeByKey" and "removeKeyMatches" return the data from the first input - return [dataInput1]; - } - - // Get the data to copy - const copyData: { - [key: string]: INodeExecutionData; - } = {}; - let entry: INodeExecutionData; - for (entry of dataInput2) { - const key = get(entry.json, propertyName2); - if (!entry.json || !key) { - // Entry does not have the property so skip it - continue; - } - - copyData[key as string] = entry; - } - - // Copy data on entries or add matching entries - let referenceValue: GenericValue; - let key: string; - for (entry of dataInput1) { - referenceValue = get(entry.json, propertyName1); - - if (referenceValue === undefined) { - // Entry does not have the property - - if (mode === 'removeKeyMatches') { - // For "removeKeyMatches" add item - returnData.push(entry); - } - - // For "mergeByKey" and "keepKeyMatches" skip item - continue; - } - - if (!['string', 'number'].includes(typeof referenceValue)) { - if (referenceValue !== null && referenceValue.constructor.name !== 'Data') { - // Reference value is not of comparable type - - if (mode === 'removeKeyMatches') { - // For "removeKeyMatches" add item - returnData.push(entry); - } - - // For "mergeByKey" and "keepKeyMatches" skip item - continue; - } - } - - if (typeof referenceValue === 'number') { - referenceValue = referenceValue.toString(); - } else if (referenceValue !== null && referenceValue.constructor.name === 'Date') { - referenceValue = (referenceValue as Date).toISOString(); - } - - if (copyData.hasOwnProperty(referenceValue as string)) { - // Item with reference value got found - - if (['null', 'undefined'].includes(typeof referenceValue)) { - // The reference value is null or undefined - - if (mode === 'removeKeyMatches') { - // For "removeKeyMatches" add item - returnData.push(entry); - } - - // For "mergeByKey" and "keepKeyMatches" skip item - continue; - } - - // Match exists - if (mode === 'removeKeyMatches') { - // For "removeKeyMatches" we can skip the item as it has a match - continue; - } else if (mode === 'mergeByKey') { - // Copy the entry as the data gets changed - entry = JSON.parse(JSON.stringify(entry)); - - for (key of Object.keys(copyData[referenceValue as string].json)) { - if (key === propertyName2) { - continue; - } - - // TODO: Currently only copies json data and no binary one - const value = copyData[referenceValue as string].json[key]; - if ( - overwrite === 'always' || - (overwrite === 'undefined' && !entry.json.hasOwnProperty(key)) || - (overwrite === 'blank' && [null, undefined, ''].includes(entry.json[key] as string)) - ) { - entry.json[key] = value; - } - } - } else { - // For "keepKeyMatches" we add it as it is - returnData.push(entry); - continue; - } - } else { - // No item for reference value got found - if (mode === 'removeKeyMatches') { - // For "removeKeyMatches" we can add it if not match got found - returnData.push(entry); - continue; - } - } - - if (mode === 'mergeByKey') { - // For "mergeByKey" we always add the entry anyway but then the unchanged one - returnData.push(entry); - } - } - - return [returnData]; - } else if (mode === 'passThrough') { - const output = this.getNodeParameter('output', 0) as string; - - if (output === 'input1') { - returnData.push.apply(returnData, this.getInputData(0)); - } else { - returnData.push.apply(returnData, this.getInputData(1)); - } - } else if (mode === 'wait') { - returnData.push({ json: {} }); - } - - return [returnData]; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts b/packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts new file mode 100644 index 0000000000000..26ec817648c21 --- /dev/null +++ b/packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts @@ -0,0 +1,482 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { get } from 'lodash'; + +import { IExecuteFunctions } from 'n8n-core'; + +import { + GenericValue, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + IPairedItemData, +} from 'n8n-workflow'; + +const versionDescription: INodeTypeDescription = { + displayName: 'Merge', + name: 'merge', + icon: 'fa:code-branch', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["mode"]}}', + description: 'Merges data of multiple streams once data from both is available', + defaults: { + name: 'Merge', + color: '#00bbcc', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: ['main', 'main'], + outputs: ['main'], + inputNames: ['Input 1', 'Input 2'], + properties: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Append', + value: 'append', + description: + 'Combines data of both inputs. The output will contain items of input 1 and input 2.', + }, + { + name: 'Keep Key Matches', + value: 'keepKeyMatches', + description: 'Keeps data of input 1 if it does find a match with data of input 2', + }, + { + name: 'Merge By Index', + value: 'mergeByIndex', + description: + 'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on the index of the items. So first item of input 1 will be merged with first item of input 2 and so on.', + }, + { + name: 'Merge By Key', + value: 'mergeByKey', + description: + 'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on a defined key.', + }, + { + name: 'Multiplex', + value: 'multiplex', + description: + 'Merges each value of one input with each value of the other input. The output will contain (m * n) items where (m) and (n) are lengths of the inputs.', + }, + { + name: 'Pass-Through', + value: 'passThrough', + description: + 'Passes through data of one input. The output will contain only items of the defined input.', + }, + { + name: 'Remove Key Matches', + value: 'removeKeyMatches', + description: 'Keeps data of input 1 if it does NOT find match with data of input 2', + }, + { + name: 'Wait', + value: 'wait', + description: + 'Waits till data of both inputs is available and will then output a single empty item. Source Nodes must connect to both Input 1 and 2. This node only supports 2 Sources, if you need more Sources, connect multiple Merge nodes in series. This node will not output any data.', + }, + ], + default: 'append', + description: 'How data of branches should be merged', + }, + { + displayName: 'Join', + name: 'join', + type: 'options', + displayOptions: { + show: { + mode: ['mergeByIndex'], + }, + }, + options: [ + { + name: 'Inner Join', + value: 'inner', + description: + 'Merges as many items as both inputs contain. (Example: Input1 = 5 items, Input2 = 3 items | Output will contain 3 items).', + }, + { + name: 'Left Join', + value: 'left', + description: + 'Merges as many items as first input contains. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 3 items).', + }, + { + name: 'Outer Join', + value: 'outer', + description: + 'Merges as many items as input contains with most items. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 5 items).', + }, + ], + default: 'left', + description: + 'How many items the output will contain if inputs contain different amount of items', + }, + { + displayName: 'Property Input 1', + name: 'propertyName1', + type: 'string', + default: '', + hint: 'The name of the field as text (e.g. “id”)', + required: true, + displayOptions: { + show: { + mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'], + }, + }, + description: 'Name of property which decides which items to merge of input 1', + }, + { + displayName: 'Property Input 2', + name: 'propertyName2', + type: 'string', + default: '', + hint: 'The name of the field as text (e.g. “id”)', + required: true, + displayOptions: { + show: { + mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'], + }, + }, + description: 'Name of property which decides which items to merge of input 2', + }, + { + displayName: 'Output Data', + name: 'output', + type: 'options', + displayOptions: { + show: { + mode: ['passThrough'], + }, + }, + options: [ + { + name: 'Input 1', + value: 'input1', + }, + { + name: 'Input 2', + value: 'input2', + }, + ], + default: 'input1', + description: 'Defines of which input the data should be used as output of node', + }, + { + displayName: 'Overwrite', + name: 'overwrite', + type: 'options', + displayOptions: { + show: { + mode: ['mergeByKey'], + }, + }, + options: [ + { + name: 'Always', + value: 'always', + description: 'Always overwrites everything', + }, + { + name: 'If Blank', + value: 'blank', + description: 'Overwrites only values of "null", "undefined" or empty string', + }, + { + name: 'If Missing', + value: 'undefined', + description: 'Only adds values which do not exist yet', + }, + ], + default: 'always', + description: 'Select when to overwrite the values from Input1 with values from Input 2', + }, + ], +}; + +export class MergeV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async execute(this: IExecuteFunctions): Promise { + const returnData: INodeExecutionData[] = []; + + const mode = this.getNodeParameter('mode', 0) as string; + + if (mode === 'append') { + // Simply appends the data + for (let i = 0; i < 2; i++) { + returnData.push.apply(returnData, this.getInputData(i)); + } + } else if (mode === 'mergeByIndex') { + // Merges data by index + + const join = this.getNodeParameter('join', 0) as string; + + const dataInput1 = this.getInputData(0); + const dataInput2 = this.getInputData(1); + + if (dataInput1 === undefined || dataInput1.length === 0) { + if (['inner', 'left'].includes(join)) { + // When "inner" or "left" join return empty if first + // input does not contain any items + return [returnData]; + } + + // For "outer" return data of second input + return [dataInput2]; + } + + if (dataInput2 === undefined || dataInput2.length === 0) { + if (['left', 'outer'].includes(join)) { + // When "left" or "outer" join return data of first input + return [dataInput1]; + } + + // For "inner" return empty + return [returnData]; + } + + // Default "left" + let numEntries = dataInput1.length; + if (join === 'inner') { + numEntries = Math.min(dataInput1.length, dataInput2.length); + } else if (join === 'outer') { + numEntries = Math.max(dataInput1.length, dataInput2.length); + } + + let newItem: INodeExecutionData; + for (let i = 0; i < numEntries; i++) { + if (i >= dataInput1.length) { + returnData.push(dataInput2[i]); + continue; + } + if (i >= dataInput2.length) { + returnData.push(dataInput1[i]); + continue; + } + + newItem = { + json: {}, + pairedItem: [ + dataInput1[i].pairedItem as IPairedItemData, + dataInput2[i].pairedItem as IPairedItemData, + ], + }; + + if (dataInput1[i].binary !== undefined) { + newItem.binary = {}; + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + Object.assign(newItem.binary, dataInput1[i].binary); + } + + // Create also a shallow copy of the json data + Object.assign(newItem.json, dataInput1[i].json); + + // Copy json data + for (const key of Object.keys(dataInput2[i].json)) { + newItem.json[key] = dataInput2[i].json[key]; + } + + // Copy binary data + if (dataInput2[i].binary !== undefined) { + if (newItem.binary === undefined) { + newItem.binary = {}; + } + + for (const key of Object.keys(dataInput2[i].binary!)) { + newItem.binary[key] = dataInput2[i].binary![key] ?? newItem.binary[key]; + } + } + + returnData.push(newItem); + } + } else if (mode === 'multiplex') { + const dataInput1 = this.getInputData(0); + const dataInput2 = this.getInputData(1); + + if (!dataInput1 || !dataInput2) { + return [returnData]; + } + + let entry1: INodeExecutionData; + let entry2: INodeExecutionData; + + for (entry1 of dataInput1) { + for (entry2 of dataInput2) { + returnData.push({ + json: { + ...entry1.json, + ...entry2.json, + }, + pairedItem: [ + entry1.pairedItem as IPairedItemData, + entry2.pairedItem as IPairedItemData, + ], + }); + } + } + return [returnData]; + } else if (['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'].includes(mode)) { + const dataInput1 = this.getInputData(0); + if (!dataInput1) { + // If it has no input data from first input return nothing + return [returnData]; + } + + const propertyName1 = this.getNodeParameter('propertyName1', 0) as string; + const propertyName2 = this.getNodeParameter('propertyName2', 0) as string; + const overwrite = this.getNodeParameter('overwrite', 0, 'always') as string; + + const dataInput2 = this.getInputData(1); + if (!dataInput2 || !propertyName1 || !propertyName2) { + // Second input does not have any data or the property names are not defined + if (mode === 'keepKeyMatches') { + // For "keepKeyMatches" return nothing + return [returnData]; + } + + // For "mergeByKey" and "removeKeyMatches" return the data from the first input + return [dataInput1]; + } + + // Get the data to copy + const copyData: { + [key: string]: INodeExecutionData; + } = {}; + let entry: INodeExecutionData; + for (entry of dataInput2) { + const key = get(entry.json, propertyName2); + if (!entry.json || !key) { + // Entry does not have the property so skip it + continue; + } + + copyData[key as string] = entry; + } + + // Copy data on entries or add matching entries + let referenceValue: GenericValue; + let key: string; + for (entry of dataInput1) { + referenceValue = get(entry.json, propertyName1); + + if (referenceValue === undefined) { + // Entry does not have the property + + if (mode === 'removeKeyMatches') { + // For "removeKeyMatches" add item + returnData.push(entry); + } + + // For "mergeByKey" and "keepKeyMatches" skip item + continue; + } + + if (!['string', 'number'].includes(typeof referenceValue)) { + if (referenceValue !== null && referenceValue.constructor.name !== 'Data') { + // Reference value is not of comparable type + + if (mode === 'removeKeyMatches') { + // For "removeKeyMatches" add item + returnData.push(entry); + } + + // For "mergeByKey" and "keepKeyMatches" skip item + continue; + } + } + + if (typeof referenceValue === 'number') { + referenceValue = referenceValue.toString(); + } else if (referenceValue !== null && referenceValue.constructor.name === 'Date') { + referenceValue = (referenceValue as Date).toISOString(); + } + + if (copyData.hasOwnProperty(referenceValue as string)) { + // Item with reference value got found + + if (['null', 'undefined'].includes(typeof referenceValue)) { + // The reference value is null or undefined + + if (mode === 'removeKeyMatches') { + // For "removeKeyMatches" add item + returnData.push(entry); + } + + // For "mergeByKey" and "keepKeyMatches" skip item + continue; + } + + // Match exists + if (mode === 'removeKeyMatches') { + // For "removeKeyMatches" we can skip the item as it has a match + continue; + } else if (mode === 'mergeByKey') { + // Copy the entry as the data gets changed + entry = JSON.parse(JSON.stringify(entry)); + + for (key of Object.keys(copyData[referenceValue as string].json)) { + if (key === propertyName2) { + continue; + } + + // TODO: Currently only copies json data and no binary one + const value = copyData[referenceValue as string].json[key]; + if ( + overwrite === 'always' || + (overwrite === 'undefined' && !entry.json.hasOwnProperty(key)) || + (overwrite === 'blank' && [null, undefined, ''].includes(entry.json[key] as string)) + ) { + entry.json[key] = value; + } + } + } else { + // For "keepKeyMatches" we add it as it is + returnData.push(entry); + continue; + } + } else { + // No item for reference value got found + if (mode === 'removeKeyMatches') { + // For "removeKeyMatches" we can add it if not match got found + returnData.push(entry); + continue; + } + } + + if (mode === 'mergeByKey') { + // For "mergeByKey" we always add the entry anyway but then the unchanged one + returnData.push(entry); + } + } + + return [returnData]; + } else if (mode === 'passThrough') { + const output = this.getNodeParameter('output', 0) as string; + + if (output === 'input1') { + returnData.push.apply(returnData, this.getInputData(0)); + } else { + returnData.push.apply(returnData, this.getInputData(1)); + } + } else if (mode === 'wait') { + returnData.push({ json: {} }); + } + + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts b/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts new file mode 100644 index 0000000000000..a5a34b1de30df --- /dev/null +++ b/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts @@ -0,0 +1,364 @@ +import { + GenericValue, + IBinaryKeyData, + IDataObject, + INodeExecutionData, + IPairedItemData, +} from 'n8n-workflow'; + +import { assign, assignWith, get, isEqual, merge, mergeWith } from 'lodash'; + +type PairToMatch = { + field1: string; + field2: string; +}; + +export type MatchFieldsOptions = { + joinMode: MatchFieldsJoinMode; + outputDataFrom: MatchFieldsOutput; + multipleMatches: MultipleMatches; + disableDotNotation: boolean; +}; + +export type ClashResolveOptions = { + resolveClash: ClashResolveMode; + mergeMode: ClashMergeMode; + overrideEmpty: boolean; +}; + +type ClashMergeMode = 'deepMerge' | 'shallowMerge'; + +type ClashResolveMode = 'addSuffix' | 'preferInput1' | 'preferInput2'; + +type MultipleMatches = 'all' | 'first'; + +export type MatchFieldsOutput = 'both' | 'input1' | 'input2'; + +export type MatchFieldsJoinMode = + | 'keepMatches' + | 'keepNonMatches' + | 'enrichInput2' + | 'enrichInput1'; + +type EntryMatches = { + entry: INodeExecutionData; + matches: INodeExecutionData[]; +}; + +export function addSuffixToEntriesKeys(data: INodeExecutionData[], suffix: string) { + return data.map((entry) => { + const json: IDataObject = {}; + Object.keys(entry.json).forEach((key) => { + json[`${key}_${suffix}`] = entry.json[key]; + }); + return { ...entry, json }; + }); +} + +function findAllMatches( + data: INodeExecutionData[], + lookup: IDataObject, + disableDotNotation: boolean, +) { + return data.reduce((acc, entry2, i) => { + if (entry2 === undefined) return acc; + + for (const key of Object.keys(lookup)) { + const excpectedValue = lookup[key]; + let entry2FieldValue; + + if (disableDotNotation) { + entry2FieldValue = entry2.json[key]; + } else { + entry2FieldValue = get(entry2.json, key); + } + + if (!isEqual(excpectedValue, entry2FieldValue)) { + return acc; + } + } + + return acc.concat({ + entry: entry2, + index: i, + }); + }, [] as IDataObject[]); +} + +function findFirstMatch( + data: INodeExecutionData[], + lookup: IDataObject, + disableDotNotation: boolean, +) { + const index = data.findIndex((entry2) => { + if (entry2 === undefined) return false; + + for (const key of Object.keys(lookup)) { + const excpectedValue = lookup[key]; + let entry2FieldValue; + + if (disableDotNotation) { + entry2FieldValue = entry2.json[key]; + } else { + entry2FieldValue = get(entry2.json, key); + } + + if (!isEqual(excpectedValue, entry2FieldValue)) { + return false; + } + } + + return true; + }); + if (index === -1) return []; + + return [{ entry: data[index], index }]; +} + +export function findMatches( + input1: INodeExecutionData[], + input2: INodeExecutionData[], + fieldsToMatch: PairToMatch[], + options: MatchFieldsOptions, +) { + let data1 = [...input1]; + let data2 = [...input2]; + + if (options.joinMode === 'enrichInput2') { + [data1, data2] = [data2, data1]; + } + + const disableDotNotation = (options.disableDotNotation as boolean) || false; + const multipleMatches = (options.multipleMatches as string) || 'all'; + + const filteredData = { + matched: [] as EntryMatches[], + matched2: [] as INodeExecutionData[], + unmatched1: [] as INodeExecutionData[], + unmatched2: [] as INodeExecutionData[], + }; + + const matchedInInput2 = new Set(); + + matchesLoop: for (const entry1 of data1) { + const lookup: IDataObject = {}; + + fieldsToMatch.forEach((matchCase) => { + let valueToCompare; + if (disableDotNotation) { + valueToCompare = entry1.json[matchCase.field1 as string]; + } else { + valueToCompare = get(entry1.json, matchCase.field1 as string); + } + lookup[matchCase.field2 as string] = valueToCompare; + }); + + for (const fieldValue of Object.values(lookup)) { + if (fieldValue === undefined) { + filteredData.unmatched1.push(entry1); + continue matchesLoop; + } + } + + const foundedMatches = + multipleMatches === 'all' + ? findAllMatches(data2, lookup, disableDotNotation) + : findFirstMatch(data2, lookup, disableDotNotation); + + const matches = foundedMatches.map((match) => match.entry) as INodeExecutionData[]; + foundedMatches.map((match) => matchedInInput2.add(match.index as number)); + + if (matches.length) { + if ( + options.outputDataFrom === 'both' || + options.joinMode === 'enrichInput1' || + options.joinMode === 'enrichInput2' + ) { + matches.forEach((match) => { + filteredData.matched.push({ + entry: entry1, + matches: [match], + }); + }); + } else { + filteredData.matched.push({ + entry: entry1, + matches, + }); + } + } else { + filteredData.unmatched1.push(entry1); + } + } + + data2.forEach((entry, i) => { + if (matchedInInput2.has(i)) { + filteredData.matched2.push(entry); + } else { + filteredData.unmatched2.push(entry); + } + }); + + return filteredData; +} + +export function mergeMatched( + matched: EntryMatches[], + clashResolveOptions: ClashResolveOptions, + joinMode?: MatchFieldsJoinMode, +) { + const returnData: INodeExecutionData[] = []; + let resolveClash = clashResolveOptions.resolveClash as string; + + const mergeIntoSingleObject = selectMergeMethod(clashResolveOptions); + + for (const match of matched) { + let { entry, matches } = match; + + let json: IDataObject = {}; + let binary: IBinaryKeyData = {}; + + if (resolveClash === 'addSuffix') { + let suffix1 = '1'; + let suffix2 = '2'; + + if (joinMode === 'enrichInput2') { + [suffix1, suffix2] = [suffix2, suffix1]; + } + + [entry] = addSuffixToEntriesKeys([entry], suffix1); + matches = addSuffixToEntriesKeys(matches, suffix2); + + json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((match) => match.json)); + binary = mergeIntoSingleObject( + { ...entry.binary }, + ...matches.map((match) => match.binary as IDataObject), + ); + } else { + let preferInput1 = 'preferInput1'; + let preferInput2 = 'preferInput2'; + + if (joinMode === 'enrichInput2') { + [preferInput1, preferInput2] = [preferInput2, preferInput1]; + } + + if (resolveClash === undefined) { + resolveClash = 'preferInput2'; + } + + if (resolveClash === preferInput1) { + const [firstMatch, ...restMatches] = matches; + json = mergeIntoSingleObject( + { ...firstMatch.json }, + ...restMatches.map((match) => match.json), + entry.json, + ); + binary = mergeIntoSingleObject( + { ...firstMatch.binary }, + ...restMatches.map((match) => match.binary as IDataObject), + entry.binary as IDataObject, + ); + } + + if (resolveClash === preferInput2) { + json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((match) => match.json)); + binary = mergeIntoSingleObject( + { ...entry.binary }, + ...matches.map((match) => match.binary as IDataObject), + ); + } + } + + const pairedItem = [ + entry.pairedItem as IPairedItemData, + ...matches.map((m) => m.pairedItem as IPairedItemData), + ]; + + returnData.push({ + json, + binary, + pairedItem, + }); + } + + return returnData; +} + +export function selectMergeMethod(clashResolveOptions: ClashResolveOptions) { + const mergeMode = clashResolveOptions.mergeMode as string; + + if (clashResolveOptions.overrideEmpty) { + function customizer(targetValue: GenericValue, srcValue: GenericValue) { + if (srcValue === undefined || srcValue === null || srcValue === '') { + return targetValue; + } + } + if (mergeMode === 'deepMerge') { + return (target: IDataObject, ...source: IDataObject[]) => + mergeWith(target, ...source, customizer); + } + if (mergeMode === 'shallowMerge') { + return (target: IDataObject, ...source: IDataObject[]) => + assignWith(target, ...source, customizer); + } + } else { + if (mergeMode === 'deepMerge') { + return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source); + } + if (mergeMode === 'shallowMerge') { + return (target: IDataObject, ...source: IDataObject[]) => assign({}, target, ...source); + } + } + return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source); +} + +export function checkMatchFieldsInput(data: IDataObject[]) { + if (data.length === 1 && data[0].field1 === '' && data[0].field2 === '') { + throw new Error( + 'You need to define at least one pair of fields in "Fields to Match" to match on', + ); + } + for (const [index, pair] of data.entries()) { + if (pair.field1 === '' || pair.field2 === '') { + throw new Error( + `You need to define both fields in "Fields to Match" for pair ${index + 1}, + field 1 = '${pair.field1}' + field 2 = '${pair.field2}'`, + ); + } + } + return data as PairToMatch[]; +} + +export function checkInput( + input: INodeExecutionData[], + fields: string[], + disableDotNotation: boolean, + inputLabel: string, +) { + for (const field of fields) { + const isPresent = (input || []).some((entry) => { + if (disableDotNotation) { + return entry.json.hasOwnProperty(field); + } + return get(entry.json, field, undefined) !== undefined; + }); + if (!isPresent) { + throw new Error(`Field '${field}' is not present in any of items in '${inputLabel}'`); + } + } + return input; +} + +export function addSourceField(data: INodeExecutionData[], sourceField: string) { + return data.map((entry) => { + const json = { + ...entry.json, + _source: sourceField, + }; + return { + ...entry, + json, + }; + }); +} diff --git a/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts new file mode 100644 index 0000000000000..5fb02ee9759a3 --- /dev/null +++ b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts @@ -0,0 +1,511 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { IExecuteFunctions } from 'n8n-core'; + +import { merge } from 'lodash'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + IPairedItemData, +} from 'n8n-workflow'; + +import { + addSourceField, + addSuffixToEntriesKeys, + checkInput, + checkMatchFieldsInput, + ClashResolveOptions, + findMatches, + MatchFieldsJoinMode, + MatchFieldsOptions, + MatchFieldsOutput, + mergeMatched, + selectMergeMethod, +} from './GenericFunctions'; + +import { optionsDescription } from './OptionsDescription'; + +const versionDescription: INodeTypeDescription = { + displayName: 'Merge', + name: 'merge', + icon: 'fa:code-branch', + group: ['transform'], + version: 2, + subtitle: '={{$parameter["mode"]}}', + description: 'Merges data of multiple streams once data from both is available', + defaults: { + name: 'Merge', + color: '#00bbcc', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: ['main', 'main'], + outputs: ['main'], + inputNames: ['Input 1', 'Input 2'], + properties: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Append', + value: 'append', + description: 'All items of input 1, then all items of input 2', + }, + { + name: 'Match Fields', + value: 'matchFields', + description: 'Pair items with the same field values', + }, + { + name: 'Match Positions', + value: 'matchPositions', + description: 'Pair items based on their order', + }, + { + name: 'Multiplex', + value: 'multiplex', + description: 'All possible item combinations (cross join)', + }, + { + name: 'Choose Branch', + value: 'chooseBranch', + description: 'Output input data, without modifying it', + }, + ], + default: 'append', + description: 'How data of branches should be merged', + }, + + // matchFields ------------------------------------------------------------------ + { + displayName: 'Fields to Match', + name: 'matchFields', + type: 'fixedCollection', + placeholder: 'Add Fields to Match', + default: { values: [{ field1: '', field2: '' }] }, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Input 1 Field', + name: 'field1', + type: 'string', + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', + }, + { + displayName: 'Input 2 Field', + name: 'field2', + type: 'string', + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', + }, + ], + }, + ], + displayOptions: { + show: { + mode: ['matchFields'], + }, + }, + }, + { + displayName: 'Output Type', + name: 'joinMode', + type: 'options', + options: [ + { + name: 'Keep Matches', + value: 'keepMatches', + description: 'Items that match, merged together (inner join)', + }, + { + name: 'Keep Non-Matches', + value: 'keepNonMatches', + description: "Items that don't match (outer join)", + }, + { + name: 'Enrich Input 1', + value: 'enrichInput1', + description: 'All of input 1, with data from input 2 added in (left join)', + }, + { + name: 'Enrich Input 2', + value: 'enrichInput2', + description: 'All of input 2, with data from input 1 added in (right join)', + }, + ], + default: 'keepMatches', + displayOptions: { + show: { + mode: ['matchFields'], + }, + }, + }, + { + displayName: 'Output Data From', + name: 'outputDataFrom', + type: 'options', + options: [ + { + name: 'Both Inputs Merged Together', + value: 'both', + }, + { + name: 'Input 1', + value: 'input1', + }, + { + name: 'Input 2', + value: 'input2', + }, + ], + default: 'both', + displayOptions: { + show: { + mode: ['matchFields'], + joinMode: ['keepMatches'], + }, + }, + }, + { + displayName: 'Output Data From', + name: 'outputDataFrom', + type: 'options', + options: [ + { + name: 'Both Inputs Appended Together', + value: 'both', + }, + { + name: 'Input 1', + value: 'input1', + }, + { + name: 'Input 2', + value: 'input2', + }, + ], + default: 'both', + displayOptions: { + show: { + mode: ['matchFields'], + joinMode: ['keepNonMatches'], + }, + }, + }, + + // chooseBranch ----------------------------------------------------------------- + { + displayName: 'Output Type', + name: 'chooseBranchMode', + type: 'options', + options: [ + { + name: 'Wait for Both Inputs to Arrive', + value: 'waitForBoth', + }, + // not MVP + // { + // name: 'Immediately Pass the First Input to Arrive', + // value: 'passFirst', + // }, + ], + default: 'waitForBoth', + displayOptions: { + show: { + mode: ['chooseBranch'], + }, + }, + }, + { + displayName: 'Output', + name: 'output', + type: 'options', + options: [ + { + name: 'Input 1 Data', + value: 'input1', + }, + { + name: 'Input 2 Data', + value: 'input2', + }, + { + name: 'A Single, Empty Item', + value: 'empty', + }, + ], + default: 'input1', + displayOptions: { + show: { + mode: ['chooseBranch'], + chooseBranchMode: ['waitForBoth'], + }, + }, + }, + + ...optionsDescription, + ], +}; + +export class MergeV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async execute(this: IExecuteFunctions): Promise { + const returnData: INodeExecutionData[] = []; + + const mode = this.getNodeParameter('mode', 0) as string; + + if (mode === 'append') { + for (let i = 0; i < 2; i++) { + returnData.push.apply(returnData, this.getInputData(i)); + } + } + + if (mode === 'multiplex') { + const clashHandling = this.getNodeParameter( + 'options.clashHandling.values', + 0, + {}, + ) as ClashResolveOptions; + + let input1 = this.getInputData(0); + let input2 = this.getInputData(1); + + if (clashHandling.resolveClash === 'preferInput1') { + [input1, input2] = [input2, input1]; + } + + if (clashHandling.resolveClash === 'addSuffix') { + input1 = addSuffixToEntriesKeys(input1, '1'); + input2 = addSuffixToEntriesKeys(input2, '2'); + } + + const mergeIntoSingleObject = selectMergeMethod(clashHandling); + + if (!input1 || !input2) { + return [returnData]; + } + + let entry1: INodeExecutionData; + let entry2: INodeExecutionData; + + for (entry1 of input1) { + for (entry2 of input2) { + returnData.push({ + json: { + ...mergeIntoSingleObject(entry1.json, entry2.json), + }, + binary: { + ...merge({}, entry1.binary, entry2.binary), + }, + pairedItem: [ + entry1.pairedItem as IPairedItemData, + entry2.pairedItem as IPairedItemData, + ], + }); + } + } + return [returnData]; + } + + if (mode === 'matchPositions') { + const clashHandling = this.getNodeParameter( + 'options.clashHandling.values', + 0, + {}, + ) as ClashResolveOptions; + const includeUnpaired = this.getNodeParameter('options.includeUnpaired', 0, false) as boolean; + + let input1 = this.getInputData(0); + let input2 = this.getInputData(1); + + if (clashHandling.resolveClash === 'preferInput1') { + [input1, input2] = [input2, input1]; + } + + if (clashHandling.resolveClash === 'addSuffix') { + input1 = addSuffixToEntriesKeys(input1, '1'); + input2 = addSuffixToEntriesKeys(input2, '2'); + } + + if (input1 === undefined || input1.length === 0) { + if (includeUnpaired) { + return [input2]; + } + return [returnData]; + } + + if (input2 === undefined || input2.length === 0) { + if (includeUnpaired) { + return [input1]; + } + return [returnData]; + } + + let numEntries: number; + if (includeUnpaired) { + numEntries = Math.max(input1.length, input2.length); + } else { + numEntries = Math.min(input1.length, input2.length); + } + + const mergeIntoSingleObject = selectMergeMethod(clashHandling); + + for (let i = 0; i < numEntries; i++) { + if (i >= input1.length) { + returnData.push(input2[i]); + continue; + } + if (i >= input2.length) { + returnData.push(input1[i]); + continue; + } + + const entry1 = input1[i]; + const entry2 = input2[i]; + + returnData.push({ + json: { + ...mergeIntoSingleObject(entry1.json, entry2.json), + }, + binary: { + ...merge({}, entry1.binary, entry2.binary), + }, + pairedItem: [entry1.pairedItem as IPairedItemData, entry2.pairedItem as IPairedItemData], + }); + } + } + + if (mode === 'matchFields') { + const matchFields = checkMatchFieldsInput( + this.getNodeParameter('matchFields.values', 0, []) as IDataObject[], + ); + + const joinMode = this.getNodeParameter('joinMode', 0) as MatchFieldsJoinMode; + const outputDataFrom = this.getNodeParameter('outputDataFrom', 0, '') as MatchFieldsOutput; + const options = this.getNodeParameter('options', 0, {}) as MatchFieldsOptions; + + options.joinMode = joinMode; + options.outputDataFrom = outputDataFrom; + + const input1 = checkInput( + this.getInputData(0), + matchFields.map((pair) => pair.field1 as string), + (options.disableDotNotation as boolean) || false, + 'Input 1', + ); + if (!input1) return [returnData]; + + const input2 = checkInput( + this.getInputData(1), + matchFields.map((pair) => pair.field2 as string), + (options.disableDotNotation as boolean) || false, + 'Input 2', + ); + + if (!input2 || !matchFields.length) { + if (joinMode === 'keepMatches' || joinMode === 'enrichInput2') { + return [returnData]; + } + return [input1]; + } + + const matches = findMatches(input1, input2, matchFields, options); + + if (joinMode === 'keepMatches') { + if (outputDataFrom === 'input1') { + return [matches.matched.map((match) => match.entry)]; + } + if (outputDataFrom === 'input2') { + return [matches.matched2]; + } + if (outputDataFrom === 'both') { + const clashResolveOptions = this.getNodeParameter( + 'options.clashHandling.values', + 0, + {}, + ) as ClashResolveOptions; + + const mergedEntries = mergeMatched(matches.matched, clashResolveOptions); + + returnData.push(...mergedEntries); + } + } + + if (joinMode === 'keepNonMatches') { + if (outputDataFrom === 'input1') { + return [matches.unmatched1]; + } + if (outputDataFrom === 'input2') { + return [matches.unmatched2]; + } + if (outputDataFrom === 'both') { + let output: INodeExecutionData[] = []; + output = output.concat(addSourceField(matches.unmatched1, 'input1')); + output = output.concat(addSourceField(matches.unmatched2, 'input2')); + return [output]; + } + } + + if (joinMode === 'enrichInput1' || joinMode === 'enrichInput2') { + const clashResolveOptions = this.getNodeParameter( + 'options.clashHandling.values', + 0, + {}, + ) as ClashResolveOptions; + + const mergedEntries = mergeMatched(matches.matched, clashResolveOptions, joinMode); + + if (clashResolveOptions.resolveClash === 'addSuffix') { + const suffix = joinMode === 'enrichInput1' ? '1' : '2'; + returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched1, suffix)); + } else { + returnData.push(...mergedEntries, ...matches.unmatched1); + } + } + } + + if (mode === 'chooseBranch') { + const chooseBranchMode = this.getNodeParameter('chooseBranchMode', 0) as string; + + if (chooseBranchMode === 'waitForBoth') { + const output = this.getNodeParameter('output', 0) as string; + + if (output === 'input1') { + returnData.push.apply(returnData, this.getInputData(0)); + } + if (output === 'input2') { + returnData.push.apply(returnData, this.getInputData(1)); + } + if (output === 'empty') { + returnData.push({ json: {} }); + } + } + } + + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts b/packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts new file mode 100644 index 0000000000000..2c9522a0b4e42 --- /dev/null +++ b/packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts @@ -0,0 +1,198 @@ +import { INodeProperties } from 'n8n-workflow'; + +const clashHandlingProperties: INodeProperties = { + displayName: 'Clash Handling', + name: 'clashHandling', + type: 'fixedCollection', + default: { + values: { resolveClash: 'preferInput2', mergeMode: 'deepMerge', overrideEmpty: false }, + }, + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'When Field Values Clash', + name: 'resolveClash', + type: 'options', + default: '', + options: [ + { + name: 'Always Add Input Number to Field Names', + value: 'addSuffix', + }, + { + name: 'Prefer Input 1 Version', + value: 'preferInput1', + }, + { + name: 'Prefer Input 2 Version', + value: 'preferInput2', + }, + ], + }, + { + displayName: 'Merging Nested Fields', + name: 'mergeMode', + type: 'options', + default: 'deepMerge', + options: [ + { + name: 'Deep Merge', + value: 'deepMerge', + description: 'Merge at every level of nesting', + }, + { + name: 'Shallow Merge', + value: 'shallowMerge', + description: + 'Merge at the top level only (all nested fields will come from the same input)', + }, + ], + hint: 'How to merge when there are sub-fields below the top-level ones', + displayOptions: { + show: { + resolveClash: ['preferInput1', 'preferInput2'], + }, + }, + }, + { + displayName: 'Minimize Empty Fields', + name: 'overrideEmpty', + type: 'boolean', + default: false, + description: + "Whether to override the preferred input version for a field if it is empty and the other version isn't. Here 'empty' means undefined, null or an empty string.", + displayOptions: { + show: { + resolveClash: ['preferInput1', 'preferInput2'], + }, + }, + }, + ], + }, + ], +}; + +export const optionsDescription: INodeProperties[] = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + ...clashHandlingProperties, + displayOptions: { + show: { + '/mode': ['matchFields'], + }, + hide: { + '/joinMode': ['keepMatches', 'keepNonMatches'], + }, + }, + }, + { + ...clashHandlingProperties, + displayOptions: { + show: { + '/mode': ['matchFields'], + '/joinMode': ['keepMatches'], + '/outputDataFrom': ['both'], + }, + }, + }, + { + ...clashHandlingProperties, + displayOptions: { + show: { + '/mode': ['multiplex', 'matchPositions'], + }, + }, + }, + { + displayName: 'Disable Dot Notation', + name: 'disableDotNotation', + type: 'boolean', + default: false, + description: + 'Whether to disallow referencing child fields using `parent.child` in the field name', + displayOptions: { + show: { + '/mode': ['matchFields'], + }, + }, + }, + { + displayName: 'Include Any Unpaired Items', + name: 'includeUnpaired', + type: 'boolean', + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: + 'If there are different numbers of items in input 1 and input 2, whether to include the ones at the end with nothing to pair with', + displayOptions: { + show: { + '/mode': ['matchPositions'], + }, + }, + }, + { + displayName: 'Multiple Matches', + name: 'multipleMatches', + type: 'options', + default: 'all', + options: [ + { + name: 'Include All Matches', + value: 'all', + description: 'Output multiple items if there are multiple matches', + }, + { + name: 'Include First Match Only', + value: 'first', + description: 'Only ever output a single item per match', + }, + ], + displayOptions: { + show: { + '/mode': ['matchFields'], + '/joinMode': ['keepMatches'], + '/outputDataFrom': ['both'], + }, + }, + }, + { + displayName: 'Multiple Matches', + name: 'multipleMatches', + type: 'options', + default: 'all', + options: [ + { + name: 'Include All Matches', + value: 'all', + description: 'Output multiple items if there are multiple matches', + }, + { + name: 'Include First Match Only', + value: 'first', + description: 'Only ever output a single item per match', + }, + ], + displayOptions: { + show: { + '/mode': ['matchFields'], + '/joinMode': ['enrichInput1', 'enrichInput2'], + }, + }, + }, + ], + displayOptions: { + hide: { + mode: ['chooseBranch', 'append'], + }, + }, + }, +];