mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(Merge Node): Overhaul, v3 (#9528)
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com> Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
@@ -3649,6 +3649,17 @@ export function getExecuteFunctions(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getNodeInputs(): INodeInputConfiguration[] {
|
||||||
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
return NodeHelpers.getNodeInputs(workflow, node, nodeType.description).map((output) => {
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
return {
|
||||||
|
type: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
});
|
||||||
|
},
|
||||||
getNodeOutputs(): INodeOutputConfiguration[] {
|
getNodeOutputs(): INodeOutputConfiguration[] {
|
||||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => {
|
return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => {
|
||||||
|
|||||||
@@ -717,10 +717,23 @@ export class WorkflowExecute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the array has all the values
|
let connectionDataArray: Array<INodeExecutionData[] | null> = get(
|
||||||
const connectionDataArray: Array<INodeExecutionData[] | null> = [];
|
this.runExecutionData,
|
||||||
for (let i: number = connectionData.index; i >= 0; i--) {
|
[
|
||||||
connectionDataArray[i] = null;
|
'executionData',
|
||||||
|
'waitingExecution',
|
||||||
|
connectionData.node,
|
||||||
|
waitingNodeIndex!,
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionDataArray === null) {
|
||||||
|
connectionDataArray = [];
|
||||||
|
for (let i: number = connectionData.index; i >= 0; i--) {
|
||||||
|
connectionDataArray[i] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the data of the current execution
|
// Add the data of the current execution
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||||||
import type {
|
import type {
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
IConnectedNode,
|
IConnectedNode,
|
||||||
|
INodeInputConfiguration,
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
Workflow,
|
Workflow,
|
||||||
@@ -408,7 +409,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
filterOutConnectionType(
|
filterOutConnectionType(
|
||||||
item: ConnectionTypes | INodeOutputConfiguration,
|
item: ConnectionTypes | INodeOutputConfiguration | INodeInputConfiguration,
|
||||||
type: ConnectionTypes,
|
type: ConnectionTypes,
|
||||||
) {
|
) {
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
|
|||||||
@@ -494,6 +494,9 @@ export default defineComponent({
|
|||||||
styles['--configurable-node-input-count'] = nonMainInputs.length + spacerCount;
|
styles['--configurable-node-input-count'] = nonMainInputs.length + spacerCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mainInputs = inputTypes.filter((output) => output === NodeConnectionType.Main);
|
||||||
|
styles['--node-main-input-count'] = mainInputs.length;
|
||||||
|
|
||||||
let outputs = [] as Array<ConnectionTypes | INodeOutputConfiguration>;
|
let outputs = [] as Array<ConnectionTypes | INodeOutputConfiguration>;
|
||||||
if (this.workflow.nodes[this.node.name]) {
|
if (this.workflow.nodes[this.node.name]) {
|
||||||
outputs = NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType);
|
outputs = NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType);
|
||||||
@@ -879,7 +882,10 @@ export default defineComponent({
|
|||||||
Increase height by 20px for each output beyond the 4th one.
|
Increase height by 20px for each output beyond the 4th one.
|
||||||
max(0, var(--node-main-output-count, 1) - 4) ensures that we only start counting after the 4th output.
|
max(0, var(--node-main-output-count, 1) - 4) ensures that we only start counting after the 4th output.
|
||||||
*/
|
*/
|
||||||
--node-height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 20px);
|
--node-height: max(
|
||||||
|
calc(100px + max(0, var(--node-main-input-count, 1) - 3) * 30px),
|
||||||
|
calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 20px)
|
||||||
|
);
|
||||||
|
|
||||||
--configurable-node-min-input-count: 4;
|
--configurable-node-min-input-count: 4;
|
||||||
--configurable-node-input-width: 65px;
|
--configurable-node-input-width: 65px;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { omit } from '@/utils/typesUtils';
|
|||||||
import type {
|
import type {
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
INode,
|
INode,
|
||||||
|
INodeInputConfiguration,
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
@@ -179,13 +180,15 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||||||
(acc, node) => {
|
(acc, node) => {
|
||||||
const inputTypes = node.inputs;
|
const inputTypes = node.inputs;
|
||||||
if (Array.isArray(inputTypes)) {
|
if (Array.isArray(inputTypes)) {
|
||||||
inputTypes.forEach((value: ConnectionTypes | INodeOutputConfiguration) => {
|
inputTypes.forEach(
|
||||||
const outputType = typeof value === 'string' ? value : value.type;
|
(value: ConnectionTypes | INodeOutputConfiguration | INodeInputConfiguration) => {
|
||||||
if (!acc[outputType]) {
|
const outputType = typeof value === 'string' ? value : value.type;
|
||||||
acc[outputType] = [];
|
if (!acc[outputType]) {
|
||||||
}
|
acc[outputType] = [];
|
||||||
acc[outputType].push(node.name);
|
}
|
||||||
});
|
acc[outputType].push(node.name);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { VersionedNodeType } from 'n8n-workflow';
|
|||||||
|
|
||||||
import { MergeV1 } from './v1/MergeV1.node';
|
import { MergeV1 } from './v1/MergeV1.node';
|
||||||
import { MergeV2 } from './v2/MergeV2.node';
|
import { MergeV2 } from './v2/MergeV2.node';
|
||||||
|
import { MergeV3 } from './v3/MergeV3.node';
|
||||||
|
|
||||||
export class Merge extends VersionedNodeType {
|
export class Merge extends VersionedNodeType {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -13,13 +14,14 @@ export class Merge extends VersionedNodeType {
|
|||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
subtitle: '={{$parameter["mode"]}}',
|
subtitle: '={{$parameter["mode"]}}',
|
||||||
description: 'Merges data of multiple streams once data from both is available',
|
description: 'Merges data of multiple streams once data from both is available',
|
||||||
defaultVersion: 2.1,
|
defaultVersion: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||||
1: new MergeV1(baseDescription),
|
1: new MergeV1(baseDescription),
|
||||||
2: new MergeV2(baseDescription),
|
2: new MergeV2(baseDescription),
|
||||||
2.1: new MergeV2(baseDescription),
|
2.1: new MergeV2(baseDescription),
|
||||||
|
3: new MergeV3(baseDescription),
|
||||||
};
|
};
|
||||||
|
|
||||||
super(nodeVersions, baseDescription);
|
super(nodeVersions, baseDescription);
|
||||||
|
|||||||
356
packages/nodes-base/nodes/Merge/test/v3/operations.test.ts
Normal file
356
packages/nodes-base/nodes/Merge/test/v3/operations.test.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import type { IDataObject, INode } from 'n8n-workflow';
|
||||||
|
import { createMockExecuteFunction } from '../../../../test/nodes/Helpers';
|
||||||
|
import * as mode from '../../v3/actions/mode';
|
||||||
|
|
||||||
|
const node: INode = {
|
||||||
|
id: '123456',
|
||||||
|
name: 'Merge',
|
||||||
|
typeVersion: 3,
|
||||||
|
type: 'n8n-nodes-base.merge',
|
||||||
|
position: [50, 50],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputsData = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 1,
|
||||||
|
data: 'a',
|
||||||
|
name: 'Sam',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 2,
|
||||||
|
data: 'b',
|
||||||
|
name: 'Dan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 3,
|
||||||
|
data: 'c',
|
||||||
|
name: 'Jon',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 6,
|
||||||
|
data: 'e',
|
||||||
|
name: 'Ron',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 7,
|
||||||
|
data: 'f',
|
||||||
|
name: 'Joe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
country: 'PL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 2,
|
||||||
|
data: 'bb',
|
||||||
|
country: 'FR',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 3,
|
||||||
|
data: 'cc',
|
||||||
|
country: 'UA',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 4,
|
||||||
|
data: 'ee',
|
||||||
|
country: 'US',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
id: 5,
|
||||||
|
data: 'ff',
|
||||||
|
country: 'ES',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
describe('Test MergeV3, combineBySql operation', () => {
|
||||||
|
it('LEFT JOIN', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query:
|
||||||
|
'SELECT *, input1.data as data_1\nFROM input1\nLEFT JOIN input2\nON input1.id = input2.id\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
data_1: 'a',
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
name: 'Sam',
|
||||||
|
country: 'PL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('LEFT JOIN, missing input 2(empty array)', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query:
|
||||||
|
'SELECT *, input1.data as data_1\nFROM input1\nLEFT JOIN input2\nON input1.id = input2.id\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
[inputsData[0], []],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
data: 'a',
|
||||||
|
data_1: 'a',
|
||||||
|
id: 1,
|
||||||
|
name: 'Sam',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LEFT JOIN, missing data in input 2', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query:
|
||||||
|
'SELECT *, input1.data as data_1\nFROM input1\nLEFT JOIN input2\nON input1.id = input2.id\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mode.combineBySql.execute.call(createMockExecuteFunction(nodeParameters, node), [
|
||||||
|
inputsData[0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toBe('Issue while executing query');
|
||||||
|
expect(error.description).toBe('Table does not exist: input2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LEFT JOIN, invalid syntax', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query:
|
||||||
|
'SELECTTT *, input1.data as data_1\nFROM input1\nLEFT JOIN input2\nON input1.id = input2.id\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toBe('Issue while executing query');
|
||||||
|
expect(error.description.includes('Parse error')).toBe(true);
|
||||||
|
expect(error.description.includes('SELECTTT')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RIGHT JOIN', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query: 'SELECT *\nFROM input1\nRIGHT JOIN input2\nON input1.id = input2.id;\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
name: 'Sam',
|
||||||
|
country: 'PL',
|
||||||
|
});
|
||||||
|
expect(returnData[4].json).toEqual({
|
||||||
|
id: 5,
|
||||||
|
data: 'ff',
|
||||||
|
country: 'ES',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INNER JOIN', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query: 'SELECT *\nFROM input1\nINNER JOIN input2\nON input1.id = input2.id;\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(3);
|
||||||
|
expect(returnData[2].json).toEqual({
|
||||||
|
id: 3,
|
||||||
|
data: 'cc',
|
||||||
|
name: 'Jon',
|
||||||
|
country: 'UA',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FULL OUTER JOIN', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query: 'SELECT *\nFROM input1\nFULL OUTER JOIN input2\nON input1.id = input2.id;\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(7);
|
||||||
|
expect(returnData[2].json).toEqual({
|
||||||
|
id: 3,
|
||||||
|
data: 'cc',
|
||||||
|
name: 'Jon',
|
||||||
|
country: 'UA',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('CROSS JOIN', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
operation: 'combineBySql',
|
||||||
|
query: 'SELECT *, input1.data AS data_1\nFROM input1\nCROSS JOIN input2;\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineBySql.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(25);
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
data_1: 'a',
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
name: 'Sam',
|
||||||
|
country: 'PL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Test MergeV3, append operation', () => {
|
||||||
|
it('append inputs', async () => {
|
||||||
|
const nodeParameters: IDataObject = {};
|
||||||
|
|
||||||
|
const returnData = await mode.append.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(10);
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
id: 1,
|
||||||
|
data: 'a',
|
||||||
|
name: 'Sam',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('Test MergeV3, combineByFields operation', () => {
|
||||||
|
it('merge inputs', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
joinMode: 'keepMatches',
|
||||||
|
fieldsToMatchString: 'id',
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineByFields.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(3);
|
||||||
|
expect(returnData[1].json).toEqual({
|
||||||
|
id: 2,
|
||||||
|
data: 'bb',
|
||||||
|
name: 'Dan',
|
||||||
|
country: 'FR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Test MergeV3, combineByPosition operation', () => {
|
||||||
|
it('combine inputs', async () => {
|
||||||
|
const nodeParameters: IDataObject = {};
|
||||||
|
|
||||||
|
const returnData = await mode.combineByPosition.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(5);
|
||||||
|
expect(returnData[4].json).toEqual({
|
||||||
|
id: 5,
|
||||||
|
data: 'ff',
|
||||||
|
name: 'Joe',
|
||||||
|
country: 'ES',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Test MergeV3, chooseBranch operation', () => {
|
||||||
|
it('choose input', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
useDataOfInput: 2,
|
||||||
|
chooseBranchMode: 'waitForAll',
|
||||||
|
output: 'specifiedInput',
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.chooseBranch.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(5);
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
country: 'PL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Test MergeV3, combineAll operation', () => {
|
||||||
|
it('combine inputs', async () => {
|
||||||
|
const nodeParameters: IDataObject = {
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnData = await mode.combineAll.execute.call(
|
||||||
|
createMockExecuteFunction(nodeParameters, node),
|
||||||
|
inputsData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(returnData.length).toEqual(25);
|
||||||
|
expect(returnData[0].json).toEqual({
|
||||||
|
id: 1,
|
||||||
|
data: 'aa',
|
||||||
|
name: 'Sam',
|
||||||
|
country: 'PL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,8 @@ import type {
|
|||||||
MatchFieldsJoinMode,
|
MatchFieldsJoinMode,
|
||||||
MatchFieldsOptions,
|
MatchFieldsOptions,
|
||||||
MatchFieldsOutput,
|
MatchFieldsOutput,
|
||||||
} from './GenericFunctions';
|
} from './interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addSourceField,
|
addSourceField,
|
||||||
addSuffixToEntriesKeys,
|
addSuffixToEntriesKeys,
|
||||||
@@ -24,9 +25,9 @@ import {
|
|||||||
findMatches,
|
findMatches,
|
||||||
mergeMatched,
|
mergeMatched,
|
||||||
selectMergeMethod,
|
selectMergeMethod,
|
||||||
} from './GenericFunctions';
|
} from './utils';
|
||||||
|
|
||||||
import { optionsDescription } from './OptionsDescription';
|
import { optionsDescription } from './descriptions';
|
||||||
import { preparePairedItemDataArray } from '@utils/utilities';
|
import { preparePairedItemDataArray } from '@utils/utilities';
|
||||||
|
|
||||||
export class MergeV2 implements INodeType {
|
export class MergeV2 implements INodeType {
|
||||||
|
|||||||
27
packages/nodes-base/nodes/Merge/v2/interfaces.ts
Normal file
27
packages/nodes-base/nodes/Merge/v2/interfaces.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
type MultipleMatches = 'all' | 'first';
|
||||||
|
|
||||||
|
export type MatchFieldsOptions = {
|
||||||
|
joinMode: MatchFieldsJoinMode;
|
||||||
|
outputDataFrom: MatchFieldsOutput;
|
||||||
|
multipleMatches: MultipleMatches;
|
||||||
|
disableDotNotation: boolean;
|
||||||
|
fuzzyCompare?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClashMergeMode = 'deepMerge' | 'shallowMerge';
|
||||||
|
type ClashResolveMode = 'addSuffix' | 'preferInput1' | 'preferInput2';
|
||||||
|
|
||||||
|
export type ClashResolveOptions = {
|
||||||
|
resolveClash: ClashResolveMode;
|
||||||
|
mergeMode: ClashMergeMode;
|
||||||
|
overrideEmpty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchFieldsOutput = 'both' | 'input1' | 'input2';
|
||||||
|
|
||||||
|
export type MatchFieldsJoinMode =
|
||||||
|
| 'keepEverything'
|
||||||
|
| 'keepMatches'
|
||||||
|
| 'keepNonMatches'
|
||||||
|
| 'enrichInput2'
|
||||||
|
| 'enrichInput1';
|
||||||
@@ -12,42 +12,16 @@ import assignWith from 'lodash/assignWith';
|
|||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
import mergeWith from 'lodash/mergeWith';
|
import mergeWith from 'lodash/mergeWith';
|
||||||
|
|
||||||
import { fuzzyCompare, preparePairedItemDataArray } from '@utils/utilities';
|
import { fuzzyCompare, preparePairedItemDataArray } from '@utils/utilities';
|
||||||
|
|
||||||
|
import type { ClashResolveOptions, MatchFieldsJoinMode, MatchFieldsOptions } from './interfaces';
|
||||||
|
|
||||||
type PairToMatch = {
|
type PairToMatch = {
|
||||||
field1: string;
|
field1: string;
|
||||||
field2: string;
|
field2: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatchFieldsOptions = {
|
|
||||||
joinMode: MatchFieldsJoinMode;
|
|
||||||
outputDataFrom: MatchFieldsOutput;
|
|
||||||
multipleMatches: MultipleMatches;
|
|
||||||
disableDotNotation: boolean;
|
|
||||||
fuzzyCompare?: 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 =
|
|
||||||
| 'keepEverything'
|
|
||||||
| 'keepMatches'
|
|
||||||
| 'keepNonMatches'
|
|
||||||
| 'enrichInput2'
|
|
||||||
| 'enrichInput1';
|
|
||||||
|
|
||||||
type EntryMatches = {
|
type EntryMatches = {
|
||||||
entry: INodeExecutionData;
|
entry: INodeExecutionData;
|
||||||
matches: INodeExecutionData[];
|
matches: INodeExecutionData[];
|
||||||
31
packages/nodes-base/nodes/Merge/v3/MergeV3.node.ts
Normal file
31
packages/nodes-base/nodes/Merge/v3/MergeV3.node.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeBaseDescription,
|
||||||
|
INodeTypeDescription,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { versionDescription } from './actions/versionDescription';
|
||||||
|
import { router } from './actions/router';
|
||||||
|
import { loadOptions } from './methods';
|
||||||
|
|
||||||
|
export class MergeV3 implements INodeType {
|
||||||
|
description: INodeTypeDescription;
|
||||||
|
|
||||||
|
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||||
|
this.description = {
|
||||||
|
...baseDescription,
|
||||||
|
...versionDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
loadOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(this: IExecuteFunctions) {
|
||||||
|
return await router.call(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/nodes-base/nodes/Merge/v3/actions/mode/append.ts
Normal file
32
packages/nodes-base/nodes/Merge/v3/actions/mode/append.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
type IExecuteFunctions,
|
||||||
|
type INodeExecutionData,
|
||||||
|
type INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [numberInputsProperty];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['append'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < inputsData.length; i++) {
|
||||||
|
returnData.push.apply(returnData, inputsData[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
110
packages/nodes-base/nodes/Merge/v3/actions/mode/chooseBranch.ts
Normal file
110
packages/nodes-base/nodes/Merge/v3/actions/mode/chooseBranch.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { preparePairedItemDataArray, updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [
|
||||||
|
numberInputsProperty,
|
||||||
|
{
|
||||||
|
displayName: 'Output Type',
|
||||||
|
name: 'chooseBranchMode',
|
||||||
|
type: 'options',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Wait for All Inputs to Arrive',
|
||||||
|
value: 'waitForAll',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'waitForAll',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Output',
|
||||||
|
name: 'output',
|
||||||
|
type: 'options',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Data of Specified Input',
|
||||||
|
value: 'specifiedInput',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'A Single, Empty Item',
|
||||||
|
value: 'empty',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'specifiedInput',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
chooseBranchMode: ['waitForAll'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||||
|
displayName: 'Use Data of Input',
|
||||||
|
name: 'useDataOfInput',
|
||||||
|
type: 'options',
|
||||||
|
default: 1,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
output: ['specifiedInput'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typeOptions: {
|
||||||
|
minValue: 1,
|
||||||
|
loadOptionsMethod: 'getInputs',
|
||||||
|
loadOptionsDependsOn: ['numberInputs'],
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||||
|
description: 'The number of the input to use data of',
|
||||||
|
validateType: 'number',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['chooseBranch'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
const chooseBranchMode = this.getNodeParameter('chooseBranchMode', 0) as string;
|
||||||
|
|
||||||
|
if (chooseBranchMode === 'waitForAll') {
|
||||||
|
const output = this.getNodeParameter('output', 0) as string;
|
||||||
|
|
||||||
|
if (output === 'specifiedInput') {
|
||||||
|
const useDataOfInput = this.getNodeParameter('useDataOfInput', 0) as number;
|
||||||
|
if (useDataOfInput > inputsData.length) {
|
||||||
|
throw new NodeOperationError(this.getNode(), `Input ${useDataOfInput} doesn't exist`, {
|
||||||
|
description: `The node has only ${inputsData.length} inputs, so selecting input ${useDataOfInput} is not possible.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputData = inputsData[useDataOfInput - 1];
|
||||||
|
|
||||||
|
returnData.push.apply(returnData, inputData);
|
||||||
|
}
|
||||||
|
if (output === 'empty') {
|
||||||
|
const pairedItem = [
|
||||||
|
...this.getInputData(0).map((inputData) => inputData.pairedItem),
|
||||||
|
...this.getInputData(1).map((inputData) => inputData.pairedItem),
|
||||||
|
].flatMap(preparePairedItemDataArray);
|
||||||
|
|
||||||
|
returnData.push({
|
||||||
|
json: {},
|
||||||
|
pairedItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeProperties,
|
||||||
|
IPairedItemData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
import type { ClashResolveOptions } from '../../helpers/interfaces';
|
||||||
|
import { clashHandlingProperties, fuzzyCompareProperty } from '../../helpers/descriptions';
|
||||||
|
import { addSuffixToEntriesKeys, selectMergeMethod } from '../../helpers/utils';
|
||||||
|
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
default: {},
|
||||||
|
options: [clashHandlingProperties, fuzzyCompareProperty],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['combine'],
|
||||||
|
combineBy: ['combineAll'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
const clashHandling = this.getNodeParameter(
|
||||||
|
'options.clashHandling.values',
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
) as ClashResolveOptions;
|
||||||
|
|
||||||
|
let input1 = inputsData[0];
|
||||||
|
let input2 = inputsData[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClashResolveOptions,
|
||||||
|
MatchFieldsJoinMode,
|
||||||
|
MatchFieldsOptions,
|
||||||
|
MatchFieldsOutput,
|
||||||
|
} from '../../helpers/interfaces';
|
||||||
|
import { clashHandlingProperties, fuzzyCompareProperty } from '../../helpers/descriptions';
|
||||||
|
import {
|
||||||
|
addSourceField,
|
||||||
|
addSuffixToEntriesKeys,
|
||||||
|
checkInput,
|
||||||
|
checkMatchFieldsInput,
|
||||||
|
findMatches,
|
||||||
|
mergeMatched,
|
||||||
|
} from '../../helpers/utils';
|
||||||
|
|
||||||
|
const multipleMatchesProperty: INodeProperties = {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Fields To Match Have Different Names',
|
||||||
|
name: 'advanced',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description: 'Whether name(s) of field to match are different in input 1 and input 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Fields to Match',
|
||||||
|
name: 'fieldsToMatchString',
|
||||||
|
type: 'string',
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
|
||||||
|
placeholder: 'e.g. id, name',
|
||||||
|
default: '',
|
||||||
|
requiresDataPath: 'multiple',
|
||||||
|
description: 'Specify the fields to use for matching input items',
|
||||||
|
hint: 'Drag or type the input field name',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
advanced: [false],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Fields to Match',
|
||||||
|
name: 'mergeByFields',
|
||||||
|
type: 'fixedCollection',
|
||||||
|
placeholder: 'Add Fields to Match',
|
||||||
|
default: { values: [{ field1: '', field2: '' }] },
|
||||||
|
typeOptions: {
|
||||||
|
multipleValues: true,
|
||||||
|
},
|
||||||
|
description: 'Specify the fields to use for matching input items',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
advanced: [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: 'Drag or type the input field name',
|
||||||
|
requiresDataPath: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'Drag or type the input field name',
|
||||||
|
requiresDataPath: 'single',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Output Type',
|
||||||
|
name: 'joinMode',
|
||||||
|
type: 'options',
|
||||||
|
description: 'How to select the items to send to output',
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Keep Everything',
|
||||||
|
value: 'keepEverything',
|
||||||
|
description: "Items that match merged together, plus 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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
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: {
|
||||||
|
joinMode: ['keepNonMatches'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
...clashHandlingProperties,
|
||||||
|
displayOptions: {
|
||||||
|
hide: {
|
||||||
|
'/joinMode': ['keepMatches', 'keepNonMatches'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...clashHandlingProperties,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'/joinMode': ['keepMatches'],
|
||||||
|
'/outputDataFrom': ['both'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Disable Dot Notation',
|
||||||
|
name: 'disableDotNotation',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Whether to disallow referencing child fields using `parent.child` in the field name',
|
||||||
|
},
|
||||||
|
fuzzyCompareProperty,
|
||||||
|
{
|
||||||
|
...multipleMatchesProperty,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'/joinMode': ['keepMatches'],
|
||||||
|
'/outputDataFrom': ['both'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...multipleMatchesProperty,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'/joinMode': ['enrichInput1', 'enrichInput2', 'keepEverything'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['combine'],
|
||||||
|
combineBy: ['combineByFields'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
const advanced = this.getNodeParameter('advanced', 0) as boolean;
|
||||||
|
let matchFields;
|
||||||
|
|
||||||
|
if (advanced) {
|
||||||
|
matchFields = this.getNodeParameter('mergeByFields.values', 0, []) as IDataObject[];
|
||||||
|
} else {
|
||||||
|
matchFields = (this.getNodeParameter('fieldsToMatchString', 0, '') as string)
|
||||||
|
.split(',')
|
||||||
|
.map((f) => {
|
||||||
|
const field = f.trim();
|
||||||
|
return { field1: field, field2: field };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
matchFields = checkMatchFieldsInput(matchFields);
|
||||||
|
|
||||||
|
const joinMode = this.getNodeParameter('joinMode', 0) as MatchFieldsJoinMode;
|
||||||
|
const outputDataFrom = this.getNodeParameter('outputDataFrom', 0, 'both') as MatchFieldsOutput;
|
||||||
|
const options = this.getNodeParameter('options', 0, {}) as MatchFieldsOptions;
|
||||||
|
|
||||||
|
options.joinMode = joinMode;
|
||||||
|
options.outputDataFrom = outputDataFrom;
|
||||||
|
|
||||||
|
const nodeVersion = this.getNode().typeVersion;
|
||||||
|
|
||||||
|
let input1 = inputsData[0];
|
||||||
|
let input2 = inputsData[1];
|
||||||
|
|
||||||
|
if (nodeVersion < 2.1) {
|
||||||
|
input1 = checkInput(
|
||||||
|
this.getInputData(0),
|
||||||
|
matchFields.map((pair) => pair.field1),
|
||||||
|
options.disableDotNotation || false,
|
||||||
|
'Input 1',
|
||||||
|
);
|
||||||
|
if (!input1) return returnData;
|
||||||
|
|
||||||
|
input2 = checkInput(
|
||||||
|
this.getInputData(1),
|
||||||
|
matchFields.map((pair) => pair.field2),
|
||||||
|
options.disableDotNotation || false,
|
||||||
|
'Input 2',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!input1) return returnData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input1.length === 0 || input2.length === 0) {
|
||||||
|
if (!input1.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input1')
|
||||||
|
return returnData;
|
||||||
|
if (!input2.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input2')
|
||||||
|
return returnData;
|
||||||
|
|
||||||
|
if (joinMode === 'keepMatches') {
|
||||||
|
// Stop the execution
|
||||||
|
return [];
|
||||||
|
} else if (joinMode === 'enrichInput1' && input1.length === 0) {
|
||||||
|
// No data to enrich so stop
|
||||||
|
return [];
|
||||||
|
} else if (joinMode === 'enrichInput2' && input2.length === 0) {
|
||||||
|
// No data to enrich so stop
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
// Return the data of any of the inputs that contains data
|
||||||
|
return [...input1, ...input2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input1) return returnData;
|
||||||
|
|
||||||
|
if (!input2 || !matchFields.length) {
|
||||||
|
if (
|
||||||
|
joinMode === 'keepMatches' ||
|
||||||
|
joinMode === 'keepEverything' ||
|
||||||
|
joinMode === 'enrichInput2'
|
||||||
|
) {
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
|
return input1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = findMatches(input1, input2, matchFields, options);
|
||||||
|
|
||||||
|
if (joinMode === 'keepMatches' || joinMode === 'keepEverything') {
|
||||||
|
let output: INodeExecutionData[] = [];
|
||||||
|
const clashResolveOptions = this.getNodeParameter(
|
||||||
|
'options.clashHandling.values',
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
) as ClashResolveOptions;
|
||||||
|
|
||||||
|
if (outputDataFrom === 'input1') {
|
||||||
|
output = matches.matched.map((match) => match.entry);
|
||||||
|
}
|
||||||
|
if (outputDataFrom === 'input2') {
|
||||||
|
output = matches.matched2;
|
||||||
|
}
|
||||||
|
if (outputDataFrom === 'both') {
|
||||||
|
output = mergeMatched(matches.matched, clashResolveOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joinMode === 'keepEverything') {
|
||||||
|
let unmatched1 = matches.unmatched1;
|
||||||
|
let unmatched2 = matches.unmatched2;
|
||||||
|
if (clashResolveOptions.resolveClash === 'addSuffix') {
|
||||||
|
unmatched1 = addSuffixToEntriesKeys(unmatched1, '1');
|
||||||
|
unmatched2 = addSuffixToEntriesKeys(unmatched2, '2');
|
||||||
|
}
|
||||||
|
output = [...output, ...unmatched1, ...unmatched2];
|
||||||
|
}
|
||||||
|
|
||||||
|
returnData.push(...output);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (joinMode === 'enrichInput1') {
|
||||||
|
if (clashResolveOptions.resolveClash === 'addSuffix') {
|
||||||
|
returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched1, '1'));
|
||||||
|
} else {
|
||||||
|
returnData.push(...mergedEntries, ...matches.unmatched1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (clashResolveOptions.resolveClash === 'addSuffix') {
|
||||||
|
returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched2, '2'));
|
||||||
|
} else {
|
||||||
|
returnData.push(...mergedEntries, ...matches.unmatched2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
NodeExecutionOutput,
|
||||||
|
type IExecuteFunctions,
|
||||||
|
type INodeExecutionData,
|
||||||
|
type INodeProperties,
|
||||||
|
type IPairedItemData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
import type { ClashResolveOptions } from '../../helpers/interfaces';
|
||||||
|
import { clashHandlingProperties, numberInputsProperty } from '../../helpers/descriptions';
|
||||||
|
import { addSuffixToEntriesKeys, selectMergeMethod } from '../../helpers/utils';
|
||||||
|
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [
|
||||||
|
numberInputsProperty,
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
...clashHandlingProperties,
|
||||||
|
default: { values: { resolveClash: 'addSuffix' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Include Any Unpaired Items',
|
||||||
|
name: 'includeUnpaired',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Whether unpaired items should be included in the result when there are differing numbers of items among the inputs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['combine'],
|
||||||
|
combineBy: ['combineByPosition'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
const clashHandling = this.getNodeParameter(
|
||||||
|
'options.clashHandling.values',
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
) as ClashResolveOptions;
|
||||||
|
const includeUnpaired = this.getNodeParameter('options.includeUnpaired', 0, false) as boolean;
|
||||||
|
|
||||||
|
let preferredInputIndex: number;
|
||||||
|
|
||||||
|
if (clashHandling?.resolveClash?.includes('preferInput')) {
|
||||||
|
preferredInputIndex = Number(clashHandling.resolveClash.replace('preferInput', '')) - 1;
|
||||||
|
} else {
|
||||||
|
preferredInputIndex = inputsData.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = inputsData[preferredInputIndex];
|
||||||
|
|
||||||
|
if (clashHandling.resolveClash === 'addSuffix') {
|
||||||
|
for (const [inputIndex, input] of inputsData.entries()) {
|
||||||
|
inputsData[inputIndex] = addSuffixToEntriesKeys(input, String(inputIndex + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let numEntries: number;
|
||||||
|
if (includeUnpaired) {
|
||||||
|
numEntries = Math.max(...inputsData.map((input) => input.length), preferred.length);
|
||||||
|
} else {
|
||||||
|
numEntries = Math.min(...inputsData.map((input) => input.length), preferred.length);
|
||||||
|
if (numEntries === 0) {
|
||||||
|
return new NodeExecutionOutput(
|
||||||
|
[returnData],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Consider enabling "Include Any Unpaired Items" in options or check your inputs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeIntoSingleObject = selectMergeMethod(clashHandling);
|
||||||
|
|
||||||
|
for (let i = 0; i < numEntries; i++) {
|
||||||
|
const preferredEntry = preferred[i] ?? {};
|
||||||
|
const restEntries = inputsData.map((input) => input[i] ?? {});
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
...mergeIntoSingleObject(
|
||||||
|
{},
|
||||||
|
...restEntries.map((entry) => entry.json ?? {}),
|
||||||
|
preferredEntry.json ?? {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const binary = {
|
||||||
|
...merge({}, ...restEntries.map((entry) => entry.binary ?? {}), preferredEntry.binary ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pairedItem = [
|
||||||
|
...restEntries.map((entry) => entry.pairedItem as IPairedItemData).flat(),
|
||||||
|
preferredEntry.pairedItem as IPairedItemData,
|
||||||
|
].filter((item) => item !== undefined);
|
||||||
|
|
||||||
|
returnData.push({ json, binary, pairedItem });
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
137
packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts
Normal file
137
packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeProperties,
|
||||||
|
IPairedItemData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
|
||||||
|
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||||
|
|
||||||
|
import alasql from 'alasql';
|
||||||
|
import type { Database } from 'alasql';
|
||||||
|
|
||||||
|
export const properties: INodeProperties[] = [
|
||||||
|
numberInputsProperty,
|
||||||
|
{
|
||||||
|
displayName: 'Query',
|
||||||
|
name: 'query',
|
||||||
|
type: 'string',
|
||||||
|
default: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
|
||||||
|
noDataExpression: true,
|
||||||
|
description: 'Input data available as tables with corresponding number, e.g. input1, input2',
|
||||||
|
hint: 'Supports <a href="https://github.com/alasql/alasql/wiki/Supported-SQL-statements" target="_blank">most</a> of the SQL-99 language',
|
||||||
|
required: true,
|
||||||
|
typeOptions: {
|
||||||
|
rows: 5,
|
||||||
|
editor: 'sqlEditor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayOptions = {
|
||||||
|
show: {
|
||||||
|
mode: ['combineBySql'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
inputsData: INodeExecutionData[][],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const nodeId = this.getNode().id;
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
const pairedItem: IPairedItemData[] = [];
|
||||||
|
|
||||||
|
const db: typeof Database = new (alasql as any).Database(nodeId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < inputsData.length; i++) {
|
||||||
|
const inputData = inputsData[i];
|
||||||
|
|
||||||
|
inputData.forEach((item, index) => {
|
||||||
|
if (item.pairedItem === undefined) {
|
||||||
|
item.pairedItem = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.pairedItem === 'number') {
|
||||||
|
pairedItem.push({
|
||||||
|
item: item.pairedItem,
|
||||||
|
input: i,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(item.pairedItem)) {
|
||||||
|
const pairedItems = item.pairedItem
|
||||||
|
.filter((p) => p !== undefined)
|
||||||
|
.map((p) => (typeof p === 'number' ? { item: p } : p))
|
||||||
|
.map((p) => {
|
||||||
|
return {
|
||||||
|
item: p.item,
|
||||||
|
input: i,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
pairedItem.push(...pairedItems);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pairedItem.push({
|
||||||
|
item: item.pairedItem.item,
|
||||||
|
input: i,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE input${i + 1}`);
|
||||||
|
db.tables[`input${i + 1}`].data = inputData.map((entry) => entry.json);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(this.getNode(), error, {
|
||||||
|
message: 'Issue while creating table from',
|
||||||
|
description: error.message,
|
||||||
|
itemIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = this.getNodeParameter('query', 0) as string;
|
||||||
|
|
||||||
|
for (const resolvable of getResolvables(query)) {
|
||||||
|
query = query.replace(resolvable, this.evaluateExpression(resolvable, 0) as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: IDataObject[] = db.exec(query);
|
||||||
|
|
||||||
|
for (const item of result) {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
returnData.push(...item.map((json) => ({ json, pairedItem })));
|
||||||
|
} else if (typeof item === 'object') {
|
||||||
|
returnData.push({ json: item, pairedItem });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnData.length) {
|
||||||
|
returnData.push({ json: { success: true }, pairedItem });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let message = '';
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
message = error;
|
||||||
|
} else {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
throw new NodeOperationError(this.getNode(), error, {
|
||||||
|
message: 'Issue while executing query',
|
||||||
|
description: message,
|
||||||
|
itemIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete alasql.databases[nodeId];
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
77
packages/nodes-base/nodes/Merge/v3/actions/mode/index.ts
Normal file
77
packages/nodes-base/nodes/Merge/v3/actions/mode/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import * as append from './append';
|
||||||
|
import * as chooseBranch from './chooseBranch';
|
||||||
|
import * as combineAll from './combineAll';
|
||||||
|
import * as combineByFields from './combineByFields';
|
||||||
|
import * as combineBySql from './combineBySql';
|
||||||
|
import * as combineByPosition from './combineByPosition';
|
||||||
|
|
||||||
|
export { append, chooseBranch, combineAll, combineByFields, combineBySql, combineByPosition };
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Mode',
|
||||||
|
name: 'mode',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Append',
|
||||||
|
value: 'append',
|
||||||
|
description: 'Output items of each input, one after the other',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Combine',
|
||||||
|
value: 'combine',
|
||||||
|
description: 'Merge matching items together',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SQL Query',
|
||||||
|
value: 'combineBySql',
|
||||||
|
description: 'Write a query to do the merge',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Choose Branch',
|
||||||
|
value: 'chooseBranch',
|
||||||
|
description: 'Output data from a specific branch, without modifying it',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'append',
|
||||||
|
description: 'How input data should be merged',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Combine By',
|
||||||
|
name: 'combineBy',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Matching Fields',
|
||||||
|
value: 'combineByFields',
|
||||||
|
description: 'Combine items with the same field values',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Position',
|
||||||
|
value: 'combineByPosition',
|
||||||
|
description: 'Combine items based on their order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'All Possible Combinations',
|
||||||
|
value: 'combineAll',
|
||||||
|
description: 'Every pairing of every two items (cross join)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'combineByFields',
|
||||||
|
description: 'How input data should be merged',
|
||||||
|
displayOptions: {
|
||||||
|
show: { mode: ['combine'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...append.description,
|
||||||
|
...combineAll.description,
|
||||||
|
...combineByFields.description,
|
||||||
|
...combineBySql.description,
|
||||||
|
...combineByPosition.description,
|
||||||
|
...chooseBranch.description,
|
||||||
|
];
|
||||||
7
packages/nodes-base/nodes/Merge/v3/actions/node.type.ts
Normal file
7
packages/nodes-base/nodes/Merge/v3/actions/node.type.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type MergeType =
|
||||||
|
| 'append'
|
||||||
|
| 'combineByFields'
|
||||||
|
| 'combineBySql'
|
||||||
|
| 'combineByPosition'
|
||||||
|
| 'combineAll'
|
||||||
|
| 'chooseBranch';
|
||||||
22
packages/nodes-base/nodes/Merge/v3/actions/router.ts
Normal file
22
packages/nodes-base/nodes/Merge/v3/actions/router.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NodeExecutionOutput, type IExecuteFunctions } from 'n8n-workflow';
|
||||||
|
import type { MergeType } from './node.type';
|
||||||
|
import * as mode from './mode';
|
||||||
|
import { getNodeInputsData } from '../helpers/utils';
|
||||||
|
|
||||||
|
export async function router(this: IExecuteFunctions) {
|
||||||
|
const inputsData = getNodeInputsData.call(this);
|
||||||
|
let operationMode = this.getNodeParameter('mode', 0) as string;
|
||||||
|
|
||||||
|
if (operationMode === 'combine') {
|
||||||
|
const combineBy = this.getNodeParameter('combineBy', 0) as string;
|
||||||
|
operationMode = combineBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnData = await mode[operationMode as MergeType].execute.call(this, inputsData);
|
||||||
|
|
||||||
|
if (returnData instanceof NodeExecutionOutput) {
|
||||||
|
return returnData;
|
||||||
|
} else {
|
||||||
|
return [returnData];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||||
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import * as mode from './mode';
|
||||||
|
|
||||||
|
import { configuredInputs } from '../helpers/utils';
|
||||||
|
|
||||||
|
export const versionDescription: INodeTypeDescription = {
|
||||||
|
displayName: 'Merge',
|
||||||
|
name: 'merge',
|
||||||
|
group: ['transform'],
|
||||||
|
description: 'Merges data of multiple streams once data from both is available',
|
||||||
|
version: [3],
|
||||||
|
defaults: {
|
||||||
|
name: 'Merge',
|
||||||
|
},
|
||||||
|
inputs: `={{(${configuredInputs})($parameter)}}`,
|
||||||
|
outputs: ['main'],
|
||||||
|
// If mode is chooseBranch data from both branches is required
|
||||||
|
// to continue, else data from any input suffices
|
||||||
|
requiredInputs: '={{ $parameter["mode"] === "chooseBranch" ? [0, 1] : 1 }}',
|
||||||
|
properties: [...mode.description],
|
||||||
|
};
|
||||||
125
packages/nodes-base/nodes/Merge/v3/helpers/descriptions.ts
Normal file
125
packages/nodes-base/nodes/Merge/v3/helpers/descriptions.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const fuzzyCompareProperty: INodeProperties = {
|
||||||
|
displayName: 'Fuzzy Compare',
|
||||||
|
name: 'fuzzyCompare',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
"Whether to tolerate small type differences when comparing fields. E.g. the number 3 and the string '3' are treated as the same.",
|
||||||
|
};
|
||||||
|
export const numberInputsProperty: INodeProperties = {
|
||||||
|
displayName: 'Number of Inputs',
|
||||||
|
name: 'numberInputs',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
default: 2,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: '2',
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '3',
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '4',
|
||||||
|
value: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '5',
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '6',
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '7',
|
||||||
|
value: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '8',
|
||||||
|
value: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '9',
|
||||||
|
value: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '10',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
validateType: 'number',
|
||||||
|
description:
|
||||||
|
'The number of data inputs you want to merge. The node waits for all connected inputs to be executed.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clashHandlingProperties: INodeProperties = {
|
||||||
|
displayName: 'Clash Handling',
|
||||||
|
name: 'clashHandling',
|
||||||
|
type: 'fixedCollection',
|
||||||
|
default: {
|
||||||
|
values: { resolveClash: 'preferLast', mergeMode: 'deepMerge', overrideEmpty: false },
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Values',
|
||||||
|
name: 'values',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||||
|
displayName: 'When Field Values Clash',
|
||||||
|
name: 'resolveClash',
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-from-dynamic-options
|
||||||
|
type: 'options',
|
||||||
|
default: '',
|
||||||
|
typeOptions: {
|
||||||
|
loadOptionsMethod: 'getResolveClashOptions',
|
||||||
|
loadOptionsDependsOn: ['numberInputs'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: [{ _cnd: { not: 'addSuffix' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: [{ _cnd: { not: 'addSuffix' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
27
packages/nodes-base/nodes/Merge/v3/helpers/interfaces.ts
Normal file
27
packages/nodes-base/nodes/Merge/v3/helpers/interfaces.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
type MultipleMatches = 'all' | 'first';
|
||||||
|
|
||||||
|
export type MatchFieldsOptions = {
|
||||||
|
joinMode: MatchFieldsJoinMode;
|
||||||
|
outputDataFrom: MatchFieldsOutput;
|
||||||
|
multipleMatches: MultipleMatches;
|
||||||
|
disableDotNotation: boolean;
|
||||||
|
fuzzyCompare?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClashMergeMode = 'deepMerge' | 'shallowMerge';
|
||||||
|
type ClashResolveMode = 'addSuffix' | 'preferInput1' | 'preferLast';
|
||||||
|
|
||||||
|
export type ClashResolveOptions = {
|
||||||
|
resolveClash: ClashResolveMode;
|
||||||
|
mergeMode: ClashMergeMode;
|
||||||
|
overrideEmpty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchFieldsOutput = 'both' | 'input1' | 'input2';
|
||||||
|
|
||||||
|
export type MatchFieldsJoinMode =
|
||||||
|
| 'keepEverything'
|
||||||
|
| 'keepMatches'
|
||||||
|
| 'keepNonMatches'
|
||||||
|
| 'enrichInput2'
|
||||||
|
| 'enrichInput1';
|
||||||
389
packages/nodes-base/nodes/Merge/v3/helpers/utils.ts
Normal file
389
packages/nodes-base/nodes/Merge/v3/helpers/utils.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { ApplicationError, NodeConnectionType, NodeHelpers } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
GenericValue,
|
||||||
|
IBinaryKeyData,
|
||||||
|
IDataObject,
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeParameters,
|
||||||
|
IPairedItemData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import assign from 'lodash/assign';
|
||||||
|
import assignWith from 'lodash/assignWith';
|
||||||
|
import get from 'lodash/get';
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
import mergeWith from 'lodash/mergeWith';
|
||||||
|
|
||||||
|
import { fuzzyCompare, preparePairedItemDataArray } from '@utils/utilities';
|
||||||
|
|
||||||
|
import type { ClashResolveOptions, MatchFieldsJoinMode, MatchFieldsOptions } from './interfaces';
|
||||||
|
|
||||||
|
type PairToMatch = {
|
||||||
|
field1: string;
|
||||||
|
field2: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryMatches = {
|
||||||
|
entry: INodeExecutionData;
|
||||||
|
matches: INodeExecutionData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompareFunction = <T, U>(a: T, b: U) => boolean;
|
||||||
|
|
||||||
|
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,
|
||||||
|
isEntriesEqual: CompareFunction,
|
||||||
|
) {
|
||||||
|
return data.reduce((acc, entry2, i) => {
|
||||||
|
if (entry2 === undefined) return acc;
|
||||||
|
|
||||||
|
for (const key of Object.keys(lookup)) {
|
||||||
|
const expectedValue = lookup[key];
|
||||||
|
let entry2FieldValue;
|
||||||
|
|
||||||
|
if (disableDotNotation) {
|
||||||
|
entry2FieldValue = entry2.json[key];
|
||||||
|
} else {
|
||||||
|
entry2FieldValue = get(entry2.json, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEntriesEqual(expectedValue, entry2FieldValue)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc.concat({
|
||||||
|
entry: entry2,
|
||||||
|
index: i,
|
||||||
|
});
|
||||||
|
}, [] as IDataObject[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstMatch(
|
||||||
|
data: INodeExecutionData[],
|
||||||
|
lookup: IDataObject,
|
||||||
|
disableDotNotation: boolean,
|
||||||
|
isEntriesEqual: CompareFunction,
|
||||||
|
) {
|
||||||
|
const index = data.findIndex((entry2) => {
|
||||||
|
if (entry2 === undefined) return false;
|
||||||
|
|
||||||
|
for (const key of Object.keys(lookup)) {
|
||||||
|
const expectedValue = lookup[key];
|
||||||
|
let entry2FieldValue;
|
||||||
|
|
||||||
|
if (disableDotNotation) {
|
||||||
|
entry2FieldValue = entry2.json[key];
|
||||||
|
} else {
|
||||||
|
entry2FieldValue = get(entry2.json, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEntriesEqual(expectedValue, 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,
|
||||||
|
) {
|
||||||
|
const data1 = [...input1];
|
||||||
|
const data2 = [...input2];
|
||||||
|
|
||||||
|
const isEntriesEqual = fuzzyCompare(options.fuzzyCompare as boolean);
|
||||||
|
const disableDotNotation = options.disableDotNotation || 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<number>();
|
||||||
|
|
||||||
|
matchesLoop: for (const entry1 of data1) {
|
||||||
|
const lookup: IDataObject = {};
|
||||||
|
|
||||||
|
fieldsToMatch.forEach((matchCase) => {
|
||||||
|
let valueToCompare;
|
||||||
|
if (disableDotNotation) {
|
||||||
|
valueToCompare = entry1.json[matchCase.field1];
|
||||||
|
} else {
|
||||||
|
valueToCompare = get(entry1.json, matchCase.field1);
|
||||||
|
}
|
||||||
|
lookup[matchCase.field2] = 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, isEntriesEqual)
|
||||||
|
: findFirstMatch(data2, lookup, disableDotNotation, isEntriesEqual);
|
||||||
|
|
||||||
|
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 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[]) => {
|
||||||
|
const targetCopy = Object.assign({}, target);
|
||||||
|
return mergeWith(targetCopy, ...source, customizer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mergeMode === 'shallowMerge') {
|
||||||
|
return (target: IDataObject, ...source: IDataObject[]) => {
|
||||||
|
const targetCopy = Object.assign({}, target);
|
||||||
|
return assignWith(targetCopy, ...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 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 = {};
|
||||||
|
let pairedItem: IPairedItemData[] = [];
|
||||||
|
|
||||||
|
if (resolveClash === 'addSuffix') {
|
||||||
|
const suffix1 = '1';
|
||||||
|
const suffix2 = '2';
|
||||||
|
|
||||||
|
[entry] = addSuffixToEntriesKeys([entry], suffix1);
|
||||||
|
matches = addSuffixToEntriesKeys(matches, suffix2);
|
||||||
|
|
||||||
|
json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((item) => item.json));
|
||||||
|
binary = mergeIntoSingleObject(
|
||||||
|
{ ...entry.binary },
|
||||||
|
...matches.map((item) => item.binary as IDataObject),
|
||||||
|
);
|
||||||
|
pairedItem = [
|
||||||
|
...preparePairedItemDataArray(entry.pairedItem),
|
||||||
|
...matches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const preferInput1 = 'preferInput1';
|
||||||
|
const preferLast = 'preferLast';
|
||||||
|
|
||||||
|
if (resolveClash === undefined) {
|
||||||
|
if (joinMode !== 'enrichInput2') {
|
||||||
|
resolveClash = 'preferLast';
|
||||||
|
} else {
|
||||||
|
resolveClash = 'preferInput1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveClash === preferInput1) {
|
||||||
|
const [firstMatch, ...restMatches] = matches;
|
||||||
|
json = mergeIntoSingleObject(
|
||||||
|
{ ...firstMatch.json },
|
||||||
|
...restMatches.map((item) => item.json),
|
||||||
|
entry.json,
|
||||||
|
);
|
||||||
|
binary = mergeIntoSingleObject(
|
||||||
|
{ ...firstMatch.binary },
|
||||||
|
...restMatches.map((item) => item.binary as IDataObject),
|
||||||
|
entry.binary as IDataObject,
|
||||||
|
);
|
||||||
|
|
||||||
|
pairedItem = [
|
||||||
|
...preparePairedItemDataArray(firstMatch.pairedItem),
|
||||||
|
...restMatches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||||
|
...preparePairedItemDataArray(entry.pairedItem),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveClash === preferLast) {
|
||||||
|
json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((item) => item.json));
|
||||||
|
binary = mergeIntoSingleObject(
|
||||||
|
{ ...entry.binary },
|
||||||
|
...matches.map((item) => item.binary as IDataObject),
|
||||||
|
);
|
||||||
|
pairedItem = [
|
||||||
|
...preparePairedItemDataArray(entry.pairedItem),
|
||||||
|
...matches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
returnData.push({
|
||||||
|
json,
|
||||||
|
binary,
|
||||||
|
pairedItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMatchFieldsInput(data: IDataObject[]) {
|
||||||
|
if (data.length === 1 && data[0].field1 === '' && data[0].field2 === '') {
|
||||||
|
throw new ApplicationError(
|
||||||
|
'You need to define at least one pair of fields in "Fields to Match" to match on',
|
||||||
|
{ level: 'warning' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [index, pair] of data.entries()) {
|
||||||
|
if (pair.field1 === '' || pair.field2 === '') {
|
||||||
|
throw new ApplicationError(
|
||||||
|
`You need to define both fields in "Fields to Match" for pair ${index + 1},
|
||||||
|
field 1 = '${pair.field1}'
|
||||||
|
field 2 = '${pair.field2}'`,
|
||||||
|
{ level: 'warning' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 ApplicationError(
|
||||||
|
`Field '${field}' is not present in any of items in '${inputLabel}'`,
|
||||||
|
{ level: 'warning' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSourceField(data: INodeExecutionData[], sourceField: string) {
|
||||||
|
return data.map((entry) => {
|
||||||
|
const json = {
|
||||||
|
...entry.json,
|
||||||
|
_source: sourceField,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
json,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configuredInputs = (parameters: INodeParameters) => {
|
||||||
|
return Array.from({ length: (parameters.numberInputs as number) || 2 }, (_, i) => ({
|
||||||
|
type: `${NodeConnectionType.Main}`,
|
||||||
|
displayName: `Input ${(i + 1).toString()}`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getNodeInputsData(this: IExecuteFunctions) {
|
||||||
|
const returnData: INodeExecutionData[][] = [];
|
||||||
|
|
||||||
|
const inputs = NodeHelpers.getConnectionTypes(this.getNodeInputs()).filter(
|
||||||
|
(type) => type === NodeConnectionType.Main,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
try {
|
||||||
|
returnData.push(this.getInputData(i) ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
returnData.push([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
1
packages/nodes-base/nodes/Merge/v3/methods/index.ts
Normal file
1
packages/nodes-base/nodes/Merge/v3/methods/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * as loadOptions from './loadOptions';
|
||||||
49
packages/nodes-base/nodes/Merge/v3/methods/loadOptions.ts
Normal file
49
packages/nodes-base/nodes/Merge/v3/methods/loadOptions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export async function getResolveClashOptions(
|
||||||
|
this: ILoadOptionsFunctions,
|
||||||
|
): Promise<INodePropertyOptions[]> {
|
||||||
|
const numberOfInputs = this.getNodeParameter('numberInputs', 2) as number;
|
||||||
|
|
||||||
|
if (numberOfInputs <= 2) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Always Add Input Number to Field Names',
|
||||||
|
value: 'addSuffix',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Prefer Input 1 Version',
|
||||||
|
value: 'preferInput1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Prefer Input 2 Version',
|
||||||
|
value: 'preferLast',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Always Add Input Number to Field Names',
|
||||||
|
value: 'addSuffix',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Use Earliest Version',
|
||||||
|
value: 'preferInput1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function getInputs(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||||
|
const numberOfInputs = this.getNodeParameter('numberInputs', 2) as number;
|
||||||
|
|
||||||
|
const returnData: INodePropertyOptions[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfInputs; i++) {
|
||||||
|
returnData.push({
|
||||||
|
name: `${i + 1}`,
|
||||||
|
value: i + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
@@ -844,6 +844,7 @@
|
|||||||
"@n8n/imap": "workspace:*",
|
"@n8n/imap": "workspace:*",
|
||||||
"@n8n/vm2": "3.9.20",
|
"@n8n/vm2": "3.9.20",
|
||||||
"amqplib": "0.10.3",
|
"amqplib": "0.10.3",
|
||||||
|
"alasql": "^4.4.0",
|
||||||
"aws4": "1.11.0",
|
"aws4": "1.11.0",
|
||||||
"basic-auth": "2.0.1",
|
"basic-auth": "2.0.1",
|
||||||
"change-case": "4.1.2",
|
"change-case": "4.1.2",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.executeWorkflo
|
|||||||
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
||||||
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
|
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
|
||||||
export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem';
|
export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem';
|
||||||
|
export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge';
|
||||||
|
|
||||||
export const STARTING_NODE_TYPES = [
|
export const STARTING_NODE_TYPES = [
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
|
|||||||
@@ -876,6 +876,7 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
|
|||||||
inputIndex?: number,
|
inputIndex?: number,
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
getInputData(inputIndex?: number, inputName?: string): INodeExecutionData[];
|
getInputData(inputIndex?: number, inputName?: string): INodeExecutionData[];
|
||||||
|
getNodeInputs(): INodeInputConfiguration[];
|
||||||
getNodeOutputs(): INodeOutputConfiguration[];
|
getNodeOutputs(): INodeOutputConfiguration[];
|
||||||
putExecutionToWait(waitTill: Date): Promise<void>;
|
putExecutionToWait(waitTill: Date): Promise<void>;
|
||||||
sendMessageToUI(message: any): void;
|
sendMessageToUI(message: any): void;
|
||||||
@@ -1760,11 +1761,12 @@ export interface INodeInputFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface INodeInputConfiguration {
|
export interface INodeInputConfiguration {
|
||||||
|
category?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
maxConnections?: number;
|
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
filter?: INodeInputFilter;
|
|
||||||
type: ConnectionTypes;
|
type: ConnectionTypes;
|
||||||
|
filter?: INodeInputFilter;
|
||||||
|
maxConnections?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INodeOutputConfiguration {
|
export interface INodeOutputConfiguration {
|
||||||
@@ -2320,6 +2322,7 @@ export interface INodeGraphItem {
|
|||||||
agent?: string; //@n8n/n8n-nodes-langchain.agent
|
agent?: string; //@n8n/n8n-nodes-langchain.agent
|
||||||
prompts?: IDataObject[] | IDataObject; //ai node's prompts, cloud only
|
prompts?: IDataObject[] | IDataObject; //ai node's prompts, cloud only
|
||||||
toolSettings?: IDataObject; //various langchain tool's settings
|
toolSettings?: IDataObject; //various langchain tool's settings
|
||||||
|
sql?: string; //merge node combineBySql, cloud only
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INodeNameIndex {
|
export interface INodeNameIndex {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
HTTP_REQUEST_NODE_TYPE,
|
HTTP_REQUEST_NODE_TYPE,
|
||||||
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
|
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
LANGCHAIN_CUSTOM_TOOLS,
|
LANGCHAIN_CUSTOM_TOOLS,
|
||||||
|
MERGE_NODE_TYPE,
|
||||||
OPENAI_LANGCHAIN_NODE_TYPE,
|
OPENAI_LANGCHAIN_NODE_TYPE,
|
||||||
STICKY_NODE_TYPE,
|
STICKY_NODE_TYPE,
|
||||||
WEBHOOK_NODE_TYPE,
|
WEBHOOK_NODE_TYPE,
|
||||||
@@ -206,6 +207,8 @@ export function generateNodesGraph(
|
|||||||
|
|
||||||
if (node.type === AGENT_LANGCHAIN_NODE_TYPE) {
|
if (node.type === AGENT_LANGCHAIN_NODE_TYPE) {
|
||||||
nodeItem.agent = (node.parameters.agent as string) ?? 'conversationalAgent';
|
nodeItem.agent = (node.parameters.agent as string) ?? 'conversationalAgent';
|
||||||
|
} else if (node.type === MERGE_NODE_TYPE) {
|
||||||
|
nodeItem.operation = node.parameters.mode as string;
|
||||||
} else if (node.type === HTTP_REQUEST_NODE_TYPE && node.typeVersion === 1) {
|
} else if (node.type === HTTP_REQUEST_NODE_TYPE && node.typeVersion === 1) {
|
||||||
try {
|
try {
|
||||||
nodeItem.domain = new URL(node.parameters.url as string).hostname;
|
nodeItem.domain = new URL(node.parameters.url as string).hostname;
|
||||||
@@ -398,6 +401,10 @@ export function generateNodesGraph(
|
|||||||
nodeItem.prompts =
|
nodeItem.prompts =
|
||||||
(((node.parameters?.messages as IDataObject) ?? {}).messageValues as IDataObject[]) ?? [];
|
(((node.parameters?.messages as IDataObject) ?? {}).messageValues as IDataObject[]) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === MERGE_NODE_TYPE && node.parameters?.operation === 'combineBySql') {
|
||||||
|
nodeItem.sql = node.parameters?.query as string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeGraph.nodes[index.toString()] = nodeItem;
|
nodeGraph.nodes[index.toString()] = nodeItem;
|
||||||
|
|||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -1368,6 +1368,9 @@ importers:
|
|||||||
'@n8n/vm2':
|
'@n8n/vm2':
|
||||||
specifier: 3.9.20
|
specifier: 3.9.20
|
||||||
version: 3.9.20
|
version: 3.9.20
|
||||||
|
alasql:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0(encoding@0.1.13)
|
||||||
amqplib:
|
amqplib:
|
||||||
specifier: 0.10.3
|
specifier: 0.10.3
|
||||||
version: 0.10.3
|
version: 0.10.3
|
||||||
@@ -6650,6 +6653,11 @@ packages:
|
|||||||
ajv@8.12.0:
|
ajv@8.12.0:
|
||||||
resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
|
resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
|
||||||
|
|
||||||
|
alasql@4.4.0:
|
||||||
|
resolution: {integrity: sha512-EQOk3NEvKcQxoYeY0d4ePF0VHAcljx3pn5ZkEowMPRThjWXyDc/VHYqC8Sg+6BH2ZhKZBdeRqlvlgZmhfGBtDA==}
|
||||||
|
engines: {node: '>=15'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
amqplib@0.10.3:
|
amqplib@0.10.3:
|
||||||
resolution: {integrity: sha512-UHmuSa7n8vVW/a5HGh2nFPqAEr8+cD4dEZ6u9GjP91nHfr1a54RyAKyra7Sb5NH7NBKOUlyQSMXIp0qAixKexw==}
|
resolution: {integrity: sha512-UHmuSa7n8vVW/a5HGh2nFPqAEr8+cD4dEZ6u9GjP91nHfr1a54RyAKyra7Sb5NH7NBKOUlyQSMXIp0qAixKexw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -7580,6 +7588,9 @@ packages:
|
|||||||
cross-fetch@3.1.8:
|
cross-fetch@3.1.8:
|
||||||
resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==}
|
resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==}
|
||||||
|
|
||||||
|
cross-fetch@4.0.0:
|
||||||
|
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
|
||||||
|
|
||||||
cross-spawn@4.0.2:
|
cross-spawn@4.0.2:
|
||||||
resolution: {integrity: sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==}
|
resolution: {integrity: sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==}
|
||||||
|
|
||||||
@@ -14279,6 +14290,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@16.2.0:
|
||||||
|
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
yargs@17.0.1:
|
yargs@17.0.1:
|
||||||
resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==}
|
resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -21043,6 +21058,13 @@ snapshots:
|
|||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
uri-js: 4.4.1
|
uri-js: 4.4.1
|
||||||
|
|
||||||
|
alasql@4.4.0(encoding@0.1.13):
|
||||||
|
dependencies:
|
||||||
|
cross-fetch: 4.0.0(encoding@0.1.13)
|
||||||
|
yargs: 16.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
amqplib@0.10.3:
|
amqplib@0.10.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@acuminous/bitsyntax': 0.1.2
|
'@acuminous/bitsyntax': 0.1.2
|
||||||
@@ -22100,6 +22122,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
|
cross-fetch@4.0.0(encoding@0.1.13):
|
||||||
|
dependencies:
|
||||||
|
node-fetch: 2.7.0(encoding@0.1.13)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
cross-spawn@4.0.2:
|
cross-spawn@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 4.1.5
|
lru-cache: 4.1.5
|
||||||
@@ -29900,6 +29928,16 @@ snapshots:
|
|||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
|
yargs@16.2.0:
|
||||||
|
dependencies:
|
||||||
|
cliui: 7.0.4
|
||||||
|
escalade: 3.1.1
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
string-width: 4.2.3
|
||||||
|
y18n: 5.0.8
|
||||||
|
yargs-parser: 20.2.9
|
||||||
|
|
||||||
yargs@17.0.1:
|
yargs@17.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 7.0.4
|
cliui: 7.0.4
|
||||||
|
|||||||
Reference in New Issue
Block a user