feat(Merge Node): overhaul of merge node

This commit is contained in:
Michael Kret
2022-09-13 08:59:49 +03:00
committed by GitHub
parent b6c1187922
commit f1a569791d
7 changed files with 1599 additions and 472 deletions

View 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,
};
});
}

View 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];
}
}

View 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'],
},
},
},
];