first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.merge",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Core Nodes"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.merge/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "How to synchronize data between two systems (one-way vs. two-way sync",
|
||||
"icon": "🏬",
|
||||
"url": "https://n8n.io/blog/how-to-sync-data-between-two-systems/"
|
||||
},
|
||||
{
|
||||
"label": "Supercharging your conference registration process with n8n",
|
||||
"icon": "🎫",
|
||||
"url": "https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Migrating Community Metrics to Orbit using n8n",
|
||||
"icon": "📈",
|
||||
"url": "https://n8n.io/blog/migrating-community-metrics-to-orbit-using-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Build your own virtual assistant with n8n: A step by step guide",
|
||||
"icon": "👦",
|
||||
"url": "https://n8n.io/blog/build-your-own-virtual-assistant-with-n8n-a-step-by-step-guide/"
|
||||
},
|
||||
{
|
||||
"label": "Sending Automated Congratulations with Google Sheets, Twilio, and n8n ",
|
||||
"icon": "🙌",
|
||||
"url": "https://n8n.io/blog/sending-automated-congratulations-with-google-sheets-twilio-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "7 no-code workflow automations for Amazon Web Services",
|
||||
"url": "https://n8n.io/blog/aws-workflow-automation/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": ["Join", "Concatenate", "Wait"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Flow", "Data Transformation"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
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() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Merge',
|
||||
name: 'merge',
|
||||
icon: 'file:merge.svg',
|
||||
group: ['transform'],
|
||||
subtitle: '={{$parameter["mode"]}}',
|
||||
description: 'Merges data of multiple streams once data from both is available',
|
||||
defaultVersion: 3.2,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new MergeV1(baseDescription),
|
||||
2: new MergeV2(baseDescription),
|
||||
2.1: new MergeV2(baseDescription),
|
||||
3: new MergeV3(baseDescription),
|
||||
3.1: new MergeV3(baseDescription),
|
||||
3.2: new MergeV3(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1177_518)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 48C0 21.4903 21.4903 0 48 0H112C138.51 0 160 21.4903 160 48V56H196.252C240.435 56 276.252 91.8172 276.252 136V192C276.252 214.091 294.161 232 316.252 232H352V224C352 197.49 373.49 176 400 176H464C490.51 176 512 197.49 512 224V288C512 314.51 490.51 336 464 336H400C373.49 336 352 314.51 352 288V280H316.252C294.161 280 276.252 297.909 276.252 320V376C276.252 420.183 240.435 456 196.252 456H160V464C160 490.51 138.51 512 112 512H48C21.4903 512 0 490.51 0 464V400C0 373.49 21.4903 352 48 352H112C138.51 352 160 373.49 160 400V408H196.252C213.925 408 228.252 393.673 228.252 376V320C228.252 294.784 238.859 272.044 255.853 256C238.859 239.956 228.252 217.216 228.252 192V136C228.252 118.327 213.925 104 196.252 104H160V112C160 138.51 138.51 160 112 160H48C21.4903 160 0 138.51 0 112V48ZM104 48C108.418 48 112 51.5817 112 56V104C112 108.418 108.418 112 104 112H56C51.5817 112 48 108.418 48 104V56C48 51.5817 51.5817 48 56 48H104ZM456 224C460.418 224 464 227.582 464 232V280C464 284.418 460.418 288 456 288H408C403.582 288 400 284.418 400 280V232C400 227.582 403.582 224 408 224H456ZM112 408C112 403.582 108.418 400 104 400H56C51.5817 400 48 403.582 48 408V456C48 460.418 51.5817 464 56 464H104C108.418 464 112 460.418 112 456V408Z" fill="#54B8C9"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1177_518">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
|
||||
describe('Test Merge Node', () => {
|
||||
new NodeTestHarness().setupTests();
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"name": "merge tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "9f880c6f-b5e0-4453-b1d7-550beb1febe8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [820, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"id": 2
|
||||
},
|
||||
{
|
||||
"id": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "263d9c7f-32d4-4112-b271-73d09b73809a",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "82ff69e1-596b-48dd-b724-388ef454a822",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 1,
|
||||
"position": [1280, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 4
|
||||
},
|
||||
{
|
||||
"id": 5
|
||||
},
|
||||
{
|
||||
"id": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "3defcf5a-ec5e-4d23-a1f6-a99b7362a241",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1040, 540]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "7082c0c9-60ab-4bcb-adbd-89e56e0bddd6",
|
||||
"id": "107",
|
||||
"meta": {
|
||||
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "8d731d98fab18fb8e68289d44c50faa5d5c5378aedc3f892abdd7d5d3b7061c5"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"fields": {
|
||||
"values": [
|
||||
{
|
||||
"name": "one",
|
||||
"stringValue": "={{ $('Code').item.json.id }}"
|
||||
},
|
||||
{
|
||||
"name": "two",
|
||||
"stringValue": "={{ $('Code1').item.json.id }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "853f9e11-e59b-4f21-81ba-08ec2d69c87a",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [900, 580]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "8310a9da-b02b-4a77-80d4-7c2de5bcbae8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [180, 600]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "0f1fb40d-0c6c-455d-bb3b-0c0fa818e063",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [420, 520]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "873fcccc-86c1-40fd-bdae-2bb31e971382",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [420, 680]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "chooseBranch",
|
||||
"output": "empty"
|
||||
},
|
||||
"id": "b81a2372-9f8b-4899-b2fd-c581bcf930d8",
|
||||
"name": "Merge3",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [700, 580]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Merge3": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Edit Fields": [
|
||||
{
|
||||
"json": {
|
||||
"one": "1",
|
||||
"two": "2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"name": "merge tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "9f880c6f-b5e0-4453-b1d7-550beb1febe8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [820, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"id": 2
|
||||
},
|
||||
{
|
||||
"id": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "263d9c7f-32d4-4112-b271-73d09b73809a",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 4
|
||||
},
|
||||
{
|
||||
"id": 5
|
||||
},
|
||||
{
|
||||
"id": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "3defcf5a-ec5e-4d23-a1f6-a99b7362a241",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1040, 540]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "chooseBranch"
|
||||
},
|
||||
"id": "7120cd6f-29f3-4f5b-aa25-babebd6335d3",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1300, 240]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "chooseBranch",
|
||||
"output": "input2"
|
||||
},
|
||||
"id": "5dbe68d4-bf2f-40ea-a9ae-ee83b4030fa5",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1300, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "chooseBranch",
|
||||
"output": "empty"
|
||||
},
|
||||
"id": "3b2885c3-5c2e-4972-910a-50ff8e173d11",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1300, 520]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge2": [
|
||||
{
|
||||
"json": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "dc43b494-33dc-4671-9004-e5e53170732c",
|
||||
"id": "107",
|
||||
"meta": {
|
||||
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"name": "merge tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "9f880c6f-b5e0-4453-b1d7-550beb1febe8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [700, 660]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1,
|
||||
"text": "foo"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1,
|
||||
"text": "foo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "263d9c7f-32d4-4112-b271-73d09b73809a",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [900, 540]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "3defcf5a-ec5e-4d23-a1f6-a99b7362a241",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [900, 780]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "7120cd6f-29f3-4f5b-aa25-babebd6335d3",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 140]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"options": {}
|
||||
},
|
||||
"id": "1d6973e5-0716-4d47-85ef-298d3f86bb9d",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 280]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepEverything",
|
||||
"options": {}
|
||||
},
|
||||
"id": "dd75b8c0-21bf-460f-9920-644a467ed356",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 420]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "enrichInput1",
|
||||
"options": {}
|
||||
},
|
||||
"id": "3545877d-6e4f-437e-b91f-4c792d714e73",
|
||||
"name": "Merge3",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 560]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "enrichInput2",
|
||||
"options": {}
|
||||
},
|
||||
"id": "4b6ac099-6e6f-4e91-bcfe-74d7524fad54",
|
||||
"name": "Merge4",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 740]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outputDataFrom": "input1",
|
||||
"options": {}
|
||||
},
|
||||
"id": "fb4fef36-26ea-4fc0-b6e9-a4b2781c94fa",
|
||||
"name": "Merge5",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 900]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"outputDataFrom": "input2",
|
||||
"options": {}
|
||||
},
|
||||
"id": "0b4fbaaa-cf0b-4da5-b35f-da3f49409965",
|
||||
"name": "Merge6",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 1040]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "addSuffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "25489a7a-74c1-48d5-a03d-1bbd2aba2abe",
|
||||
"name": "Merge7",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 1200]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2,
|
||||
"tag": "second",
|
||||
"_source": "input2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge2": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge3": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge4": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1,
|
||||
"text": "foo",
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge5": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1,
|
||||
"text": "foo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1,
|
||||
"text": "foo"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge6": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2,
|
||||
"tag": "second"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge7": [
|
||||
{
|
||||
"json": {
|
||||
"id_1": 1,
|
||||
"data_1": "a",
|
||||
"input_1": 1,
|
||||
"text_1": "foo",
|
||||
"id_2": 1,
|
||||
"data_2": "c",
|
||||
"input_2": 2,
|
||||
"tag_2": "second"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id_1": 2,
|
||||
"data_1": "b",
|
||||
"input_1": 1,
|
||||
"text_1": "foo",
|
||||
"id_2": 2,
|
||||
"data_2": "d",
|
||||
"input_2": 2,
|
||||
"tag_2": "second"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge4",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge5",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge6",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge7",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge4",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge5",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge6",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge7",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "8bad6701-bb00-4617-b1d5-bd24145eadaf",
|
||||
"id": "107",
|
||||
"meta": {
|
||||
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"name": "merge tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "9f880c6f-b5e0-4453-b1d7-550beb1febe8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [840, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "263d9c7f-32d4-4112-b271-73d09b73809a",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1040, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "3defcf5a-ec5e-4d23-a1f6-a99b7362a241",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1060, 520]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "mergeByPosition",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "addSuffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "7120cd6f-29f3-4f5b-aa25-babebd6335d3",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "mergeByPosition",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "preferInput1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "b40fdf2e-7527-4954-84fe-464a762f8957",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "mergeByPosition",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "preferInput2"
|
||||
}
|
||||
},
|
||||
"includeUnpaired": true
|
||||
}
|
||||
},
|
||||
"id": "daca2305-691a-45c2-9010-34a9ef5a783f",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 540]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id_1": 1,
|
||||
"data_1": "a",
|
||||
"input_1": 1,
|
||||
"id_2": 1,
|
||||
"data_2": "c",
|
||||
"input_2": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id_1": 2,
|
||||
"data_1": "b",
|
||||
"input_1": 1,
|
||||
"id_2": 2,
|
||||
"data_2": "d",
|
||||
"input_2": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge2": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"data": "e",
|
||||
"input": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "c61ce62f-d995-46bb-900a-8b3edf95a858",
|
||||
"id": "107",
|
||||
"meta": {
|
||||
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"name": "merge tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "9f880c6f-b5e0-4453-b1d7-550beb1febe8",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [820, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "263d9c7f-32d4-4112-b271-73d09b73809a",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "3defcf5a-ec5e-4d23-a1f6-a99b7362a241",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1040, 540]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "multiplex",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "addSuffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "7120cd6f-29f3-4f5b-aa25-babebd6335d3",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "multiplex",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "preferInput1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "1ec4a466-432d-43f8-b6c0-3cc02cf32f6f",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"combinationMode": "multiplex",
|
||||
"options": {
|
||||
"clashHandling": {
|
||||
"values": {
|
||||
"resolveClash": "preferInput2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "fb671be6-b476-4302-984f-0c973839581b",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1260, 500]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id_1": 1,
|
||||
"data_1": "a",
|
||||
"input_1": 1,
|
||||
"id_2": 1,
|
||||
"data_2": "c",
|
||||
"input_2": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id_1": 1,
|
||||
"data_1": "a",
|
||||
"input_1": 1,
|
||||
"id_2": 2,
|
||||
"data_2": "d",
|
||||
"input_2": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id_1": 2,
|
||||
"data_1": "b",
|
||||
"input_1": 1,
|
||||
"id_2": 1,
|
||||
"data_2": "c",
|
||||
"input_2": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id_1": 2,
|
||||
"data_1": "b",
|
||||
"input_1": 1,
|
||||
"id_2": 2,
|
||||
"data_2": "d",
|
||||
"input_2": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "a",
|
||||
"input": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "b",
|
||||
"input": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge2": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"data": "c",
|
||||
"input": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"data": "d",
|
||||
"input": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "0cab5d94-90b7-4024-ad58-af9cafb46a21",
|
||||
"id": "107",
|
||||
"meta": {
|
||||
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"name": "merge fix",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "94003e55-6c4e-492f-802a-49f4fb5b5f4b",
|
||||
"name": "When clicking ‘Execute workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [120, 940]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"first_name": "Michael",
|
||||
"last_name": "Johnson"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"first_name": "Emily",
|
||||
"last_name": "Davis"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"first_name": "Robert",
|
||||
"last_name": "Taylor"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Martin"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"first_name": "William",
|
||||
"last_name": "Brown"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"first_name": "Sophia",
|
||||
"last_name": "Anderson"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Wilson"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"first_name": "Ava",
|
||||
"last_name": "Miller"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "da82fbb0-b689-4c4e-b310-303361e8411c",
|
||||
"name": "Mock data A",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [340, 840]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": []
|
||||
},
|
||||
"id": "493ecb02-52e7-43b7-8c73-d45a3f7e76f6",
|
||||
"name": "Mock data B",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [340, 1040]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"options": {}
|
||||
},
|
||||
"id": "d0c0f123-1461-4b78-9cf8-6410440430d3",
|
||||
"name": "Merge",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 500]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"options": {}
|
||||
},
|
||||
"id": "5093ec3e-c987-44c7-815f-279ea7842db5",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 680]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"outputDataFrom": "input1",
|
||||
"options": {}
|
||||
},
|
||||
"id": "0453bb35-3971-4a23-93b7-31853e4634fb",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 880]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"outputDataFrom": "input1",
|
||||
"options": {}
|
||||
},
|
||||
"id": "f09c9327-4557-406b-8ae0-db851055dd08",
|
||||
"name": "Merge3",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 1060],
|
||||
"alwaysOutputData": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"outputDataFrom": "input2",
|
||||
"options": {}
|
||||
},
|
||||
"id": "d5bc6ee7-141e-4eba-a9aa-cb866b177b89",
|
||||
"name": "Merge4",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 1280],
|
||||
"alwaysOutputData": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "id",
|
||||
"field2": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"joinMode": "keepNonMatches",
|
||||
"outputDataFrom": "input2",
|
||||
"options": {}
|
||||
},
|
||||
"id": "4c881679-3f76-491a-a2c9-e35e4c9e28ff",
|
||||
"name": "Merge5",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [700, 1460],
|
||||
"alwaysOutputData": true
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Merge": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"first_name": "Michael",
|
||||
"last_name": "Johnson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4,
|
||||
"first_name": "Emily",
|
||||
"last_name": "Davis"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5,
|
||||
"first_name": "Robert",
|
||||
"last_name": "Taylor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6,
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Martin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 7,
|
||||
"first_name": "William",
|
||||
"last_name": "Brown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 8,
|
||||
"first_name": "Sophia",
|
||||
"last_name": "Anderson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 9,
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Wilson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 10,
|
||||
"first_name": "Ava",
|
||||
"last_name": "Miller"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"first_name": "Michael",
|
||||
"last_name": "Johnson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4,
|
||||
"first_name": "Emily",
|
||||
"last_name": "Davis"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5,
|
||||
"first_name": "Robert",
|
||||
"last_name": "Taylor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6,
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Martin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 7,
|
||||
"first_name": "William",
|
||||
"last_name": "Brown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 8,
|
||||
"first_name": "Sophia",
|
||||
"last_name": "Anderson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 9,
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Wilson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 10,
|
||||
"first_name": "Ava",
|
||||
"last_name": "Miller"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge2": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"first_name": "Michael",
|
||||
"last_name": "Johnson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4,
|
||||
"first_name": "Emily",
|
||||
"last_name": "Davis"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5,
|
||||
"first_name": "Robert",
|
||||
"last_name": "Taylor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6,
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Martin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 7,
|
||||
"first_name": "William",
|
||||
"last_name": "Brown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 8,
|
||||
"first_name": "Sophia",
|
||||
"last_name": "Anderson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 9,
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Wilson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 10,
|
||||
"first_name": "Ava",
|
||||
"last_name": "Miller"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Merge3": [
|
||||
{
|
||||
"json": {}
|
||||
}
|
||||
],
|
||||
"Merge4": [
|
||||
{
|
||||
"json": {}
|
||||
}
|
||||
],
|
||||
"Merge5": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2,
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3,
|
||||
"first_name": "Michael",
|
||||
"last_name": "Johnson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4,
|
||||
"first_name": "Emily",
|
||||
"last_name": "Davis"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 5,
|
||||
"first_name": "Robert",
|
||||
"last_name": "Taylor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 6,
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Martin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 7,
|
||||
"first_name": "William",
|
||||
"last_name": "Brown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 8,
|
||||
"first_name": "Sophia",
|
||||
"last_name": "Anderson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 9,
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Wilson"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 10,
|
||||
"first_name": "Ava",
|
||||
"last_name": "Miller"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Mock data A",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Mock data B",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock data A": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge4",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge5",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock data B": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge4",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge5",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "54329e46-4d05-491a-919c-e156e77d6d11",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
|
||||
},
|
||||
"id": "WbgpQNak2tO6spx2",
|
||||
"tags": []
|
||||
}
|
||||
+2440
File diff suppressed because it is too large
Load Diff
+2378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"name": "do not error on missing keys",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "c7c0cf66-790a-4da7-81c8-ba9e4bbcec9a",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [720, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"id": 2
|
||||
},
|
||||
{
|
||||
"id": 3
|
||||
},
|
||||
{
|
||||
"id": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "cec18624-ced0-4de1-8987-d4b184b136b9",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": []
|
||||
},
|
||||
"id": "754d549c-82ce-4625-ba2b-6f8edcbf715e",
|
||||
"name": "Code1",
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "551ed574-607a-4d98-9b06-350df92c805e",
|
||||
"name": "No Operation, do nothing",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1540, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "00e75760-d88e-413c-b276-c759db72411f",
|
||||
"name": "No Operation, do nothing1",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1540, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "idd",
|
||||
"field2": "idd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "2d412290-4c43-40a6-be78-946602749aa1",
|
||||
"name": "Merge1",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2.1,
|
||||
"position": [1340, 520],
|
||||
"alwaysOutputData": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "combine",
|
||||
"mergeByFields": {
|
||||
"values": [
|
||||
{
|
||||
"field1": "idd",
|
||||
"field2": "idd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "b6f05b31-3ca0-48c4-a5ec-c7f81dc7957f",
|
||||
"name": "Merge2",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 2,
|
||||
"position": [1320, 160],
|
||||
"continueOnFail": true
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing1": [
|
||||
{
|
||||
"json": {}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge2",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"No Operation, do nothing1": {
|
||||
"main": [[]]
|
||||
},
|
||||
"Merge1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Merge2": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "d0ebcbdd-b57d-495e-8f84-b9500d25c384",
|
||||
"id": "8",
|
||||
"meta": {
|
||||
"instanceId": "6ebec4953fe56f1c009e7c3b107578b375137523af057073c0b5da17350651bd"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
loadAlaSqlSandbox,
|
||||
resetSandboxCache,
|
||||
runAlaSqlInSandbox,
|
||||
} from '../../v3/helpers/sandbox-utils';
|
||||
|
||||
describe('combineBySql sandbox (isolated-vm)', () => {
|
||||
describe('loadAlaSqlSandbox', () => {
|
||||
beforeEach(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
it('should return a context with alasql available', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = await context.eval('typeof alasql', { copy: true });
|
||||
expect(result).toBe('function');
|
||||
});
|
||||
|
||||
it('should return the same cached context on subsequent calls', async () => {
|
||||
const context1 = await loadAlaSqlSandbox();
|
||||
const context2 = await loadAlaSqlSandbox();
|
||||
expect(context1).toBe(context2);
|
||||
});
|
||||
|
||||
it('should recreate the sandbox after resetSandboxCache', async () => {
|
||||
const context1 = await loadAlaSqlSandbox();
|
||||
resetSandboxCache();
|
||||
const context2 = await loadAlaSqlSandbox();
|
||||
expect(context1).not.toBe(context2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAlaSqlInSandbox – isolation', () => {
|
||||
beforeAll(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
// ── Host API access ────────────────────────────────────────────────────
|
||||
|
||||
it('should not expose require inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof require; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
it('should not expose process inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof process; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
it('should not expose __dirname inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof __dirname; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
it('should not expose __filename inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof __filename; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
it('should not expose global inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof global; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
it('should not expose Buffer inside the sandbox', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { return typeof Buffer; })()', { copy: true }),
|
||||
).resolves.toBe('undefined');
|
||||
});
|
||||
|
||||
// ── File system access ─────────────────────────────────────────────────
|
||||
|
||||
it('should block FROM FILE() SQL clause (no fs in browser bundle)', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
"SELECT * FROM FILE('/etc/passwd', {headers: false})",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should block ATTACH CSV FILE attempts', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
"ATTACH CSV '/etc/passwd' AS secret; SELECT * FROM secret",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── Code evaluation / function injection ───────────────────────────────
|
||||
|
||||
it('should block CREATE FUNCTION from accessing host APIs', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
// Even if a user crafts a SQL function, require is not in scope inside the isolate
|
||||
await expect(
|
||||
runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
'SELECT id FROM input1 WHERE alasql(\'CREATE FUNCTION danger() RETURNS STRING BEGIN RETURN typeof require END; SELECT danger() FROM [{"x":1}]\')',
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return "undefined" for require even when called via a SQL function', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
// CREATE FUNCTION compiles a JS body – require must still be absent
|
||||
await expect(
|
||||
runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
"SELECT alasql('CREATE FUNCTION getRequire() RETURNS STRING BEGIN RETURN typeof require END') FROM input1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should block eval() inside the sandbox from escaping isolation', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
// eval exists in V8 but must not be able to reach host-side objects
|
||||
const result = (await context.eval(
|
||||
`(function() {
|
||||
try { return eval('typeof require'); }
|
||||
catch(e) { return 'error: ' + e.message; }
|
||||
})()`,
|
||||
{ copy: true },
|
||||
)) as string;
|
||||
// Either eval is blocked or require is still undefined inside eval'd code
|
||||
expect(result === 'undefined' || result.startsWith('error:')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block alasql arrow-operator prototype traversal from reaching host globals', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
// process is unavailable in the isolate, the inner call silently fails, resulting in an empty object
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
'SELECT {}.constructor->constructor->[call](\'\',\'return {result: process.getBuiltinModule("child_process").execSync("id").toString()}\') AS r FROM input1',
|
||||
);
|
||||
expect(result).toEqual([{}]);
|
||||
});
|
||||
|
||||
it('should block Function constructor from reaching host APIs', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = (await context.eval(
|
||||
`(function() {
|
||||
try { return new Function('return typeof require')(); }
|
||||
catch(e) { return 'error: ' + e.message; }
|
||||
})()`,
|
||||
{ copy: true },
|
||||
)) as string;
|
||||
expect(result === 'undefined' || result.startsWith('error:')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Prototype pollution ────────────────────────────────────────────────
|
||||
|
||||
it('should contain prototype pollution within the isolate heap', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
// Pollute Object.prototype inside the sandbox
|
||||
await context.eval('Object.prototype.__polluted = true', { copy: true });
|
||||
// Host-side Object.prototype must be untouched
|
||||
expect(({} as Record<string, unknown>).__polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have alasql.fn frozen after sandbox initialisation', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const isFrozen = (await context.eval('Object.isFrozen(alasql.fn)', {
|
||||
copy: true,
|
||||
})) as boolean;
|
||||
expect(isFrozen).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow a CREATE FUNCTION', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
|
||||
await expect(
|
||||
runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ x: 1 }]],
|
||||
'CREATE FUNCTION double(n) RETURNS NUMBER BEGIN RETURN n * 2 END; SELECT double(x) AS v FROM input1',
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
runAlaSqlInSandbox(context, [[{ x: 1 }]], 'SELECT double(x) AS v FROM input1'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── Resource limits ────────────────────────────────────────────────────
|
||||
|
||||
it('should enforce the CPU timeout on an infinite loop', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(
|
||||
context.eval('(function() { while(true) {} })()', { timeout: 100, copy: true }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAlaSqlInSandbox – SQL execution', () => {
|
||||
beforeAll(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetSandboxCache();
|
||||
});
|
||||
|
||||
it('should execute a basic SELECT query', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
[
|
||||
[
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
],
|
||||
],
|
||||
'SELECT * FROM input1',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should execute a JOIN query across two inputs', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
[
|
||||
[
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
[
|
||||
{ userId: 1, score: 100 },
|
||||
{ userId: 2, score: 200 },
|
||||
],
|
||||
],
|
||||
'SELECT input1.name, input2.score FROM input1 LEFT JOIN input2 ON input1.id = input2.userId',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ name: 'Alice', score: 100 },
|
||||
{ name: 'Bob', score: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should execute a WHERE filter', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
[
|
||||
[
|
||||
{ id: 1, val: 10 },
|
||||
{ id: 2, val: 20 },
|
||||
{ id: 3, val: 30 },
|
||||
],
|
||||
],
|
||||
'SELECT * FROM input1 WHERE val > 15',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ id: 2, val: 20 },
|
||||
{ id: 3, val: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array for a query with no results', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
[[{ id: 1 }]],
|
||||
'SELECT * FROM input1 WHERE id = 999',
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw a meaningful error for invalid SQL', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
await expect(runAlaSqlInSandbox(context, [[{ id: 1 }]], 'THIS IS NOT SQL')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should clean up its database after each execution', async () => {
|
||||
const context = await loadAlaSqlSandbox();
|
||||
|
||||
const countBefore = (await context.eval('Object.keys(alasql.databases).length', {
|
||||
copy: true,
|
||||
})) as number;
|
||||
|
||||
await runAlaSqlInSandbox(context, [[{ id: 1 }]], 'SELECT * FROM input1');
|
||||
await runAlaSqlInSandbox(context, [[{ id: 2 }]], 'SELECT * FROM input1');
|
||||
|
||||
const countAfter = (await context.eval('Object.keys(alasql.databases).length', {
|
||||
copy: true,
|
||||
})) as number;
|
||||
|
||||
// Database count must not grow – each invocation cleans up after itself
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import type { IDataObject, INodeExecutionData, IPairedItemData } from 'n8n-workflow';
|
||||
|
||||
import { modifySelectQuery, rowToExecutionData } from '../../v3/helpers/utils';
|
||||
|
||||
describe('rowToExecutionData', () => {
|
||||
test('should return empty json and pairedItem when input is empty', () => {
|
||||
const input: IDataObject = {};
|
||||
const result = rowToExecutionData(input);
|
||||
expect(result).toEqual({ json: {}, pairedItem: [] });
|
||||
});
|
||||
|
||||
test('should separate json properties and pairedItem properties', () => {
|
||||
const input: IDataObject = {
|
||||
key1: 'value1',
|
||||
key2: 42,
|
||||
pairedItem1: { item: 0, input: undefined } as IPairedItemData,
|
||||
pairedItem2: { item: 0, input: 1 } as IPairedItemData,
|
||||
};
|
||||
|
||||
const expectedOutput: INodeExecutionData = {
|
||||
json: { key1: 'value1', key2: 42 },
|
||||
pairedItem: [
|
||||
{ item: 0, input: undefined },
|
||||
{ item: 0, input: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(rowToExecutionData(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('should ignore undefined pairedItem values', () => {
|
||||
const input: IDataObject = {
|
||||
key: 'value',
|
||||
pairedItem1: { item: 0, input: undefined } as IPairedItemData,
|
||||
pairedItem2: undefined,
|
||||
};
|
||||
|
||||
const expectedOutput: INodeExecutionData = {
|
||||
json: { key: 'value' },
|
||||
pairedItem: [{ item: 0, input: undefined }],
|
||||
};
|
||||
|
||||
expect(rowToExecutionData(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('should handle only json properties without pairedItem', () => {
|
||||
const input: IDataObject = {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
};
|
||||
|
||||
const expectedOutput: INodeExecutionData = {
|
||||
json: { name: 'Alice', age: 30 },
|
||||
pairedItem: [],
|
||||
};
|
||||
|
||||
expect(rowToExecutionData(input)).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifySelectQuery', () => {
|
||||
test('should return the original query if no SELECT match is found', () => {
|
||||
const query = 'UPDATE table SET column = 1';
|
||||
expect(modifySelectQuery(query, 2)).toBe(query);
|
||||
});
|
||||
|
||||
test('should return the original query if SELECT * is used', () => {
|
||||
const query = 'SELECT * FROM input1';
|
||||
expect(modifySelectQuery(query, 2)).toBe(query);
|
||||
});
|
||||
|
||||
test('should append pairedItem columns when input tables exist', () => {
|
||||
const query = 'SELECT column1, column2 FROM input1 WHERE input1.id = table.id';
|
||||
const modifiedQuery = modifySelectQuery(query, 2);
|
||||
expect(modifiedQuery).toBe(
|
||||
'SELECT column1, column2, input1.pairedItem AS pairedItem1 FROM input1 WHERE input1.id = table.id',
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle multiple input tables correctly', () => {
|
||||
const query = 'SELECT column1 FROM input1 LEFT JOIN input2 ON input1.name = input2.name';
|
||||
const modifiedQuery = modifySelectQuery(query, 2);
|
||||
expect(modifiedQuery).toBe(
|
||||
'SELECT column1, input1.pairedItem AS pairedItem1, input2.pairedItem AS pairedItem2 FROM input1 LEFT JOIN input2 ON input1.name = input2.name',
|
||||
);
|
||||
});
|
||||
|
||||
test('should not modify query if no input tables are found', () => {
|
||||
const query = 'SELECT column1 FROM table';
|
||||
expect(modifySelectQuery(query, 2)).toBe(query);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
GenericValue,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, deepCopy } from 'n8n-workflow';
|
||||
|
||||
import { oldVersionNotice } from '@utils/descriptions';
|
||||
|
||||
import { generatePairedItemData } from '../../../utils/utilities';
|
||||
|
||||
export class MergeV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: 1,
|
||||
defaults: {
|
||||
name: 'Merge',
|
||||
color: '#00bbcc',
|
||||
},
|
||||
|
||||
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
inputNames: ['Input 1', 'Input 2'],
|
||||
properties: [
|
||||
oldVersionNotice,
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Append',
|
||||
value: 'append',
|
||||
description:
|
||||
'Combines data of both inputs. The output will contain items of input 1 and input 2.',
|
||||
},
|
||||
{
|
||||
name: 'Keep Key Matches',
|
||||
value: 'keepKeyMatches',
|
||||
description: 'Keeps data of input 1 if it does find a match with data of input 2',
|
||||
},
|
||||
{
|
||||
name: 'Merge By Index',
|
||||
value: 'mergeByIndex',
|
||||
description:
|
||||
'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on the index of the items. So first item of input 1 will be merged with first item of input 2 and so on.',
|
||||
},
|
||||
{
|
||||
name: 'Merge By Key',
|
||||
value: 'mergeByKey',
|
||||
description:
|
||||
'Merges data of both inputs. The output will contain items of input 1 merged with data of input 2. Merge happens depending on a defined key.',
|
||||
},
|
||||
{
|
||||
name: 'Multiplex',
|
||||
value: 'multiplex',
|
||||
description:
|
||||
'Merges each value of one input with each value of the other input. The output will contain (m * n) items where (m) and (n) are lengths of the inputs.',
|
||||
},
|
||||
{
|
||||
name: 'Pass-Through',
|
||||
value: 'passThrough',
|
||||
description:
|
||||
'Passes through data of one input. The output will contain only items of the defined input.',
|
||||
},
|
||||
{
|
||||
name: 'Remove Key Matches',
|
||||
value: 'removeKeyMatches',
|
||||
description: 'Keeps data of input 1 if it does NOT find match with data of input 2',
|
||||
},
|
||||
{
|
||||
name: 'Wait',
|
||||
value: 'wait',
|
||||
description:
|
||||
'Waits till data of both inputs is available and will then output a single empty item. Source Nodes must connect to both Input 1 and 2. This node only supports 2 Sources, if you need more Sources, connect multiple Merge nodes in series. This node will not output any data.',
|
||||
},
|
||||
],
|
||||
default: 'append',
|
||||
description: 'How data of branches should be merged',
|
||||
},
|
||||
{
|
||||
displayName: 'Join',
|
||||
name: 'join',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['mergeByIndex'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Inner Join',
|
||||
value: 'inner',
|
||||
description:
|
||||
'Merges as many items as both inputs contain. (Example: Input1 = 5 items, Input2 = 3 items | Output will contain 3 items).',
|
||||
},
|
||||
{
|
||||
name: 'Left Join',
|
||||
value: 'left',
|
||||
description:
|
||||
'Merges as many items as first input contains. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 3 items).',
|
||||
},
|
||||
{
|
||||
name: 'Outer Join',
|
||||
value: 'outer',
|
||||
description:
|
||||
'Merges as many items as input contains with most items. (Example: Input1 = 3 items, Input2 = 5 items | Output will contain 5 items).',
|
||||
},
|
||||
],
|
||||
default: 'left',
|
||||
description:
|
||||
'How many items the output will contain if inputs contain different amount of items',
|
||||
},
|
||||
{
|
||||
displayName: 'Property Input 1',
|
||||
name: 'propertyName1',
|
||||
type: 'string',
|
||||
default: '',
|
||||
hint: 'The name of the field as text (e.g. “id”)',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'],
|
||||
},
|
||||
},
|
||||
description: 'Name of property which decides which items to merge of input 1',
|
||||
},
|
||||
{
|
||||
displayName: 'Property Input 2',
|
||||
name: 'propertyName2',
|
||||
type: 'string',
|
||||
default: '',
|
||||
hint: 'The name of the field as text (e.g. “id”)',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'],
|
||||
},
|
||||
},
|
||||
description: 'Name of property which decides which items to merge of input 2',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Data',
|
||||
name: 'output',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['passThrough'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Input 1',
|
||||
value: 'input1',
|
||||
},
|
||||
{
|
||||
name: 'Input 2',
|
||||
value: 'input2',
|
||||
},
|
||||
],
|
||||
default: 'input1',
|
||||
description: 'Defines of which input the data should be used as output of node',
|
||||
},
|
||||
{
|
||||
displayName: 'Overwrite',
|
||||
name: 'overwrite',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['mergeByKey'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Always',
|
||||
value: 'always',
|
||||
description: 'Always overwrites everything',
|
||||
},
|
||||
{
|
||||
name: 'If Blank',
|
||||
value: 'blank',
|
||||
description: 'Overwrites only values of "null", "undefined" or empty string',
|
||||
},
|
||||
{
|
||||
name: 'If Missing',
|
||||
value: 'undefined',
|
||||
description: 'Only adds values which do not exist yet',
|
||||
},
|
||||
],
|
||||
default: 'always',
|
||||
description: 'Select when to overwrite the values from Input1 with values from Input 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const mode = this.getNodeParameter('mode', 0) as string;
|
||||
|
||||
if (mode === 'append') {
|
||||
// Simply appends the data
|
||||
for (let i = 0; i < 2; i++) {
|
||||
returnData.push.apply(returnData, this.getInputData(i));
|
||||
}
|
||||
} else if (mode === 'mergeByIndex') {
|
||||
// Merges data by index
|
||||
|
||||
const join = this.getNodeParameter('join', 0) as string;
|
||||
|
||||
const dataInput1 = this.getInputData(0);
|
||||
const dataInput2 = this.getInputData(1);
|
||||
|
||||
if (dataInput1 === undefined || dataInput1.length === 0) {
|
||||
if (['inner', 'left'].includes(join)) {
|
||||
// When "inner" or "left" join return empty if first
|
||||
// input does not contain any items
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
// For "outer" return data of second input
|
||||
return [dataInput2];
|
||||
}
|
||||
|
||||
if (dataInput2 === undefined || dataInput2.length === 0) {
|
||||
if (['left', 'outer'].includes(join)) {
|
||||
// When "left" or "outer" join return data of first input
|
||||
return [dataInput1];
|
||||
}
|
||||
|
||||
// For "inner" return empty
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
// Default "left"
|
||||
let numEntries = dataInput1.length;
|
||||
if (join === 'inner') {
|
||||
numEntries = Math.min(dataInput1.length, dataInput2.length);
|
||||
} else if (join === 'outer') {
|
||||
numEntries = Math.max(dataInput1.length, dataInput2.length);
|
||||
}
|
||||
|
||||
let newItem: INodeExecutionData;
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
if (i >= dataInput1.length) {
|
||||
returnData.push(dataInput2[i]);
|
||||
continue;
|
||||
}
|
||||
if (i >= dataInput2.length) {
|
||||
returnData.push(dataInput1[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
newItem = {
|
||||
json: {},
|
||||
pairedItem: [
|
||||
dataInput1[i].pairedItem as IPairedItemData,
|
||||
dataInput2[i].pairedItem as IPairedItemData,
|
||||
],
|
||||
};
|
||||
|
||||
if (dataInput1[i].binary !== undefined) {
|
||||
newItem.binary = {};
|
||||
// Create a shallow copy of the binary data so that the old
|
||||
// data references which do not get changed still stay behind
|
||||
// but the incoming data does not get changed.
|
||||
Object.assign(newItem.binary, dataInput1[i].binary);
|
||||
}
|
||||
|
||||
// Create also a shallow copy of the json data
|
||||
Object.assign(newItem.json, dataInput1[i].json);
|
||||
|
||||
// Copy json data
|
||||
for (const key of Object.keys(dataInput2[i].json)) {
|
||||
newItem.json[key] = dataInput2[i].json[key];
|
||||
}
|
||||
|
||||
// Copy binary data
|
||||
if (dataInput2[i].binary !== undefined) {
|
||||
if (newItem.binary === undefined) {
|
||||
newItem.binary = {};
|
||||
}
|
||||
|
||||
for (const key of Object.keys(dataInput2[i].binary!)) {
|
||||
newItem.binary[key] = dataInput2[i].binary![key] ?? newItem.binary[key];
|
||||
}
|
||||
}
|
||||
|
||||
returnData.push(newItem);
|
||||
}
|
||||
} else if (mode === 'multiplex') {
|
||||
const dataInput1 = this.getInputData(0);
|
||||
const dataInput2 = this.getInputData(1);
|
||||
|
||||
if (!dataInput1 || !dataInput2) {
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
let entry1: INodeExecutionData;
|
||||
let entry2: INodeExecutionData;
|
||||
|
||||
for (entry1 of dataInput1) {
|
||||
for (entry2 of dataInput2) {
|
||||
returnData.push({
|
||||
json: {
|
||||
...entry1.json,
|
||||
...entry2.json,
|
||||
},
|
||||
pairedItem: [
|
||||
entry1.pairedItem as IPairedItemData,
|
||||
entry2.pairedItem as IPairedItemData,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
return [returnData];
|
||||
} else if (['keepKeyMatches', 'mergeByKey', 'removeKeyMatches'].includes(mode)) {
|
||||
const dataInput1 = this.getInputData(0);
|
||||
if (!dataInput1) {
|
||||
// If it has no input data from first input return nothing
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
const propertyName1 = this.getNodeParameter('propertyName1', 0) as string;
|
||||
const propertyName2 = this.getNodeParameter('propertyName2', 0) as string;
|
||||
const overwrite = this.getNodeParameter('overwrite', 0, 'always') as string;
|
||||
|
||||
const dataInput2 = this.getInputData(1);
|
||||
if (!dataInput2 || !propertyName1 || !propertyName2) {
|
||||
// Second input does not have any data or the property names are not defined
|
||||
if (mode === 'keepKeyMatches') {
|
||||
// For "keepKeyMatches" return nothing
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
// For "mergeByKey" and "removeKeyMatches" return the data from the first input
|
||||
return [dataInput1];
|
||||
}
|
||||
|
||||
// Get the data to copy
|
||||
const copyData: {
|
||||
[key: string]: INodeExecutionData;
|
||||
} = {};
|
||||
let entry: INodeExecutionData;
|
||||
for (entry of dataInput2) {
|
||||
const key = get(entry.json, propertyName2);
|
||||
if (!entry.json || !key) {
|
||||
// Entry does not have the property so skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
copyData[key as string] = entry;
|
||||
}
|
||||
|
||||
// Copy data on entries or add matching entries
|
||||
let referenceValue: GenericValue;
|
||||
let key: string;
|
||||
for (entry of dataInput1) {
|
||||
referenceValue = get(entry.json, propertyName1);
|
||||
|
||||
if (referenceValue === undefined) {
|
||||
// Entry does not have the property
|
||||
|
||||
if (mode === 'removeKeyMatches') {
|
||||
// For "removeKeyMatches" add item
|
||||
returnData.push(entry);
|
||||
}
|
||||
|
||||
// For "mergeByKey" and "keepKeyMatches" skip item
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!['string', 'number'].includes(typeof referenceValue)) {
|
||||
if (referenceValue !== null && referenceValue.constructor.name !== 'Data') {
|
||||
// Reference value is not of comparable type
|
||||
|
||||
if (mode === 'removeKeyMatches') {
|
||||
// For "removeKeyMatches" add item
|
||||
returnData.push(entry);
|
||||
}
|
||||
|
||||
// For "mergeByKey" and "keepKeyMatches" skip item
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof referenceValue === 'number') {
|
||||
referenceValue = referenceValue.toString();
|
||||
} else if (referenceValue !== null && referenceValue.constructor.name === 'Date') {
|
||||
referenceValue = (referenceValue as Date).toISOString();
|
||||
}
|
||||
|
||||
if (copyData.hasOwnProperty(referenceValue as string)) {
|
||||
// Item with reference value got found
|
||||
|
||||
if (['null', 'undefined'].includes(typeof referenceValue)) {
|
||||
// The reference value is null or undefined
|
||||
|
||||
if (mode === 'removeKeyMatches') {
|
||||
// For "removeKeyMatches" add item
|
||||
returnData.push(entry);
|
||||
}
|
||||
|
||||
// For "mergeByKey" and "keepKeyMatches" skip item
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match exists
|
||||
if (mode === 'removeKeyMatches') {
|
||||
// For "removeKeyMatches" we can skip the item as it has a match
|
||||
continue;
|
||||
} else if (mode === 'mergeByKey') {
|
||||
// Copy the entry as the data gets changed
|
||||
entry = deepCopy(entry);
|
||||
|
||||
for (key of Object.keys(copyData[referenceValue as string].json)) {
|
||||
if (key === propertyName2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Currently only copies json data and no binary one
|
||||
const value = copyData[referenceValue as string].json[key];
|
||||
if (
|
||||
overwrite === 'always' ||
|
||||
(overwrite === 'undefined' && !entry.json.hasOwnProperty(key)) ||
|
||||
(overwrite === 'blank' && [null, undefined, ''].includes(entry.json[key] as string))
|
||||
) {
|
||||
entry.json[key] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For "keepKeyMatches" we add it as it is
|
||||
returnData.push(entry);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// No item for reference value got found
|
||||
if (mode === 'removeKeyMatches') {
|
||||
// For "removeKeyMatches" we can add it if not match got found
|
||||
returnData.push(entry);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'mergeByKey') {
|
||||
// For "mergeByKey" we always add the entry anyway but then the unchanged one
|
||||
returnData.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
} else if (mode === 'passThrough') {
|
||||
const output = this.getNodeParameter('output', 0) as string;
|
||||
|
||||
if (output === 'input1') {
|
||||
returnData.push.apply(returnData, this.getInputData(0));
|
||||
} else {
|
||||
returnData.push.apply(returnData, this.getInputData(1));
|
||||
}
|
||||
} else if (mode === 'wait') {
|
||||
const pairedItem = generatePairedItemData(this.getInputData(0).length);
|
||||
returnData.push({ json: {}, pairedItem });
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import merge from 'lodash/merge';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeBaseDescription,
|
||||
type INodeTypeDescription,
|
||||
type IPairedItemData,
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { preparePairedItemDataArray } from '@utils/utilities';
|
||||
|
||||
import { optionsDescription } from './descriptions';
|
||||
import type {
|
||||
ClashResolveOptions,
|
||||
MatchFieldsJoinMode,
|
||||
MatchFieldsOptions,
|
||||
MatchFieldsOutput,
|
||||
} from './interfaces';
|
||||
import {
|
||||
addSourceField,
|
||||
addSuffixToEntriesKeys,
|
||||
checkInput,
|
||||
checkMatchFieldsInput,
|
||||
findMatches,
|
||||
mergeMatched,
|
||||
selectMergeMethod,
|
||||
} from './utils';
|
||||
|
||||
export class MergeV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: [2, 2.1],
|
||||
defaults: {
|
||||
name: 'Merge',
|
||||
},
|
||||
|
||||
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
inputNames: ['Input 1', 'Input 2'],
|
||||
// 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: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Append',
|
||||
value: 'append',
|
||||
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',
|
||||
value: 'mergeByFields',
|
||||
description: 'Combine items with the same field values',
|
||||
},
|
||||
{
|
||||
name: 'Merge By Position',
|
||||
value: 'mergeByPosition',
|
||||
description: 'Combine items based on their order',
|
||||
},
|
||||
{
|
||||
name: 'Multiplex',
|
||||
value: 'multiplex',
|
||||
description: 'All possible item combinations (cross join)',
|
||||
},
|
||||
],
|
||||
default: 'mergeByFields',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// mergeByFields ------------------------------------------------------------------
|
||||
{
|
||||
displayName: 'Fields to Match',
|
||||
name: 'mergeByFields',
|
||||
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',
|
||||
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: ' Enter the field name as text',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
combinationMode: ['mergeByFields'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Type',
|
||||
name: 'joinMode',
|
||||
type: 'options',
|
||||
// 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',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
combinationMode: ['mergeByFields'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: ['combine'],
|
||||
combinationMode: ['mergeByFields'],
|
||||
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: ['combine'],
|
||||
combinationMode: ['mergeByFields'],
|
||||
joinMode: ['keepNonMatches'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// chooseBranch -----------------------------------------------------------------
|
||||
{
|
||||
displayName: 'Output Type',
|
||||
name: 'chooseBranchMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Wait for Both Inputs to Arrive',
|
||||
value: 'waitForBoth',
|
||||
},
|
||||
],
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let 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 === 'combine') {
|
||||
const combinationMode = this.getNodeParameter('combinationMode', 0) as string;
|
||||
|
||||
if (combinationMode === '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 (combinationMode === 'mergeByPosition') {
|
||||
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 (input1?.length === 0 || input2?.length === 0) {
|
||||
// If data of any input is missing, return the data of
|
||||
// the input that contains data
|
||||
return [[...input1, ...input2]];
|
||||
}
|
||||
|
||||
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 (combinationMode === '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,
|
||||
'both',
|
||||
) as MatchFieldsOutput;
|
||||
const options = this.getNodeParameter('options', 0, {}) as MatchFieldsOptions;
|
||||
|
||||
options.joinMode = joinMode;
|
||||
options.outputDataFrom = outputDataFrom;
|
||||
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
let input1 = this.getInputData(0);
|
||||
let input2 = this.getInputData(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 = returnData.concat(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 = returnData.concat(
|
||||
mergedEntries,
|
||||
addSuffixToEntriesKeys(matches.unmatched1, '1'),
|
||||
);
|
||||
} else {
|
||||
returnData = returnData.concat(mergedEntries, matches.unmatched1);
|
||||
}
|
||||
} else {
|
||||
if (clashResolveOptions.resolveClash === 'addSuffix') {
|
||||
returnData = returnData.concat(
|
||||
mergedEntries,
|
||||
addSuffixToEntriesKeys(matches.unmatched2, '2'),
|
||||
);
|
||||
} else {
|
||||
returnData = returnData.concat(mergedEntries, matches.unmatched2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
const pairedItem = [
|
||||
...this.getInputData(0).map((inputData) => inputData.pairedItem),
|
||||
...this.getInputData(1).map((inputData) => inputData.pairedItem),
|
||||
].flatMap(preparePairedItemDataArray);
|
||||
|
||||
returnData.push({
|
||||
json: {},
|
||||
pairedItem,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { 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': ['combine'],
|
||||
'/combinationMode': ['mergeByFields'],
|
||||
},
|
||||
hide: {
|
||||
'/joinMode': ['keepMatches', 'keepNonMatches'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...clashHandlingProperties,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/mode': ['combine'],
|
||||
'/combinationMode': ['mergeByFields'],
|
||||
'/joinMode': ['keepMatches'],
|
||||
'/outputDataFrom': ['both'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...clashHandlingProperties,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/mode': ['combine'],
|
||||
'/combinationMode': ['multiplex', 'mergeByPosition'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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': ['combine'],
|
||||
'/combinationMode': ['mergeByFields'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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': ['combine'],
|
||||
'/combinationMode': ['mergeByPosition'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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': ['combine'],
|
||||
'/combinationMode': ['mergeByFields'],
|
||||
'/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': ['combine'],
|
||||
'/combinationMode': ['mergeByFields'],
|
||||
'/joinMode': ['enrichInput1', 'enrichInput2', 'keepEverything'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
hide: {
|
||||
mode: ['chooseBranch', 'append'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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';
|
||||
@@ -0,0 +1,361 @@
|
||||
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 type {
|
||||
GenericValue,
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
import { fuzzyCompare, preparePairedItemDataArray } from '@utils/utilities';
|
||||
|
||||
import type { ClashResolveOptions, MatchFieldsJoinMode, MatchFieldsOptions } from './interfaces';
|
||||
|
||||
type PairToMatch = {
|
||||
field1: string;
|
||||
field2: string;
|
||||
};
|
||||
|
||||
type EntryMatches = {
|
||||
entry: INodeExecutionData;
|
||||
matches: INodeExecutionData[];
|
||||
};
|
||||
|
||||
type CompareFunction = <T, U>(a: T, b: U) => boolean;
|
||||
|
||||
export function addSuffixToEntriesKeys(data: INodeExecutionData[], suffix: string) {
|
||||
return data.map((entry) => {
|
||||
const json: IDataObject = {};
|
||||
Object.keys(entry.json).forEach((key) => {
|
||||
json[`${key}_${suffix}`] = entry.json[key];
|
||||
});
|
||||
return { ...entry, json };
|
||||
});
|
||||
}
|
||||
|
||||
function findAllMatches(
|
||||
data: INodeExecutionData[],
|
||||
lookup: IDataObject,
|
||||
disableDotNotation: boolean,
|
||||
isEntriesEqual: CompareFunction,
|
||||
) {
|
||||
return data.reduce((acc, entry2, i) => {
|
||||
if (entry2 === undefined) return acc;
|
||||
|
||||
for (const key of Object.keys(lookup)) {
|
||||
const excpectedValue = lookup[key];
|
||||
let entry2FieldValue;
|
||||
|
||||
if (disableDotNotation) {
|
||||
entry2FieldValue = entry2.json[key];
|
||||
} else {
|
||||
entry2FieldValue = get(entry2.json, key);
|
||||
}
|
||||
|
||||
if (!isEntriesEqual(excpectedValue, 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 excpectedValue = lookup[key];
|
||||
let entry2FieldValue;
|
||||
|
||||
if (disableDotNotation) {
|
||||
entry2FieldValue = entry2.json[key];
|
||||
} else {
|
||||
entry2FieldValue = get(entry2.json, key);
|
||||
}
|
||||
|
||||
if (!isEntriesEqual(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,
|
||||
) {
|
||||
const data1 = [...input1];
|
||||
const data2 = [...input2];
|
||||
|
||||
const isEntriesEqual = fuzzyCompare(options.fuzzyCompare as boolean);
|
||||
const disableDotNotation = options.disableDotNotation || false;
|
||||
const multipleMatches = (options.multipleMatches as string) || 'all';
|
||||
|
||||
const filteredData = {
|
||||
matched: [] as EntryMatches[],
|
||||
matched2: [] as INodeExecutionData[],
|
||||
unmatched1: [] as INodeExecutionData[],
|
||||
unmatched2: [] as INodeExecutionData[],
|
||||
};
|
||||
|
||||
const matchedInInput2 = new Set<number>();
|
||||
|
||||
matchesLoop: for (const entry1 of data1) {
|
||||
const lookup: IDataObject = {};
|
||||
|
||||
fieldsToMatch.forEach((matchCase) => {
|
||||
let valueToCompare;
|
||||
if (disableDotNotation) {
|
||||
valueToCompare = entry1.json[matchCase.field1];
|
||||
} else {
|
||||
valueToCompare = get(entry1.json, matchCase.field1);
|
||||
}
|
||||
lookup[matchCase.field2] = valueToCompare;
|
||||
});
|
||||
|
||||
for (const fieldValue of Object.values(lookup)) {
|
||||
if (fieldValue === undefined) {
|
||||
filteredData.unmatched1.push(entry1);
|
||||
continue matchesLoop;
|
||||
}
|
||||
}
|
||||
|
||||
const foundedMatches =
|
||||
multipleMatches === 'all'
|
||||
? findAllMatches(data2, lookup, disableDotNotation, isEntriesEqual)
|
||||
: findFirstMatch(data2, lookup, disableDotNotation, isEntriesEqual);
|
||||
|
||||
const matches = foundedMatches.map((match) => match.entry) as INodeExecutionData[];
|
||||
foundedMatches.map((match) => matchedInInput2.add(match.index as number));
|
||||
|
||||
if (matches.length) {
|
||||
if (
|
||||
options.outputDataFrom === 'both' ||
|
||||
options.joinMode === 'enrichInput1' ||
|
||||
options.joinMode === 'enrichInput2'
|
||||
) {
|
||||
matches.forEach((match) => {
|
||||
filteredData.matched.push({
|
||||
entry: entry1,
|
||||
matches: [match],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
filteredData.matched.push({
|
||||
entry: entry1,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
filteredData.unmatched1.push(entry1);
|
||||
}
|
||||
}
|
||||
|
||||
data2.forEach((entry, i) => {
|
||||
if (matchedInInput2.has(i)) {
|
||||
filteredData.matched2.push(entry);
|
||||
} else {
|
||||
filteredData.unmatched2.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
return filteredData;
|
||||
}
|
||||
|
||||
export function selectMergeMethod(clashResolveOptions: ClashResolveOptions) {
|
||||
const mergeMode = clashResolveOptions.mergeMode as string;
|
||||
|
||||
if (clashResolveOptions.overrideEmpty) {
|
||||
function customizer(targetValue: GenericValue, srcValue: GenericValue) {
|
||||
if (srcValue === undefined || srcValue === null || srcValue === '') {
|
||||
return targetValue;
|
||||
}
|
||||
}
|
||||
if (mergeMode === 'deepMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => {
|
||||
const targetCopy = Object.assign({}, target);
|
||||
return mergeWith(targetCopy, ...source, customizer);
|
||||
};
|
||||
}
|
||||
if (mergeMode === 'shallowMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => {
|
||||
const targetCopy = Object.assign({}, target);
|
||||
return assignWith(targetCopy, ...source, customizer);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (mergeMode === 'deepMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source);
|
||||
}
|
||||
if (mergeMode === 'shallowMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => assign({}, target, ...source);
|
||||
}
|
||||
}
|
||||
return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source);
|
||||
}
|
||||
|
||||
export function mergeMatched(
|
||||
matched: EntryMatches[],
|
||||
clashResolveOptions: ClashResolveOptions,
|
||||
joinMode?: MatchFieldsJoinMode,
|
||||
) {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
let resolveClash = clashResolveOptions.resolveClash as string;
|
||||
|
||||
const mergeIntoSingleObject = selectMergeMethod(clashResolveOptions);
|
||||
|
||||
for (const match of matched) {
|
||||
let { entry, matches } = match;
|
||||
|
||||
let json: IDataObject = {};
|
||||
let binary: IBinaryKeyData = {};
|
||||
let pairedItem: IPairedItemData[] = [];
|
||||
|
||||
if (resolveClash === 'addSuffix') {
|
||||
const suffix1 = '1';
|
||||
const suffix2 = '2';
|
||||
|
||||
[entry] = addSuffixToEntriesKeys([entry], suffix1);
|
||||
matches = addSuffixToEntriesKeys(matches, suffix2);
|
||||
|
||||
json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((item) => item.json));
|
||||
binary = mergeIntoSingleObject(
|
||||
{ ...entry.binary },
|
||||
...matches.map((item) => item.binary as IDataObject),
|
||||
);
|
||||
pairedItem = [
|
||||
...preparePairedItemDataArray(entry.pairedItem),
|
||||
...matches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||
];
|
||||
} else {
|
||||
const preferInput1 = 'preferInput1';
|
||||
const preferInput2 = 'preferInput2';
|
||||
|
||||
if (resolveClash === undefined) {
|
||||
if (joinMode !== 'enrichInput2') {
|
||||
resolveClash = 'preferInput2';
|
||||
} 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 === preferInput2) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||
|
||||
export const properties: INodeProperties[] = [numberInputsProperty];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['append'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < inputsData.length; i++) {
|
||||
returnData.push.apply(returnData, inputsData[i]);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { preparePairedItemDataArray, updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
numberInputsProperty,
|
||||
{
|
||||
displayName: 'Output Type',
|
||||
name: 'chooseBranchMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Wait for All Inputs to Arrive',
|
||||
value: 'waitForAll',
|
||||
},
|
||||
],
|
||||
default: 'waitForAll',
|
||||
},
|
||||
{
|
||||
displayName: 'Output',
|
||||
name: 'output',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Data of Specified Input',
|
||||
value: 'specifiedInput',
|
||||
},
|
||||
{
|
||||
name: 'A Single, Empty Item',
|
||||
value: 'empty',
|
||||
},
|
||||
],
|
||||
default: 'specifiedInput',
|
||||
displayOptions: {
|
||||
show: {
|
||||
chooseBranchMode: ['waitForAll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Use Data of Input',
|
||||
name: 'useDataOfInput',
|
||||
type: 'options',
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
output: ['specifiedInput'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
loadOptionsMethod: 'getInputs',
|
||||
loadOptionsDependsOn: ['numberInputs'],
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description: 'The number of the input to use data of',
|
||||
validateType: 'number',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['chooseBranch'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const chooseBranchMode = this.getNodeParameter('chooseBranchMode', 0) as string;
|
||||
|
||||
if (chooseBranchMode === 'waitForAll') {
|
||||
const output = this.getNodeParameter('output', 0) as string;
|
||||
|
||||
if (output === 'specifiedInput') {
|
||||
const useDataOfInput = this.getNodeParameter('useDataOfInput', 0) as number;
|
||||
if (useDataOfInput > inputsData.length) {
|
||||
throw new NodeOperationError(this.getNode(), `Input ${useDataOfInput} doesn't exist`, {
|
||||
description: `The node has only ${inputsData.length} inputs, so selecting input ${useDataOfInput} is not possible.`,
|
||||
});
|
||||
}
|
||||
|
||||
const inputData = inputsData[useDataOfInput - 1];
|
||||
|
||||
returnData.push.apply(returnData, inputData);
|
||||
}
|
||||
if (output === 'empty') {
|
||||
const pairedItem = [
|
||||
...this.getInputData(0).map((inputData) => inputData.pairedItem),
|
||||
...this.getInputData(1).map((inputData) => inputData.pairedItem),
|
||||
].flatMap(preparePairedItemDataArray);
|
||||
|
||||
returnData.push({
|
||||
json: {},
|
||||
pairedItem,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import merge from 'lodash/merge';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { clashHandlingProperties, fuzzyCompareProperty } from '../../helpers/descriptions';
|
||||
import type { ClashResolveOptions } from '../../helpers/interfaces';
|
||||
import { addSuffixToEntriesKeys, selectMergeMethod } from '../../helpers/utils';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [clashHandlingProperties, fuzzyCompareProperty],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
combineBy: ['combineAll'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const clashHandling = this.getNodeParameter(
|
||||
'options.clashHandling.values',
|
||||
0,
|
||||
{},
|
||||
) as ClashResolveOptions;
|
||||
|
||||
let input1 = inputsData[0];
|
||||
let input2 = inputsData[1];
|
||||
|
||||
if (clashHandling.resolveClash === 'preferInput1') {
|
||||
[input1, input2] = [input2, input1];
|
||||
}
|
||||
|
||||
if (clashHandling.resolveClash === 'addSuffix') {
|
||||
input1 = addSuffixToEntriesKeys(input1, '1');
|
||||
input2 = addSuffixToEntriesKeys(input2, '2');
|
||||
}
|
||||
|
||||
const mergeIntoSingleObject = selectMergeMethod(clashHandling);
|
||||
|
||||
if (!input1 || !input2) {
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
let entry1: INodeExecutionData;
|
||||
let entry2: INodeExecutionData;
|
||||
|
||||
for (entry1 of input1) {
|
||||
for (entry2 of input2) {
|
||||
returnData.push({
|
||||
json: {
|
||||
...mergeIntoSingleObject(entry1.json, entry2.json),
|
||||
},
|
||||
binary: {
|
||||
...merge({}, entry1.binary, entry2.binary),
|
||||
},
|
||||
pairedItem: [entry1.pairedItem as IPairedItemData, entry2.pairedItem as IPairedItemData],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { clashHandlingProperties, fuzzyCompareProperty } from '../../helpers/descriptions';
|
||||
import type {
|
||||
ClashResolveOptions,
|
||||
MatchFieldsJoinMode,
|
||||
MatchFieldsOptions,
|
||||
MatchFieldsOutput,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
addSourceField,
|
||||
addSuffixToEntriesKeys,
|
||||
checkInput,
|
||||
checkMatchFieldsInput,
|
||||
findMatches,
|
||||
mergeMatched,
|
||||
} from '../../helpers/utils';
|
||||
|
||||
const multipleMatchesProperty: INodeProperties = {
|
||||
displayName: 'Multiple Matches',
|
||||
name: 'multipleMatches',
|
||||
type: 'options',
|
||||
default: 'all',
|
||||
options: [
|
||||
{
|
||||
name: 'Include All Matches',
|
||||
value: 'all',
|
||||
description: 'Output multiple items if there are multiple matches',
|
||||
},
|
||||
{
|
||||
name: 'Include First Match Only',
|
||||
value: 'first',
|
||||
description: 'Only ever output a single item per match',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Fields To Match Have Different Names',
|
||||
name: 'advanced',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether name(s) of field to match are different in input 1 and input 2',
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Match',
|
||||
name: 'fieldsToMatchString',
|
||||
type: 'string',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
|
||||
placeholder: 'e.g. id, name',
|
||||
default: '',
|
||||
requiresDataPath: 'multiple',
|
||||
description: 'Specify the fields to use for matching input items',
|
||||
hint: 'Drag or type the input field name',
|
||||
displayOptions: {
|
||||
show: {
|
||||
advanced: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Match',
|
||||
name: 'mergeByFields',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add Fields to Match',
|
||||
default: { values: [{ field1: '', field2: '' }] },
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
description: 'Specify the fields to use for matching input items',
|
||||
displayOptions: {
|
||||
show: {
|
||||
advanced: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Input 1 Field',
|
||||
name: 'field1',
|
||||
type: 'string',
|
||||
default: '',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
|
||||
placeholder: 'e.g. id',
|
||||
hint: 'Drag or type the input field name',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
{
|
||||
displayName: 'Input 2 Field',
|
||||
name: 'field2',
|
||||
type: 'string',
|
||||
default: '',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
|
||||
placeholder: 'e.g. id',
|
||||
hint: 'Drag or type the input field name',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Output Type',
|
||||
name: 'joinMode',
|
||||
type: 'options',
|
||||
description: 'How to select the items to send to output',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Keep Matches',
|
||||
value: 'keepMatches',
|
||||
description: 'Items that match, merged together (inner join)',
|
||||
},
|
||||
{
|
||||
name: 'Keep Non-Matches',
|
||||
value: 'keepNonMatches',
|
||||
description: "Items that don't match",
|
||||
},
|
||||
{
|
||||
name: 'Keep Everything',
|
||||
value: 'keepEverything',
|
||||
description: "Items that match merged together, plus items that don't match (outer join)",
|
||||
},
|
||||
{
|
||||
name: 'Enrich Input 1',
|
||||
value: 'enrichInput1',
|
||||
description: 'All of input 1, with data from input 2 added in (left join)',
|
||||
},
|
||||
{
|
||||
name: 'Enrich Input 2',
|
||||
value: 'enrichInput2',
|
||||
description: 'All of input 2, with data from input 1 added in (right join)',
|
||||
},
|
||||
],
|
||||
default: 'keepMatches',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Data From',
|
||||
name: 'outputDataFrom',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Both Inputs Merged Together',
|
||||
value: 'both',
|
||||
},
|
||||
{
|
||||
name: 'Input 1',
|
||||
value: 'input1',
|
||||
},
|
||||
{
|
||||
name: 'Input 2',
|
||||
value: 'input2',
|
||||
},
|
||||
],
|
||||
default: 'both',
|
||||
displayOptions: {
|
||||
show: {
|
||||
joinMode: ['keepMatches'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Data From',
|
||||
name: 'outputDataFrom',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Both Inputs Appended Together',
|
||||
value: 'both',
|
||||
},
|
||||
{
|
||||
name: 'Input 1',
|
||||
value: 'input1',
|
||||
},
|
||||
{
|
||||
name: 'Input 2',
|
||||
value: 'input2',
|
||||
},
|
||||
],
|
||||
default: 'both',
|
||||
displayOptions: {
|
||||
show: {
|
||||
joinMode: ['keepNonMatches'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
...clashHandlingProperties,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/joinMode': ['keepMatches', 'keepNonMatches'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...clashHandlingProperties,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/joinMode': ['keepMatches'],
|
||||
'/outputDataFrom': ['both'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Disable Dot Notation',
|
||||
name: 'disableDotNotation',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to disallow referencing child fields using `parent.child` in the field name',
|
||||
},
|
||||
fuzzyCompareProperty,
|
||||
{
|
||||
...multipleMatchesProperty,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/joinMode': ['keepMatches'],
|
||||
'/outputDataFrom': ['both'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...multipleMatchesProperty,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/joinMode': ['enrichInput1', 'enrichInput2', 'keepEverything'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
combineBy: ['combineByFields'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
let 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 = returnData.concat(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 = returnData.concat(
|
||||
mergedEntries,
|
||||
addSuffixToEntriesKeys(matches.unmatched1, '1'),
|
||||
);
|
||||
} else {
|
||||
returnData = returnData.concat(mergedEntries, matches.unmatched1);
|
||||
}
|
||||
} else {
|
||||
if (clashResolveOptions.resolveClash === 'addSuffix') {
|
||||
returnData = returnData.concat(
|
||||
mergedEntries,
|
||||
addSuffixToEntriesKeys(matches.unmatched2, '2'),
|
||||
);
|
||||
} else {
|
||||
returnData = returnData.concat(mergedEntries, matches.unmatched2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import merge from 'lodash/merge';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { clashHandlingProperties, numberInputsProperty } from '../../helpers/descriptions';
|
||||
import type { ClashResolveOptions } from '../../helpers/interfaces';
|
||||
import { addSuffixToEntriesKeys, selectMergeMethod } from '../../helpers/utils';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
numberInputsProperty,
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
...clashHandlingProperties,
|
||||
default: { values: { resolveClash: 'addSuffix' } },
|
||||
},
|
||||
{
|
||||
displayName: 'Include Any Unpaired Items',
|
||||
name: 'includeUnpaired',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether unpaired items should be included in the result when there are differing numbers of items among the inputs',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['combine'],
|
||||
combineBy: ['combineByPosition'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const clashHandling = this.getNodeParameter(
|
||||
'options.clashHandling.values',
|
||||
0,
|
||||
{},
|
||||
) as ClashResolveOptions;
|
||||
const includeUnpaired = this.getNodeParameter('options.includeUnpaired', 0, false) as boolean;
|
||||
|
||||
let preferredInputIndex: number;
|
||||
|
||||
if (clashHandling?.resolveClash?.includes('preferInput')) {
|
||||
preferredInputIndex = Number(clashHandling.resolveClash.replace('preferInput', '')) - 1;
|
||||
} else {
|
||||
preferredInputIndex = inputsData.length - 1;
|
||||
}
|
||||
|
||||
const preferred = inputsData[preferredInputIndex];
|
||||
|
||||
if (clashHandling.resolveClash === 'addSuffix') {
|
||||
for (const [inputIndex, input] of inputsData.entries()) {
|
||||
inputsData[inputIndex] = addSuffixToEntriesKeys(input, String(inputIndex + 1));
|
||||
}
|
||||
}
|
||||
|
||||
let numEntries: number;
|
||||
if (includeUnpaired) {
|
||||
numEntries = Math.max(...inputsData.map((input) => input.length), preferred.length);
|
||||
} else {
|
||||
numEntries = Math.min(...inputsData.map((input) => input.length), preferred.length);
|
||||
if (numEntries === 0) {
|
||||
this.addExecutionHints({
|
||||
message: 'Consider enabling "Include Any Unpaired Items" in options or check your inputs',
|
||||
});
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
|
||||
const mergeIntoSingleObject = selectMergeMethod(clashHandling);
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const preferredEntry = preferred[i] ?? ({} as INodeExecutionData);
|
||||
const restEntries = inputsData.map((input) => input[i] ?? ({} as INodeExecutionData));
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { numberInputsProperty } from '../../helpers/descriptions';
|
||||
import { modifySelectQuery, rowToExecutionData } from '../../helpers/utils';
|
||||
import { loadAlaSqlSandbox, runAlaSqlInSandbox } from '../../helpers/sandbox-utils';
|
||||
|
||||
type OperationOptions = {
|
||||
emptyQueryResult: 'success' | 'empty';
|
||||
};
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
numberInputsProperty,
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
default: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
|
||||
noDataExpression: true,
|
||||
description: 'Input data available as tables with corresponding number, e.g. input1, input2',
|
||||
hint: 'Supports <a href="https://github.com/alasql/alasql/wiki/Supported-SQL-statements" target="_blank">most</a> of the SQL-99 language',
|
||||
required: true,
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
editor: 'sqlEditor',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Empty Query Result',
|
||||
name: 'emptyQueryResult',
|
||||
type: 'options',
|
||||
description: 'What to return if the query executed successfully but returned no results',
|
||||
options: [
|
||||
{
|
||||
name: 'Success',
|
||||
value: 'success',
|
||||
},
|
||||
{
|
||||
name: 'Empty Result',
|
||||
value: 'empty',
|
||||
},
|
||||
],
|
||||
default: 'empty',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['combineBySql'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
const prepareError = (node: INode, error: Error) => {
|
||||
let message = '';
|
||||
if (typeof error === 'string') {
|
||||
message = error;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
throw new NodeOperationError(node, error, {
|
||||
message: 'Issue while executing query',
|
||||
description: message,
|
||||
itemIndex: 0,
|
||||
});
|
||||
};
|
||||
|
||||
async function executeSelectWithMappedPairedItems(
|
||||
node: INode,
|
||||
inputsData: INodeExecutionData[][],
|
||||
query: string,
|
||||
returnSuccessItemIfEmpty: boolean,
|
||||
context: Awaited<ReturnType<typeof loadAlaSqlSandbox>>,
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const tableData = inputsData.map((inputData) =>
|
||||
inputData.map((entry) => ({ ...entry.json, pairedItem: entry.pairedItem })),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runAlaSqlInSandbox(
|
||||
context,
|
||||
tableData,
|
||||
modifySelectQuery(query, inputsData.length),
|
||||
);
|
||||
|
||||
for (const item of result) {
|
||||
if (Array.isArray(item)) {
|
||||
returnData.push.apply(returnData, item.map(rowToExecutionData));
|
||||
} else if (typeof item === 'object') {
|
||||
returnData.push(rowToExecutionData(item));
|
||||
}
|
||||
}
|
||||
|
||||
if (!returnData.length && returnSuccessItemIfEmpty) {
|
||||
returnData.push({ json: { success: true } });
|
||||
}
|
||||
} catch (error) {
|
||||
prepareError(node, error as Error);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputsData: INodeExecutionData[][],
|
||||
): Promise<INodeExecutionData[][]> {
|
||||
const node = this.getNode();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const pairedItem: IPairedItemData[] = [];
|
||||
const options = this.getNodeParameter('options', 0, {}) as OperationOptions;
|
||||
const workflowId = this.getWorkflow().id;
|
||||
|
||||
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 context = await loadAlaSqlSandbox();
|
||||
|
||||
const isSelectQuery = node.typeVersion >= 3.1 ? query.toLowerCase().startsWith('select') : false;
|
||||
const returnSuccessItemIfEmpty =
|
||||
node.typeVersion <= 3.1 ? true : options.emptyQueryResult === 'success';
|
||||
|
||||
if (isSelectQuery) {
|
||||
try {
|
||||
return await executeSelectWithMappedPairedItems(
|
||||
node,
|
||||
inputsData,
|
||||
query,
|
||||
returnSuccessItemIfEmpty,
|
||||
context,
|
||||
);
|
||||
} catch (error) {
|
||||
Container.get(ErrorReporter).error(error, {
|
||||
extra: {
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
nodeVersion: node.typeVersion,
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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.apply(pairedItem, pairedItems);
|
||||
return;
|
||||
}
|
||||
|
||||
pairedItem.push({
|
||||
item: item.pairedItem.item,
|
||||
input: i,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const tableData: IDataObject[][] = inputsData.map((inputData) =>
|
||||
inputData.map((entry) => entry.json),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runAlaSqlInSandbox(context, tableData, query);
|
||||
|
||||
for (const item of result) {
|
||||
if (Array.isArray(item)) {
|
||||
returnData.push.apply(
|
||||
returnData,
|
||||
item.map((json) => ({ json, pairedItem })),
|
||||
);
|
||||
} else if (typeof item === 'object') {
|
||||
returnData.push({ json: item, pairedItem });
|
||||
}
|
||||
}
|
||||
|
||||
if (!returnData.length && returnSuccessItemIfEmpty) {
|
||||
returnData.push({ json: { success: true }, pairedItem });
|
||||
}
|
||||
} catch (error) {
|
||||
prepareError(node, error as Error);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 combineByPosition from './combineByPosition';
|
||||
import * as combineBySql from './combineBySql';
|
||||
|
||||
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',
|
||||
builderHint: {
|
||||
message:
|
||||
'Append items from multiple branches into a single list sequentially. Waits for all running branches. Supports any number of inputs. @example input1: [{ x }] [{ y }] input2: [{ z }]. Output: [{ x }, { y }, { z }]. Next node will execute 3 times with each item. Set executeOnce on next node to execute once.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Combine',
|
||||
value: 'combine',
|
||||
description: 'Merge matching items together',
|
||||
builderHint: {
|
||||
message:
|
||||
'Combines items from 2 branches. Waits for both to have input data. @example **combine by position** input1: [{ x }, { y }] input2: [{ z }] output: [{ x, y }, { x: undefined, y: undefined, z }] @example **combine by key** input1: [{ id: 1, x }, { id: 2, y }] input2: [{ id: 1, z }] output: [{ id: 1, x, z }, { id: 2, y }]',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SQL Query',
|
||||
value: 'combineBySql',
|
||||
description: 'Write a query to do the merge',
|
||||
builderHint: {
|
||||
message:
|
||||
'Need to combine more than 2 branches? Use SQL Query for advanced operations. Waits for all inputs. @example Results depend on query - can filter, join, aggregate',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Choose Branch',
|
||||
value: 'chooseBranch',
|
||||
description: 'Output data from a specific branch, without modifying it',
|
||||
builderHint: {
|
||||
message:
|
||||
'Do you need to select data from only ONE specific input and discard the others? Use Choose Branch after conditional nodes to pick which path to continue. Waits for all inputs. @example 3 items from Input A + 2 items from Input B, choose Input A → 3 items',
|
||||
},
|
||||
},
|
||||
],
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
export type MergeType =
|
||||
| 'append'
|
||||
| 'combineByFields'
|
||||
| 'combineBySql'
|
||||
| 'combineByPosition'
|
||||
| 'combineAll'
|
||||
| 'chooseBranch';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as mode from './mode';
|
||||
import type { MergeType } from './node.type';
|
||||
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;
|
||||
}
|
||||
|
||||
return await mode[operationMode as MergeType].execute.call(this, inputsData);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, 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, 3.1, 3.2],
|
||||
defaults: {
|
||||
name: 'Merge',
|
||||
},
|
||||
inputs: `={{(${configuredInputs})($parameter)}}`,
|
||||
outputs: [NodeConnectionTypes.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],
|
||||
};
|
||||
@@ -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' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import ivm from 'isolated-vm';
|
||||
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
// Singleton – recreated only after resetSandboxCache() (tests) or isolate disposal.
|
||||
let sandboxIsolate: ivm.Isolate | null = null;
|
||||
let sandboxContext: ivm.Context | null = null;
|
||||
|
||||
/** Disposes the cached isolate. Exposed for tests only. */
|
||||
export function resetSandboxCache(): void {
|
||||
sandboxContext = null;
|
||||
if (sandboxIsolate && !sandboxIsolate.isDisposed) {
|
||||
sandboxIsolate.dispose();
|
||||
}
|
||||
sandboxIsolate = null;
|
||||
}
|
||||
|
||||
/** Returns a cached isolated-vm context with alasql pre-loaded. Creates it on first call. */
|
||||
export async function loadAlaSqlSandbox(): Promise<ivm.Context> {
|
||||
if (sandboxContext && sandboxIsolate && !sandboxIsolate.isDisposed) {
|
||||
return sandboxContext;
|
||||
}
|
||||
|
||||
sandboxIsolate = new ivm.Isolate({ memoryLimit: 64 }); // 64 MB hard limit
|
||||
sandboxContext = await sandboxIsolate.createContext();
|
||||
|
||||
// Browser bundle only – no Node.js fs/require handlers inside the isolate.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const alasqlBundlePath = require.resolve('alasql/dist/alasql.min.js');
|
||||
await sandboxContext.eval(await readFile(alasqlBundlePath, 'utf-8'));
|
||||
await sandboxContext.eval('Object.freeze(alasql.fn)');
|
||||
|
||||
return sandboxContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a SQL query against plain-object table data inside the isolated-vm sandbox.
|
||||
* Only JSON-serialisable values cross the isolate boundary.
|
||||
*/
|
||||
export async function runAlaSqlInSandbox(
|
||||
context: ivm.Context,
|
||||
tableData: unknown[][],
|
||||
query: string,
|
||||
): Promise<IDataObject[]> {
|
||||
// UUID per invocation so concurrent calls sharing the singleton context
|
||||
// don't collide on alasql.databases.
|
||||
const dbId = randomUUID();
|
||||
|
||||
// Double-serialization: outer JSON.stringify produces a JSON string, inner produces a JSON literal
|
||||
// embedded in the script source. Inside the isolate, JSON.parse reconstructs the plain array.
|
||||
// This ensures data enters the isolate as a parsed JSON literal, never as live objects.
|
||||
const script = `(function() {
|
||||
const __rows = JSON.parse(${JSON.stringify(JSON.stringify(tableData))});
|
||||
const __db = new alasql.Database(${JSON.stringify(dbId)});
|
||||
try {
|
||||
for (let i = 0; i < __rows.length; i++) {
|
||||
__db.exec('CREATE TABLE input' + (i + 1));
|
||||
__db.tables['input' + (i + 1)].data = __rows[i];
|
||||
}
|
||||
return JSON.stringify(__db.exec(${JSON.stringify(query)}));
|
||||
} finally {
|
||||
delete alasql.databases[${JSON.stringify(dbId)}];
|
||||
}
|
||||
})()`;
|
||||
|
||||
const resultJson = (await context.eval(script, { timeout: 5000, copy: true })) as string;
|
||||
try {
|
||||
return JSON.parse(resultJson) as IDataObject[];
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse SQL result: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
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 type {
|
||||
GenericValue,
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
IPairedItemData,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
import { fuzzyCompare, preparePairedItemDataArray } from '@utils/utilities';
|
||||
|
||||
import type { ClashResolveOptions, MatchFieldsJoinMode, MatchFieldsOptions } from './interfaces';
|
||||
|
||||
type PairToMatch = {
|
||||
field1: string;
|
||||
field2: string;
|
||||
};
|
||||
|
||||
type EntryMatches = {
|
||||
entry: INodeExecutionData;
|
||||
matches: INodeExecutionData[];
|
||||
};
|
||||
|
||||
type CompareFunction = <T, U>(a: T, b: U) => boolean;
|
||||
|
||||
export function addSuffixToEntriesKeys(data: INodeExecutionData[], suffix: string) {
|
||||
return data.map((entry) => {
|
||||
const json: IDataObject = {};
|
||||
Object.keys(entry.json).forEach((key) => {
|
||||
json[`${key}_${suffix}`] = entry.json[key];
|
||||
});
|
||||
return { ...entry, json };
|
||||
});
|
||||
}
|
||||
|
||||
function findAllMatches(
|
||||
data: INodeExecutionData[],
|
||||
lookup: IDataObject,
|
||||
disableDotNotation: boolean,
|
||||
isEntriesEqual: CompareFunction,
|
||||
) {
|
||||
return data.reduce((acc, entry2, i) => {
|
||||
if (entry2 === undefined) return acc;
|
||||
|
||||
for (const key of Object.keys(lookup)) {
|
||||
const expectedValue = lookup[key];
|
||||
let entry2FieldValue;
|
||||
|
||||
if (disableDotNotation) {
|
||||
entry2FieldValue = entry2.json[key];
|
||||
} else {
|
||||
entry2FieldValue = get(entry2.json, key);
|
||||
}
|
||||
|
||||
if (!isEntriesEqual(expectedValue, entry2FieldValue)) {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
return acc.concat({
|
||||
entry: entry2,
|
||||
index: i,
|
||||
});
|
||||
}, [] as IDataObject[]);
|
||||
}
|
||||
|
||||
function findFirstMatch(
|
||||
data: INodeExecutionData[],
|
||||
lookup: IDataObject,
|
||||
disableDotNotation: boolean,
|
||||
isEntriesEqual: CompareFunction,
|
||||
) {
|
||||
const index = data.findIndex((entry2) => {
|
||||
if (entry2 === undefined) return false;
|
||||
|
||||
for (const key of Object.keys(lookup)) {
|
||||
const expectedValue = lookup[key];
|
||||
let entry2FieldValue;
|
||||
|
||||
if (disableDotNotation) {
|
||||
entry2FieldValue = entry2.json[key];
|
||||
} else {
|
||||
entry2FieldValue = get(entry2.json, key);
|
||||
}
|
||||
|
||||
if (!isEntriesEqual(expectedValue, entry2FieldValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
if (index === -1) return [];
|
||||
|
||||
return [{ entry: data[index], index }];
|
||||
}
|
||||
|
||||
export function findMatches(
|
||||
input1: INodeExecutionData[],
|
||||
input2: INodeExecutionData[],
|
||||
fieldsToMatch: PairToMatch[],
|
||||
options: MatchFieldsOptions,
|
||||
) {
|
||||
const data1 = [...input1];
|
||||
const data2 = [...input2];
|
||||
|
||||
const isEntriesEqual = fuzzyCompare(options.fuzzyCompare as boolean);
|
||||
const disableDotNotation = options.disableDotNotation || false;
|
||||
const multipleMatches = (options.multipleMatches as string) || 'all';
|
||||
|
||||
const filteredData = {
|
||||
matched: [] as EntryMatches[],
|
||||
matched2: [] as INodeExecutionData[],
|
||||
unmatched1: [] as INodeExecutionData[],
|
||||
unmatched2: [] as INodeExecutionData[],
|
||||
};
|
||||
|
||||
const matchedInInput2 = new Set<number>();
|
||||
|
||||
matchesLoop: for (const entry1 of data1) {
|
||||
const lookup: IDataObject = {};
|
||||
|
||||
fieldsToMatch.forEach((matchCase) => {
|
||||
let valueToCompare;
|
||||
if (disableDotNotation) {
|
||||
valueToCompare = entry1.json[matchCase.field1];
|
||||
} else {
|
||||
valueToCompare = get(entry1.json, matchCase.field1);
|
||||
}
|
||||
lookup[matchCase.field2] = valueToCompare;
|
||||
});
|
||||
|
||||
for (const fieldValue of Object.values(lookup)) {
|
||||
if (fieldValue === undefined) {
|
||||
filteredData.unmatched1.push(entry1);
|
||||
continue matchesLoop;
|
||||
}
|
||||
}
|
||||
|
||||
const foundedMatches =
|
||||
multipleMatches === 'all'
|
||||
? findAllMatches(data2, lookup, disableDotNotation, isEntriesEqual)
|
||||
: findFirstMatch(data2, lookup, disableDotNotation, isEntriesEqual);
|
||||
|
||||
const matches = foundedMatches.map((match) => match.entry) as INodeExecutionData[];
|
||||
foundedMatches.map((match) => matchedInInput2.add(match.index as number));
|
||||
|
||||
if (matches.length) {
|
||||
if (
|
||||
options.outputDataFrom === 'both' ||
|
||||
options.joinMode === 'enrichInput1' ||
|
||||
options.joinMode === 'enrichInput2'
|
||||
) {
|
||||
matches.forEach((match) => {
|
||||
filteredData.matched.push({
|
||||
entry: entry1,
|
||||
matches: [match],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
filteredData.matched.push({
|
||||
entry: entry1,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
filteredData.unmatched1.push(entry1);
|
||||
}
|
||||
}
|
||||
|
||||
data2.forEach((entry, i) => {
|
||||
if (matchedInInput2.has(i)) {
|
||||
filteredData.matched2.push(entry);
|
||||
} else {
|
||||
filteredData.unmatched2.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
return filteredData;
|
||||
}
|
||||
|
||||
export function selectMergeMethod(clashResolveOptions: ClashResolveOptions) {
|
||||
const mergeMode = clashResolveOptions.mergeMode as string;
|
||||
|
||||
if (clashResolveOptions.overrideEmpty) {
|
||||
function customizer(targetValue: GenericValue, srcValue: GenericValue) {
|
||||
if (srcValue === undefined || srcValue === null || srcValue === '') {
|
||||
return targetValue;
|
||||
}
|
||||
}
|
||||
if (mergeMode === 'deepMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => {
|
||||
const targetCopy = Object.assign({}, target);
|
||||
return mergeWith(targetCopy, ...source, customizer);
|
||||
};
|
||||
}
|
||||
if (mergeMode === 'shallowMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => {
|
||||
const targetCopy = Object.assign({}, target);
|
||||
return assignWith(targetCopy, ...source, customizer);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (mergeMode === 'deepMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source);
|
||||
}
|
||||
if (mergeMode === 'shallowMerge') {
|
||||
return (target: IDataObject, ...source: IDataObject[]) => assign({}, target, ...source);
|
||||
}
|
||||
}
|
||||
return (target: IDataObject, ...source: IDataObject[]) => merge({}, target, ...source);
|
||||
}
|
||||
|
||||
export function mergeMatched(
|
||||
matched: EntryMatches[],
|
||||
clashResolveOptions: ClashResolveOptions,
|
||||
joinMode?: MatchFieldsJoinMode,
|
||||
) {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
let resolveClash = clashResolveOptions.resolveClash as string;
|
||||
|
||||
const mergeIntoSingleObject = selectMergeMethod(clashResolveOptions);
|
||||
|
||||
for (const match of matched) {
|
||||
let { entry, matches } = match;
|
||||
|
||||
let json: IDataObject = {};
|
||||
let binary: IBinaryKeyData = {};
|
||||
let pairedItem: IPairedItemData[] = [];
|
||||
|
||||
if (resolveClash === 'addSuffix') {
|
||||
const suffix1 = '1';
|
||||
const suffix2 = '2';
|
||||
|
||||
[entry] = addSuffixToEntriesKeys([entry], suffix1);
|
||||
matches = addSuffixToEntriesKeys(matches, suffix2);
|
||||
|
||||
json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((item) => item.json));
|
||||
binary = mergeIntoSingleObject(
|
||||
{ ...entry.binary },
|
||||
...matches.map((item) => item.binary as IDataObject),
|
||||
);
|
||||
pairedItem = [
|
||||
...preparePairedItemDataArray(entry.pairedItem),
|
||||
...matches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||
];
|
||||
} else {
|
||||
const preferInput1 = 'preferInput1';
|
||||
const preferLast = 'preferLast';
|
||||
|
||||
if (resolveClash === undefined) {
|
||||
if (joinMode !== 'enrichInput2') {
|
||||
resolveClash = 'preferLast';
|
||||
} else {
|
||||
resolveClash = 'preferInput1';
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveClash === preferInput1) {
|
||||
const [firstMatch, ...restMatches] = matches;
|
||||
json = mergeIntoSingleObject(
|
||||
{ ...firstMatch.json },
|
||||
...restMatches.map((item) => item.json),
|
||||
entry.json,
|
||||
);
|
||||
binary = mergeIntoSingleObject(
|
||||
{ ...firstMatch.binary },
|
||||
...restMatches.map((item) => item.binary as IDataObject),
|
||||
entry.binary as IDataObject,
|
||||
);
|
||||
|
||||
pairedItem = [
|
||||
...preparePairedItemDataArray(firstMatch.pairedItem),
|
||||
...restMatches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||
...preparePairedItemDataArray(entry.pairedItem),
|
||||
];
|
||||
}
|
||||
|
||||
if (resolveClash === preferLast) {
|
||||
json = mergeIntoSingleObject({ ...entry.json }, ...matches.map((item) => item.json));
|
||||
binary = mergeIntoSingleObject(
|
||||
{ ...entry.binary },
|
||||
...matches.map((item) => item.binary as IDataObject),
|
||||
);
|
||||
pairedItem = [
|
||||
...preparePairedItemDataArray(entry.pairedItem),
|
||||
...matches.map((item) => preparePairedItemDataArray(item.pairedItem)).flat(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
json,
|
||||
binary,
|
||||
pairedItem,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function checkMatchFieldsInput(data: IDataObject[]) {
|
||||
if (data.length === 1 && data[0].field1 === '' && data[0].field2 === '') {
|
||||
throw new ApplicationError(
|
||||
'You need to define at least one pair of fields in "Fields to Match" to match on',
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
for (const [index, pair] of data.entries()) {
|
||||
if (pair.field1 === '' || pair.field2 === '') {
|
||||
throw new ApplicationError(
|
||||
`You need to define both fields in "Fields to Match" for pair ${index + 1},
|
||||
field 1 = '${pair.field1}'
|
||||
field 2 = '${pair.field2}'`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
}
|
||||
return data as PairToMatch[];
|
||||
}
|
||||
|
||||
export function checkInput(
|
||||
input: INodeExecutionData[],
|
||||
fields: string[],
|
||||
disableDotNotation: boolean,
|
||||
inputLabel: string,
|
||||
) {
|
||||
for (const field of fields) {
|
||||
const isPresent = (input || []).some((entry) => {
|
||||
if (disableDotNotation) {
|
||||
return entry.json.hasOwnProperty(field);
|
||||
}
|
||||
return get(entry.json, field, undefined) !== undefined;
|
||||
});
|
||||
if (!isPresent) {
|
||||
throw new ApplicationError(
|
||||
`Field '${field}' is not present in any of items in '${inputLabel}'`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function addSourceField(data: INodeExecutionData[], sourceField: string) {
|
||||
return data.map((entry) => {
|
||||
const json = {
|
||||
...entry.json,
|
||||
_source: sourceField,
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
json,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const configuredInputs = (parameters: INodeParameters) => {
|
||||
return Array.from({ length: (parameters.numberInputs as number) || 2 }, (_, i) => ({
|
||||
type: 'main',
|
||||
displayName: `Input ${(i + 1).toString()}`,
|
||||
}));
|
||||
};
|
||||
|
||||
export function getNodeInputsData(this: IExecuteFunctions) {
|
||||
const returnData: INodeExecutionData[][] = [];
|
||||
|
||||
const inputs = NodeHelpers.getConnectionTypes(this.getNodeInputs()).filter(
|
||||
(type) => type === NodeConnectionTypes.Main,
|
||||
);
|
||||
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
try {
|
||||
returnData.push(this.getInputData(i) ?? []);
|
||||
} catch (error) {
|
||||
returnData.push([]);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export const rowToExecutionData = (data: IDataObject): INodeExecutionData => {
|
||||
const keys = Object.keys(data);
|
||||
const pairedItem: IPairedItemData[] = [];
|
||||
const json: IDataObject = {};
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith('pairedItem')) {
|
||||
if (data[key] === undefined) continue;
|
||||
pairedItem.push(data[key] as IPairedItemData);
|
||||
} else {
|
||||
json[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
return { json, pairedItem };
|
||||
};
|
||||
|
||||
export function modifySelectQuery(userQuery: string, inputLength: number): string {
|
||||
const selectMatch = userQuery.match(/SELECT\s+(.+?)\s+FROM/i);
|
||||
if (!selectMatch) return userQuery;
|
||||
|
||||
let selectedColumns = selectMatch[1].trim();
|
||||
|
||||
if (selectedColumns === '*') {
|
||||
return userQuery;
|
||||
}
|
||||
|
||||
const pairedItemColumns = [];
|
||||
|
||||
for (let i = 1; i <= inputLength; i++) {
|
||||
if (userQuery.includes(`input${i}`)) {
|
||||
pairedItemColumns.push(`input${i}.pairedItem AS pairedItem${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
selectedColumns += pairedItemColumns.length ? ', ' + pairedItemColumns.join(', ') : '';
|
||||
|
||||
return userQuery.replace(selectMatch[0], `SELECT ${selectedColumns} FROM`);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as loadOptions from './loadOptions';
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
export async function getResolveClashOptions(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const numberOfInputs = this.getNodeParameter('numberInputs', 2) as number;
|
||||
|
||||
if (numberOfInputs <= 2) {
|
||||
return [
|
||||
{
|
||||
name: 'Always Add Input Number to Field Names',
|
||||
value: 'addSuffix',
|
||||
},
|
||||
{
|
||||
name: 'Prefer Input 1 Version',
|
||||
value: 'preferInput1',
|
||||
},
|
||||
{
|
||||
name: 'Prefer Input 2 Version',
|
||||
value: 'preferLast',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
name: 'Always Add Input Number to Field Names',
|
||||
value: 'addSuffix',
|
||||
},
|
||||
{
|
||||
name: 'Use Earliest Version',
|
||||
value: 'preferInput1',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
export async function getInputs(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const numberOfInputs = this.getNodeParameter('numberInputs', 2) as number;
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
||||
for (let i = 0; i < numberOfInputs; i++) {
|
||||
returnData.push({
|
||||
name: `${i + 1}`,
|
||||
value: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
Reference in New Issue
Block a user