mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(Merge Node): overhaul of merge node
This commit is contained in:
parent
b6c1187922
commit
f1a569791d
|
@ -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',
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
import { MergeV1 } from './v1/MergeV1.node';
|
||||
|
||||
import { MergeV2 } from './v2/MergeV2.node';
|
||||
|
||||
export class Merge extends NodeVersionedType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
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',
|
||||
},
|
||||
],
|
||||
defaultVersion: 2,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
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,
|
||||
],
|
||||
const nodeVersions: INodeVersionedType['nodeVersions'] = {
|
||||
1: new MergeV1(baseDescription),
|
||||
2: new MergeV2(baseDescription),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
482
packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts
Normal file
482
packages/nodes-base/nodes/Merge/v1/MergeV1.node.ts
Normal file
|
@ -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<INodeExecutionData[][]> {
|
||||
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];
|
||||
}
|
||||
}
|
364
packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts
Normal file
364
packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts
Normal file
|
@ -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<number>();
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
511
packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts
Normal file
511
packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts
Normal file
|
@ -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<INodeExecutionData[][]> {
|
||||
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];
|
||||
}
|
||||
}
|
198
packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts
Normal file
198
packages/nodes-base/nodes/Merge/v2/OptionsDescription.ts
Normal file
|
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
Loading…
Reference in a new issue