feat(Merge Node)!: node tweaks n8n-4939 (#4321)

BREAKING CHANGE: The Merge node list of operations was rearranged.

Merge node: 'Combine' operation was added with 'Combine Mode' option, operations 'Merge By Fields', 'Merge By Position' and 'Multiplex' placed under 'Combine Mode' option.
To update -go to the workflows that use the Merge node, select 'Combine' operation and then choose an option from 'Combination Mode' that matches an operation that was previously used. If you want to continue even on error, you can set "Continue on Fail" to true.
This commit is contained in:
Michael Kret 2022-10-13 17:14:47 +03:00 committed by GitHub
parent 1db4fa2bf8
commit 6a37071350
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 265 additions and 185 deletions

View file

@ -2,6 +2,20 @@
This list shows all the versions which include breaking changes and how to upgrade. This list shows all the versions which include breaking changes and how to upgrade.
## 0.198.0
### What changed?
The Merge node list of operations was rearranged.
### When is action necessary?
If you are using the overhauled Merge node and 'Merge By Fields', 'Merge By Position' or 'Multiplex' operation.
### How to upgrade:
Go to the workflows that use the Merge node, select 'Combine' operation and then choose an option from 'Combination Mode' that matches an operation that was previously used. If you want to continue even on error, you can set "Continue on Fail" to true.
## 0.171.0 ## 0.171.0
### What changed? ### What changed?

View file

@ -35,6 +35,7 @@ type MultipleMatches = 'all' | 'first';
export type MatchFieldsOutput = 'both' | 'input1' | 'input2'; export type MatchFieldsOutput = 'both' | 'input1' | 'input2';
export type MatchFieldsJoinMode = export type MatchFieldsJoinMode =
| 'keepEverything'
| 'keepMatches' | 'keepMatches'
| 'keepNonMatches' | 'keepNonMatches'
| 'enrichInput2' | 'enrichInput2'
@ -294,12 +295,16 @@ export function selectMergeMethod(clashResolveOptions: ClashResolveOptions) {
} }
} }
if (mergeMode === 'deepMerge') { if (mergeMode === 'deepMerge') {
return (target: IDataObject, ...source: IDataObject[]) => return (target: IDataObject, ...source: IDataObject[]) => {
mergeWith(target, ...source, customizer); const targetCopy = Object.assign({}, target);
return mergeWith(targetCopy, ...source, customizer);
};
} }
if (mergeMode === 'shallowMerge') { if (mergeMode === 'shallowMerge') {
return (target: IDataObject, ...source: IDataObject[]) => return (target: IDataObject, ...source: IDataObject[]) => {
assignWith(target, ...source, customizer); const targetCopy = Object.assign({}, target);
return assignWith(targetCopy, ...source, customizer);
};
} }
} else { } else {
if (mergeMode === 'deepMerge') { if (mergeMode === 'deepMerge') {

View file

@ -49,13 +49,31 @@ const versionDescription: INodeTypeDescription = {
displayName: 'Mode', displayName: 'Mode',
name: 'mode', name: 'mode',
type: 'options', type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [ options: [
{ {
name: 'Append', name: 'Append',
value: 'append', value: 'append',
description: 'All items of input 1, then all items of input 2', description: 'All items of input 1, then all items of input 2',
}, },
{
name: 'Combine',
value: 'combine',
description: 'Merge matching items together',
},
{
name: 'Choose Branch',
value: 'chooseBranch',
description: 'Output input data, without modifying it',
},
],
default: 'append',
description: 'How data of branches should be merged',
},
{
displayName: 'Combination Mode',
name: 'combinationMode',
type: 'options',
options: [
{ {
name: 'Merge By Fields', name: 'Merge By Fields',
value: 'mergeByFields', value: 'mergeByFields',
@ -71,16 +89,14 @@ const versionDescription: INodeTypeDescription = {
value: 'multiplex', value: 'multiplex',
description: 'All possible item combinations (cross join)', description: 'All possible item combinations (cross join)',
}, },
{
name: 'Choose Branch',
value: 'chooseBranch',
description: 'Output input data, without modifying it',
},
], ],
default: 'append', default: 'mergeByFields',
description: 'How data of branches should be merged', displayOptions: {
show: {
mode: ['combine'],
},
},
}, },
// mergeByFields ------------------------------------------------------------------ // mergeByFields ------------------------------------------------------------------
{ {
displayName: 'Fields to Match', displayName: 'Fields to Match',
@ -119,7 +135,8 @@ const versionDescription: INodeTypeDescription = {
], ],
displayOptions: { displayOptions: {
show: { show: {
mode: ['mergeByFields'], mode: ['combine'],
combinationMode: ['mergeByFields'],
}, },
}, },
}, },
@ -127,6 +144,7 @@ const versionDescription: INodeTypeDescription = {
displayName: 'Output Type', displayName: 'Output Type',
name: 'joinMode', name: 'joinMode',
type: 'options', type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [ options: [
{ {
name: 'Keep Matches', name: 'Keep Matches',
@ -136,7 +154,12 @@ const versionDescription: INodeTypeDescription = {
{ {
name: 'Keep Non-Matches', name: 'Keep Non-Matches',
value: 'keepNonMatches', value: 'keepNonMatches',
description: "Items that don't match (outer join)", 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', name: 'Enrich Input 1',
@ -152,7 +175,8 @@ const versionDescription: INodeTypeDescription = {
default: 'keepMatches', default: 'keepMatches',
displayOptions: { displayOptions: {
show: { show: {
mode: ['mergeByFields'], mode: ['combine'],
combinationMode: ['mergeByFields'],
}, },
}, },
}, },
@ -177,7 +201,8 @@ const versionDescription: INodeTypeDescription = {
default: 'both', default: 'both',
displayOptions: { displayOptions: {
show: { show: {
mode: ['mergeByFields'], mode: ['combine'],
combinationMode: ['mergeByFields'],
joinMode: ['keepMatches'], joinMode: ['keepMatches'],
}, },
}, },
@ -203,7 +228,8 @@ const versionDescription: INodeTypeDescription = {
default: 'both', default: 'both',
displayOptions: { displayOptions: {
show: { show: {
mode: ['mergeByFields'], mode: ['combine'],
combinationMode: ['mergeByFields'],
joinMode: ['keepNonMatches'], joinMode: ['keepNonMatches'],
}, },
}, },
@ -219,11 +245,6 @@ const versionDescription: INodeTypeDescription = {
name: 'Wait for Both Inputs to Arrive', name: 'Wait for Both Inputs to Arrive',
value: 'waitForBoth', value: 'waitForBoth',
}, },
// not MVP
// {
// name: 'Immediately Pass the First Input to Arrive',
// value: 'passFirst',
// },
], ],
default: 'waitForBoth', default: 'waitForBoth',
displayOptions: { displayOptions: {
@ -284,36 +305,116 @@ export class MergeV2 implements INodeType {
} }
} }
if (mode === 'multiplex') { if (mode === 'combine') {
const clashHandling = this.getNodeParameter( const combinationMode = this.getNodeParameter('combinationMode', 0) as string;
'options.clashHandling.values',
0,
{},
) as ClashResolveOptions;
let input1 = this.getInputData(0); if (combinationMode === 'multiplex') {
let input2 = this.getInputData(1); const clashHandling = this.getNodeParameter(
'options.clashHandling.values',
0,
{},
) as ClashResolveOptions;
if (clashHandling.resolveClash === 'preferInput1') { let input1 = this.getInputData(0);
[input1, input2] = [input2, input1]; let input2 = this.getInputData(1);
}
if (clashHandling.resolveClash === 'addSuffix') { if (clashHandling.resolveClash === 'preferInput1') {
input1 = addSuffixToEntriesKeys(input1, '1'); [input1, input2] = [input2, input1];
input2 = addSuffixToEntriesKeys(input2, '2'); }
}
const mergeIntoSingleObject = selectMergeMethod(clashHandling); if (clashHandling.resolveClash === 'addSuffix') {
input1 = addSuffixToEntriesKeys(input1, '1');
input2 = addSuffixToEntriesKeys(input2, '2');
}
if (!input1 || !input2) { 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]; return [returnData];
} }
let entry1: INodeExecutionData; if (combinationMode === 'mergeByPosition') {
let entry2: INodeExecutionData; 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];
for (entry1 of input1) {
for (entry2 of input2) {
returnData.push({ returnData.push({
json: { json: {
...mergeIntoSingleObject(entry1.json, entry2.json), ...mergeIntoSingleObject(entry1.json, entry2.json),
@ -328,162 +429,115 @@ export class MergeV2 implements INodeType {
}); });
} }
} }
return [returnData];
}
if (mode === 'mergeByPosition') { if (combinationMode === 'mergeByFields') {
const clashHandling = this.getNodeParameter( const matchFields = checkMatchFieldsInput(
'options.clashHandling.values', this.getNodeParameter('mergeByFields.values', 0, []) as IDataObject[],
0, );
{},
) as ClashResolveOptions;
const includeUnpaired = this.getNodeParameter('options.includeUnpaired', 0, false) as boolean;
let input1 = this.getInputData(0); const joinMode = this.getNodeParameter('joinMode', 0) as MatchFieldsJoinMode;
let input2 = this.getInputData(1); const outputDataFrom = this.getNodeParameter(
'outputDataFrom',
0,
'both',
) as MatchFieldsOutput;
const options = this.getNodeParameter('options', 0, {}) as MatchFieldsOptions;
if (clashHandling.resolveClash === 'preferInput1') { options.joinMode = joinMode;
[input1, input2] = [input2, input1]; options.outputDataFrom = outputDataFrom;
}
if (clashHandling.resolveClash === 'addSuffix') { const input1 = checkInput(
input1 = addSuffixToEntriesKeys(input1, '1'); this.getInputData(0),
input2 = addSuffixToEntriesKeys(input2, '2'); matchFields.map((pair) => pair.field1 as string),
} (options.disableDotNotation as boolean) || false,
'Input 1',
);
if (!input1) return [returnData];
if (input1 === undefined || input1.length === 0) { const input2 = checkInput(
if (includeUnpaired) { this.getInputData(1),
return [input2]; matchFields.map((pair) => pair.field2 as string),
} (options.disableDotNotation as boolean) || false,
return [returnData]; 'Input 2',
} );
if (input2 === undefined || input2.length === 0) { if (!input2 || !matchFields.length) {
if (includeUnpaired) { if (
joinMode === 'keepMatches' ||
joinMode === 'keepEverything' ||
joinMode === 'enrichInput2'
) {
return [returnData];
}
return [input1]; return [input1];
} }
return [returnData];
}
let numEntries: number; const matches = findMatches(input1, input2, matchFields, options);
if (includeUnpaired) {
numEntries = Math.max(input1.length, input2.length);
} else {
numEntries = Math.min(input1.length, input2.length);
}
const mergeIntoSingleObject = selectMergeMethod(clashHandling); if (joinMode === 'keepMatches' || joinMode === 'keepEverything') {
let output: INodeExecutionData[] = [];
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 === 'mergeByFields') {
const matchFields = checkMatchFieldsInput(
this.getNodeParameter('mergeByFields.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( const clashResolveOptions = this.getNodeParameter(
'options.clashHandling.values', 'options.clashHandling.values',
0, 0,
{}, {},
) as ClashResolveOptions; ) as ClashResolveOptions;
const mergedEntries = mergeMatched(matches.matched, 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);
}
returnData.push(...mergedEntries); 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 (joinMode === 'keepNonMatches') {
if (outputDataFrom === 'input1') { if (outputDataFrom === 'input1') {
return [matches.unmatched1]; 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 (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') { if (joinMode === 'enrichInput1' || joinMode === 'enrichInput2') {
const clashResolveOptions = this.getNodeParameter( const clashResolveOptions = this.getNodeParameter(
'options.clashHandling.values', 'options.clashHandling.values',
0, 0,
{}, {},
) as ClashResolveOptions; ) as ClashResolveOptions;
const mergedEntries = mergeMatched(matches.matched, clashResolveOptions, joinMode); const mergedEntries = mergeMatched(matches.matched, clashResolveOptions, joinMode);
if (clashResolveOptions.resolveClash === 'addSuffix') { if (clashResolveOptions.resolveClash === 'addSuffix') {
const suffix = joinMode === 'enrichInput1' ? '1' : '2'; const suffix = joinMode === 'enrichInput1' ? '1' : '2';
returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched1, suffix)); returnData.push(
} else { ...mergedEntries,
returnData.push(...mergedEntries, ...matches.unmatched1); ...addSuffixToEntriesKeys(matches.unmatched1, suffix),
);
} else {
returnData.push(...mergedEntries, ...matches.unmatched1);
}
} }
} }
} }

View file

@ -87,7 +87,8 @@ export const optionsDescription: INodeProperties[] = [
...clashHandlingProperties, ...clashHandlingProperties,
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['mergeByFields'], '/mode': ['combine'],
'/combinationMode': ['mergeByFields'],
}, },
hide: { hide: {
'/joinMode': ['keepMatches', 'keepNonMatches'], '/joinMode': ['keepMatches', 'keepNonMatches'],
@ -98,7 +99,8 @@ export const optionsDescription: INodeProperties[] = [
...clashHandlingProperties, ...clashHandlingProperties,
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['mergeByFields'], '/mode': ['combine'],
'/combinationMode': ['mergeByFields'],
'/joinMode': ['keepMatches'], '/joinMode': ['keepMatches'],
'/outputDataFrom': ['both'], '/outputDataFrom': ['both'],
}, },
@ -108,7 +110,8 @@ export const optionsDescription: INodeProperties[] = [
...clashHandlingProperties, ...clashHandlingProperties,
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['multiplex', 'mergeByPosition'], '/mode': ['combine'],
'/combinationMode': ['multiplex', 'mergeByPosition'],
}, },
}, },
}, },
@ -121,7 +124,8 @@ export const optionsDescription: INodeProperties[] = [
'Whether to disallow referencing child fields using `parent.child` in the field name', 'Whether to disallow referencing child fields using `parent.child` in the field name',
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['mergeByFields'], '/mode': ['combine'],
'/combinationMode': ['mergeByFields'],
}, },
}, },
}, },
@ -135,7 +139,8 @@ export const optionsDescription: INodeProperties[] = [
'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', '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: { displayOptions: {
show: { show: {
'/mode': ['mergeByPosition'], '/mode': ['combine'],
'/combinationMode': ['mergeByPosition'],
}, },
}, },
}, },
@ -158,7 +163,8 @@ export const optionsDescription: INodeProperties[] = [
], ],
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['mergeByFields'], '/mode': ['combine'],
'/combinationMode': ['mergeByFields'],
'/joinMode': ['keepMatches'], '/joinMode': ['keepMatches'],
'/outputDataFrom': ['both'], '/outputDataFrom': ['both'],
}, },
@ -183,8 +189,9 @@ export const optionsDescription: INodeProperties[] = [
], ],
displayOptions: { displayOptions: {
show: { show: {
'/mode': ['mergeByFields'], '/mode': ['combine'],
'/joinMode': ['enrichInput1', 'enrichInput2'], '/combinationMode': ['mergeByFields'],
'/joinMode': ['enrichInput1', 'enrichInput2', 'keepEverything'],
}, },
}, },
}, },