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