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,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.mySql",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development", "Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/mysql/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.mysql/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { MySqlV1 } from './v1/MySqlV1.node';
|
||||
import { MySqlV2 } from './v2/MySqlV2.node';
|
||||
|
||||
export class MySql extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'MySQL',
|
||||
name: 'mySql',
|
||||
icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' },
|
||||
group: ['input'],
|
||||
defaultVersion: 2.5,
|
||||
description: 'Get, add and update data in MySQL',
|
||||
parameterPane: 'wide',
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new MySqlV1(baseDescription),
|
||||
2: new MySqlV2(baseDescription),
|
||||
2.1: new MySqlV2(baseDescription),
|
||||
2.2: new MySqlV2(baseDescription),
|
||||
2.3: new MySqlV2(baseDescription),
|
||||
2.4: new MySqlV2(baseDescription),
|
||||
2.5: new MySqlV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8445 30.3684C34.668 30.3139 32.9815 30.5319 31.5665 31.1304C31.1585 31.2934 30.5055 31.2939 30.451 31.8104C30.669 32.0284 30.696 32.3819 30.8865 32.6809C31.213 33.2249 31.784 33.9594 32.301 34.3404C32.872 34.7754 33.4435 35.2109 34.042 35.5919C35.103 36.2449 36.3 36.6259 37.334 37.2784C37.9325 37.6589 38.531 38.1489 39.1295 38.5569C39.429 38.7744 39.619 39.1279 40 39.2644V39.1829C39.8095 38.9379 39.755 38.5844 39.565 38.3124C39.293 38.0404 39.021 37.7954 38.749 37.5234C37.96 36.4624 36.9805 35.5374 35.9195 34.7759C35.049 34.1774 33.1445 33.3614 32.791 32.3544L32.7365 32.2999C33.335 32.2454 34.0425 32.0279 34.6135 31.8644C35.5385 31.6194 36.382 31.6739 37.334 31.4294C37.7728 31.3147 38.2084 31.1877 38.64 31.0484V30.8039C38.1505 30.3139 37.7965 29.6614 37.28 29.1989C35.8925 28.0014 34.369 26.8319 32.791 25.8529C31.9475 25.3089 30.8595 24.9549 29.9615 24.4924C29.635 24.3289 29.091 24.2474 28.9005 23.9754C28.4105 23.3769 28.1385 22.5879 27.785 21.8804C26.9965 20.3839 26.2345 18.7249 25.554 17.1469C25.0645 16.0859 24.765 15.0249 24.1665 14.0454C21.3645 9.42041 18.3175 6.61842 13.6381 3.87043C12.6316 3.29943 11.4346 3.05443 10.1561 2.75493L8.11557 2.64593C7.68057 2.45543 7.24507 1.93843 6.86407 1.69393C5.31357 0.714932 1.31458 -1.40656 0.171582 1.39543C-0.562917 3.16343 1.26008 4.90442 1.88558 5.80242C2.34808 6.42792 2.94658 7.13542 3.27308 7.84292C3.46358 8.30492 3.51757 8.79492 3.70807 9.28491C4.14357 10.4819 4.55157 11.8149 5.12257 12.9304C5.42207 13.5014 5.74807 14.1004 6.12907 14.6169C6.34657 14.9159 6.72757 15.0524 6.80907 15.5419C6.42807 16.0859 6.40107 16.9024 6.18307 17.5824C5.20357 20.6564 5.58457 24.4654 6.97207 26.7234C7.40707 27.4034 8.44106 28.8999 9.82856 28.3284C11.0526 27.8389 10.7806 26.2879 11.1346 24.9279C11.2166 24.6014 11.1621 24.3839 11.3251 24.1664V24.2209L12.4406 26.4789C13.2841 27.8119 14.7531 29.1994 15.977 30.1244C16.63 30.6139 17.147 31.4574 17.963 31.7569V31.6749H17.9085C17.745 31.4299 17.5005 31.3214 17.283 31.1309C16.7935 30.6414 16.249 30.0429 15.8685 29.4984C14.7261 27.9749 13.7191 26.2884 12.8216 24.5469C12.3866 23.7034 12.0056 22.7784 11.6516 21.9354C11.4881 21.6089 11.4881 21.1194 11.2161 20.9559C10.8081 21.5544 10.2096 22.0714 9.91006 22.8059C9.39356 23.9759 9.33906 25.4174 9.14856 26.9139C9.04006 26.9414 9.09406 26.9139 9.03956 26.9684C8.16907 26.7504 7.86957 25.8529 7.54307 25.0914C6.72707 23.1599 6.59107 20.0584 7.29857 17.8279C7.48907 17.2564 8.30507 15.4609 7.97857 14.9169C7.81557 14.3999 7.27107 14.1009 6.97207 13.6929C6.61857 13.1759 6.23757 12.5229 5.99257 11.9519C5.33957 10.4284 5.01307 8.74192 4.30607 7.21792C3.97957 6.51042 3.40858 5.77592 2.94558 5.12342C2.43008 4.38792 1.85858 3.87093 1.45058 3.00043C1.31458 2.70143 1.12408 2.21143 1.34158 1.88493C1.39608 1.66743 1.50458 1.58593 1.72258 1.53143C2.07608 1.23193 3.08308 1.61293 3.43658 1.77593C4.44307 2.18393 5.28657 2.56493 6.13007 3.13643C6.51107 3.40843 6.91907 3.92543 7.40857 4.06143H7.98007C8.85056 4.25192 9.83006 4.11592 10.6461 4.36042C12.0881 4.82292 13.3936 5.50292 14.5636 6.23742C18.1275 8.49542 21.0655 11.7059 23.0515 15.5414C23.378 16.1669 23.514 16.7384 23.813 17.3914C24.3845 18.7244 25.0915 20.0849 25.663 21.3904C26.234 22.6689 26.7785 23.9749 27.5945 25.0359C28.0025 25.6069 29.635 25.9064 30.3695 26.2059C30.9135 26.4504 31.757 26.6684 32.2465 26.9674C33.1715 27.5384 34.0965 28.1914 34.967 28.8174C35.4025 29.1439 36.7625 29.8239 36.844 30.3679L36.8445 30.3684ZM9.09506 6.72742C8.71888 6.72376 8.34385 6.76948 7.97957 6.86342V6.91792H8.03407C8.25207 7.35292 8.63256 7.65242 8.90456 8.03342L9.53056 9.33941L9.58506 9.28491C9.96606 9.01291 10.1566 8.57742 10.1566 7.92442C9.99306 7.73392 9.96606 7.54342 9.83006 7.35292C9.66706 7.08092 9.31306 6.94492 9.09556 6.72692L9.09506 6.72742Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8445 30.3684C34.668 30.3139 32.9815 30.5319 31.5665 31.1304C31.1585 31.2934 30.5055 31.2939 30.451 31.8104C30.669 32.0284 30.696 32.3819 30.8865 32.6809C31.213 33.2249 31.784 33.9594 32.301 34.3404C32.872 34.7754 33.4435 35.2109 34.042 35.5919C35.103 36.2449 36.3 36.6259 37.334 37.2784C37.9325 37.6589 38.531 38.1489 39.1295 38.5569C39.429 38.7744 39.619 39.1279 40 39.2644V39.1829C39.8095 38.9379 39.755 38.5844 39.565 38.3124C39.293 38.0404 39.021 37.7954 38.749 37.5234C37.96 36.4624 36.9805 35.5374 35.9195 34.7759C35.049 34.1774 33.1445 33.3614 32.791 32.3544L32.7365 32.2999C33.335 32.2454 34.0425 32.0279 34.6135 31.8644C35.5385 31.6194 36.382 31.6739 37.334 31.4294C37.7728 31.3147 38.2084 31.1877 38.64 31.0484V30.8039C38.1505 30.3139 37.7965 29.6614 37.28 29.1989C35.8925 28.0014 34.369 26.8319 32.791 25.8529C31.9475 25.3089 30.8595 24.9549 29.9615 24.4924C29.635 24.3289 29.091 24.2474 28.9005 23.9754C28.4105 23.3769 28.1385 22.5879 27.785 21.8804C26.9965 20.3839 26.2345 18.7249 25.554 17.1469C25.0645 16.0859 24.765 15.0249 24.1665 14.0454C21.3645 9.42041 18.3175 6.61842 13.6381 3.87043C12.6316 3.29943 11.4346 3.05443 10.1561 2.75493L8.11557 2.64593C7.68057 2.45543 7.24507 1.93843 6.86407 1.69393C5.31357 0.714932 1.31458 -1.40656 0.171582 1.39543C-0.562917 3.16343 1.26008 4.90442 1.88558 5.80242C2.34808 6.42792 2.94658 7.13542 3.27308 7.84292C3.46358 8.30492 3.51757 8.79492 3.70807 9.28491C4.14357 10.4819 4.55157 11.8149 5.12257 12.9304C5.42207 13.5014 5.74807 14.1004 6.12907 14.6169C6.34657 14.9159 6.72757 15.0524 6.80907 15.5419C6.42807 16.0859 6.40107 16.9024 6.18307 17.5824C5.20357 20.6564 5.58457 24.4654 6.97207 26.7234C7.40707 27.4034 8.44106 28.8999 9.82856 28.3284C11.0526 27.8389 10.7806 26.2879 11.1346 24.9279C11.2166 24.6014 11.1621 24.3839 11.3251 24.1664V24.2209L12.4406 26.4789C13.2841 27.8119 14.7531 29.1994 15.977 30.1244C16.63 30.6139 17.147 31.4574 17.963 31.7569V31.6749H17.9085C17.745 31.4299 17.5005 31.3214 17.283 31.1309C16.7935 30.6414 16.249 30.0429 15.8685 29.4984C14.7261 27.9749 13.7191 26.2884 12.8216 24.5469C12.3866 23.7034 12.0056 22.7784 11.6516 21.9354C11.4881 21.6089 11.4881 21.1194 11.2161 20.9559C10.8081 21.5544 10.2096 22.0714 9.91006 22.8059C9.39356 23.9759 9.33906 25.4174 9.14856 26.9139C9.04006 26.9414 9.09406 26.9139 9.03956 26.9684C8.16907 26.7504 7.86957 25.8529 7.54307 25.0914C6.72707 23.1599 6.59107 20.0584 7.29857 17.8279C7.48907 17.2564 8.30507 15.4609 7.97857 14.9169C7.81557 14.3999 7.27107 14.1009 6.97207 13.6929C6.61857 13.1759 6.23757 12.5229 5.99257 11.9519C5.33957 10.4284 5.01307 8.74192 4.30607 7.21792C3.97957 6.51042 3.40858 5.77592 2.94558 5.12342C2.43008 4.38792 1.85858 3.87093 1.45058 3.00043C1.31458 2.70143 1.12408 2.21143 1.34158 1.88493C1.39608 1.66743 1.50458 1.58593 1.72258 1.53143C2.07608 1.23193 3.08308 1.61293 3.43658 1.77593C4.44307 2.18393 5.28657 2.56493 6.13007 3.13643C6.51107 3.40843 6.91907 3.92543 7.40857 4.06143H7.98007C8.85056 4.25192 9.83006 4.11592 10.6461 4.36042C12.0881 4.82292 13.3936 5.50292 14.5636 6.23742C18.1275 8.49542 21.0655 11.7059 23.0515 15.5414C23.378 16.1669 23.514 16.7384 23.813 17.3914C24.3845 18.7244 25.0915 20.0849 25.663 21.3904C26.234 22.6689 26.7785 23.9749 27.5945 25.0359C28.0025 25.6069 29.635 25.9064 30.3695 26.2059C30.9135 26.4504 31.757 26.6684 32.2465 26.9674C33.1715 27.5384 34.0965 28.1914 34.967 28.8174C35.4025 29.1439 36.7625 29.8239 36.844 30.3679L36.8445 30.3684ZM9.09506 6.72742C8.71888 6.72376 8.34385 6.76948 7.97957 6.86342V6.91792H8.03407C8.25207 7.35292 8.63256 7.65242 8.90456 8.03342L9.53056 9.33941L9.58506 9.28491C9.96606 9.01291 10.1566 8.57742 10.1566 7.92442C9.99306 7.73392 9.96606 7.54342 9.83006 7.35292C9.66706 7.08092 9.31306 6.94492 9.09556 6.72692L9.09506 6.72742Z" fill="#00546B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import { searchTables } from '../../v1/GenericFunctions';
|
||||
|
||||
jest.mock('mysql2/promise');
|
||||
|
||||
describe('MySQL / v1 / Generic Functions', () => {
|
||||
let mockLoadOptionsFunctions: ILoadOptionsFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockLoadOptionsFunctions = {
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
database: 'test_db',
|
||||
}),
|
||||
} as unknown as ILoadOptionsFunctions;
|
||||
});
|
||||
|
||||
describe('searchTables', () => {
|
||||
it('should return matching tables', async () => {
|
||||
const mockRows = [{ table_name: 'users' }, { table_name: 'products' }];
|
||||
|
||||
const mockQuery = jest.fn().mockResolvedValue([mockRows]);
|
||||
const mockEnd = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
(mysql2.createConnection as jest.Mock).mockResolvedValue({
|
||||
query: mockQuery,
|
||||
end: mockEnd,
|
||||
});
|
||||
|
||||
const result: INodeListSearchResult = await searchTables.call(
|
||||
mockLoadOptionsFunctions,
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'users', value: 'users' },
|
||||
{ name: 'products', value: 'products' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?
|
||||
AND table_name LIKE ?
|
||||
ORDER BY table_name`,
|
||||
['test_db', '%user%'],
|
||||
);
|
||||
|
||||
expect(mockEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty search query', async () => {
|
||||
const mockRows: any[] = [];
|
||||
|
||||
const mockQuery = jest.fn().mockResolvedValue([mockRows]);
|
||||
const mockEnd = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
(mysql2.createConnection as jest.Mock).mockResolvedValue({
|
||||
query: mockQuery,
|
||||
end: mockEnd,
|
||||
});
|
||||
|
||||
const result = await searchTables.call(mockLoadOptionsFunctions);
|
||||
|
||||
expect(result).toEqual({ results: [] });
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
`SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?
|
||||
AND table_name LIKE ?
|
||||
ORDER BY table_name`,
|
||||
['test_db', '%%'],
|
||||
);
|
||||
|
||||
expect(mockEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
const mockError = new Error('Database connection failed');
|
||||
|
||||
(mysql2.createConnection as jest.Mock).mockRejectedValue(mockError);
|
||||
|
||||
await expect(searchTables.call(mockLoadOptionsFunctions)).rejects.toThrow(
|
||||
'Database connection failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Connection, QueryResult } from 'mysql2/promise';
|
||||
|
||||
const mockConnection = mock<Connection>();
|
||||
const createConnection = jest.fn().mockReturnValue(mockConnection);
|
||||
jest.mock('mysql2/promise', () => ({ createConnection }));
|
||||
|
||||
describe('Test MySqlV1, executeQuery', () => {
|
||||
mockConnection.query.mockResolvedValue([{ success: true } as unknown as QueryResult, []]);
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
workflowFiles: ['executeQuery.workflow.json'],
|
||||
customAssertions() {
|
||||
expect(mockConnection.query).toHaveBeenCalledTimes(1);
|
||||
expect(mockConnection.query).toHaveBeenCalledWith(
|
||||
"select * from family_parents where (parent_email = 'parent1@mail.com' or parent_email = 'parent2@mail.com') and parent_email <> '';",
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"name": "mysql v1 resolve expression copy",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "d6d9fbcc-d8bc-4f79-8e00-3acf8ffb12de",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
460,
|
||||
460
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "select * from family_parents where (parent_email = {{ \"'\" + $json['Parent 1 email'] + \"'\" }} or parent_email = {{ \"'\" + $json['Parent 2 email'] + \"'\"}}) and parent_email <> '';\n"
|
||||
},
|
||||
"id": "faefc24c-91b4-4b10-85a6-b3cecbceee08",
|
||||
"name": "Get matching families",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
900,
|
||||
460
|
||||
],
|
||||
"credentials": {
|
||||
"mySql": {
|
||||
"id": "93",
|
||||
"name": "MySQL account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "29c30f6e-9f5f-4b3a-80c4-2da762e96bd9",
|
||||
"name": "No Operation, do nothing1",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1120,
|
||||
460
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fields": {
|
||||
"values": [
|
||||
{
|
||||
"name": "Parent 1 email",
|
||||
"stringValue": "parent1@mail.com"
|
||||
},
|
||||
{
|
||||
"name": "Parent 2 email",
|
||||
"stringValue": "parent2@mail.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": "none",
|
||||
"options": {}
|
||||
},
|
||||
"id": "54c7bbf9-dabc-421b-85b2-7c3006f5ee61",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
680,
|
||||
460
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing1": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get matching families": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get matching families",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "aeb01d24-c117-405a-875f-909ea8ccdc16",
|
||||
"id": "GlTwlHZfQwNjbeqv",
|
||||
"meta": {
|
||||
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createServiceStack, type N8NStack } from 'n8n-containers';
|
||||
import { constructExecutionMetaData } from 'n8n-core';
|
||||
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { router } from '../../v2/actions/router';
|
||||
import type { MysqlNodeCredentials } from '../../v2/helpers/interfaces';
|
||||
|
||||
let stack: N8NStack;
|
||||
let credentials: MysqlNodeCredentials;
|
||||
|
||||
beforeAll(async () => {
|
||||
stack = await createServiceStack({ services: ['mysql'] });
|
||||
const meta = stack.serviceResults.mysql!.meta as {
|
||||
externalHost: string;
|
||||
externalPort: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
credentials = {
|
||||
host: meta.externalHost,
|
||||
port: meta.externalPort,
|
||||
database: meta.database,
|
||||
user: meta.username,
|
||||
password: meta.password,
|
||||
connectTimeout: 10000,
|
||||
ssl: false,
|
||||
sshTunnel: false,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stack?.stop();
|
||||
});
|
||||
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'MySQL',
|
||||
typeVersion: 2.5,
|
||||
type: 'n8n-nodes-base.mySql',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
function mockExecuteFunctions(
|
||||
params: IDataObject,
|
||||
creds: MysqlNodeCredentials,
|
||||
continueOnFail = false,
|
||||
): IExecuteFunctions {
|
||||
return {
|
||||
getNodeParameter: (name: string, _i: number, fallback?: unknown) => params[name] ?? fallback,
|
||||
getNode: () => node,
|
||||
getInputData: () => [{ json: {} }],
|
||||
continueOnFail: () => continueOnFail,
|
||||
getCredentials: async () => creds,
|
||||
helpers: {
|
||||
constructExecutionMetaData,
|
||||
getSSHClient: () => {
|
||||
throw new Error('No SSH');
|
||||
},
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
}
|
||||
|
||||
const badCreds: MysqlNodeCredentials = {
|
||||
host: 'invalid.host',
|
||||
port: 3306,
|
||||
database: 'x',
|
||||
user: 'x',
|
||||
password: 'x',
|
||||
connectTimeout: 1000,
|
||||
ssl: false,
|
||||
sshTunnel: false,
|
||||
};
|
||||
|
||||
describe('MySQL Integration - NODE-4174', () => {
|
||||
test('happy path: SELECT against real MySQL', async () => {
|
||||
const params = {
|
||||
resource: 'database',
|
||||
operation: 'executeQuery',
|
||||
query: 'SELECT 1 as result',
|
||||
options: { queryBatching: 'single', nodeVersion: 2.5 },
|
||||
};
|
||||
|
||||
const result = await router.call(mockExecuteFunctions(params, credentials));
|
||||
|
||||
expect(result[0][0].json).toEqual({ result: 1 });
|
||||
});
|
||||
|
||||
test('bad path: query error returns error item with continueOnFail', async () => {
|
||||
const params = {
|
||||
resource: 'database',
|
||||
operation: 'executeQuery',
|
||||
query: 'SELECT * FROM table_that_does_not_exist',
|
||||
options: { queryBatching: 'single', nodeVersion: 2.5 },
|
||||
};
|
||||
|
||||
const result = await router.call(mockExecuteFunctions(params, credentials, true));
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('message');
|
||||
});
|
||||
|
||||
test('connection error should return error item with continueOnFail', async () => {
|
||||
const params = {
|
||||
resource: 'database',
|
||||
operation: 'executeQuery',
|
||||
query: 'SELECT 1',
|
||||
options: { queryBatching: 'single', nodeVersion: 2.5, connectTimeout: 1000 },
|
||||
};
|
||||
|
||||
const result = await router.call(mockExecuteFunctions(params, badCreds, true));
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
test('connection error throws when continueOnFail is false', async () => {
|
||||
const params = {
|
||||
resource: 'database',
|
||||
operation: 'executeQuery',
|
||||
query: 'SELECT 1',
|
||||
options: { queryBatching: 'single', nodeVersion: 2.5, connectTimeout: 1000 },
|
||||
};
|
||||
|
||||
await expect(router.call(mockExecuteFunctions(params, badCreds, false))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,714 @@
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type { IDataObject, INode } from 'n8n-workflow';
|
||||
|
||||
import { createMockExecuteFunction } from '@test/nodes/Helpers';
|
||||
|
||||
import * as deleteTable from '../../v2/actions/database/deleteTable.operation';
|
||||
import * as executeQuery from '../../v2/actions/database/executeQuery.operation';
|
||||
import * as insert from '../../v2/actions/database/insert.operation';
|
||||
import * as select from '../../v2/actions/database/select.operation';
|
||||
import * as update from '../../v2/actions/database/update.operation';
|
||||
import * as upsert from '../../v2/actions/database/upsert.operation';
|
||||
import type { Mysql2Pool, QueryRunner } from '../../v2/helpers/interfaces';
|
||||
import { configureQueryRunner } from '../../v2/helpers/utils';
|
||||
|
||||
const mySqlMockNode: INode = {
|
||||
id: '1',
|
||||
name: 'MySQL node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mySql',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'select',
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConnection = {
|
||||
format(query: string, values: any[]) {
|
||||
return mysql2.format(query, values);
|
||||
},
|
||||
query: jest.fn(async (_query = '') => [{}]),
|
||||
release: jest.fn(),
|
||||
beginTransaction: jest.fn(),
|
||||
commit: jest.fn(),
|
||||
rollback: jest.fn(),
|
||||
};
|
||||
|
||||
const createFakePool = (connection: IDataObject) => {
|
||||
return {
|
||||
getConnection() {
|
||||
return connection;
|
||||
},
|
||||
query: jest.fn(async () => [{}]),
|
||||
} as unknown as Mysql2Pool;
|
||||
};
|
||||
|
||||
const emptyInputItems = [{ json: {}, pairedItem: { item: 0, input: undefined } }];
|
||||
|
||||
describe('Test MySql V2, operations', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have all operations', () => {
|
||||
expect(deleteTable.execute).toBeDefined();
|
||||
expect(deleteTable.description).toBeDefined();
|
||||
expect(executeQuery.execute).toBeDefined();
|
||||
expect(executeQuery.description).toBeDefined();
|
||||
expect(insert.execute).toBeDefined();
|
||||
expect(insert.description).toBeDefined();
|
||||
expect(select.execute).toBeDefined();
|
||||
expect(select.description).toBeDefined();
|
||||
expect(update.execute).toBeDefined();
|
||||
expect(update.description).toBeDefined();
|
||||
expect(upsert.execute).toBeDefined();
|
||||
expect(upsert.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('deleteTable: drop, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'deleteTable',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
deleteCommand: 'drop',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const poolQuerySpy = jest.spyOn(pool, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await deleteTable.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }] }]);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith('DROP TABLE IF EXISTS `test_table`');
|
||||
});
|
||||
|
||||
it('deleteTable: truncate, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'deleteTable',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
deleteCommand: 'truncate',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const poolQuerySpy = jest.spyOn(pool, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await deleteTable.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }] }]);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith('TRUNCATE TABLE `test_table`');
|
||||
});
|
||||
|
||||
it('deleteTable: delete, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'deleteTable',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
deleteCommand: 'delete',
|
||||
where: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
condition: 'equal',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
condition: 'LIKE',
|
||||
value: 'some%',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const poolQuerySpy = jest.spyOn(pool, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await deleteTable.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }] }]);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith(
|
||||
"DELETE FROM `test_table` WHERE `id` = '1' AND `name` LIKE 'some%'",
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteTable: delete, should throw on invalid where clause', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'deleteTable',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
deleteCommand: 'delete',
|
||||
where: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
condition: '=1; select 1,2; -- -',
|
||||
value: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const promise = deleteTable.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
await expect(promise).rejects.toThrow('Invalid where clause');
|
||||
});
|
||||
|
||||
it('executeQuery, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'executeQuery',
|
||||
query:
|
||||
"DROP TABLE IF EXISTS $1:name;\ncreate table $1:name (id INT, name TEXT);\ninsert into $1:name (id, name) values (1, 'test 1');\nselect * from $1:name;\n",
|
||||
options: {
|
||||
queryBatching: 'independently',
|
||||
queryReplacement: 'test_table',
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const fakeConnectionCopy = { ...fakeConnection };
|
||||
|
||||
fakeConnectionCopy.query = jest.fn(async (query?: string) => {
|
||||
const result = [];
|
||||
if (query?.toLowerCase().includes('select')) {
|
||||
result.push([{ id: 1, name: 'test 1' }]);
|
||||
} else {
|
||||
result.push({});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const pool = createFakePool(fakeConnectionCopy);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnectionCopy, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await executeQuery.execute.call(
|
||||
fakeExecuteFunction,
|
||||
emptyInputItems,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 1,
|
||||
name: 'test 1',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(4);
|
||||
expect(connectionQuerySpy).toBeCalledWith('DROP TABLE IF EXISTS `test_table`');
|
||||
expect(connectionQuerySpy).toBeCalledWith('create table `test_table` (id INT, name TEXT)');
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"insert into `test_table` (id, name) values (1, 'test 1')",
|
||||
);
|
||||
expect(connectionQuerySpy).toBeCalledWith('select * from `test_table`');
|
||||
});
|
||||
it('executeQuery, should parse numbers', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'executeQuery',
|
||||
query: 'SELECT * FROM users LIMIT $1, $2',
|
||||
options: {
|
||||
queryBatching: 'independently',
|
||||
queryReplacement: '2, 5',
|
||||
nodeVersion: 2.3,
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const fakeConnectionCopy = { ...fakeConnection };
|
||||
|
||||
fakeConnectionCopy.query = jest.fn(async (query?: string) => {
|
||||
return [{ query }];
|
||||
});
|
||||
const pool = createFakePool(fakeConnectionCopy);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnectionCopy, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await executeQuery.execute.call(
|
||||
fakeExecuteFunction,
|
||||
emptyInputItems,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledWith('SELECT * FROM users LIMIT 2, 5');
|
||||
});
|
||||
|
||||
it('select, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'select',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
limit: 2,
|
||||
where: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
condition: '>',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
condition: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
combineConditions: 'OR',
|
||||
sort: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
direction: 'DESC',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
queryBatching: 'transaction',
|
||||
detailedOutput: false,
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
{ ...nodeOptions, nodeVersion: 2 },
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await select.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
|
||||
|
||||
const connectionBeginTransactionSpy = jest.spyOn(fakeConnection, 'beginTransaction');
|
||||
const connectionCommitSpy = jest.spyOn(fakeConnection, 'commit');
|
||||
|
||||
expect(connectionBeginTransactionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(1);
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"SELECT * FROM `test_table` WHERE `id` > 1 OR `name` = 'test' ORDER BY `id` DESC LIMIT 2",
|
||||
);
|
||||
|
||||
expect(connectionCommitSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('select, should throw on invalid where clause', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'select',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
limit: 2,
|
||||
where: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
condition: '=1; select 1,2; -- -',
|
||||
value: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
combineConditions: 'OR',
|
||||
sort: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
direction: 'DESC',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
queryBatching: 'transaction',
|
||||
detailedOutput: false,
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
{ ...nodeOptions, nodeVersion: 2 },
|
||||
pool,
|
||||
);
|
||||
|
||||
const promise = select.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
await expect(promise).rejects.toThrow('Invalid where clause');
|
||||
});
|
||||
|
||||
it('select, should replace direction with ASC or DESC', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'select',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
limit: 2,
|
||||
where: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
condition: '>',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
condition: '=',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
combineConditions: 'OR',
|
||||
sort: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
direction: 'DESC; Select 1,2; -- -',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
queryBatching: 'transaction',
|
||||
detailedOutput: false,
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
{ ...nodeOptions, nodeVersion: 2 },
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await select.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
|
||||
|
||||
const connectionBeginTransactionSpy = jest.spyOn(fakeConnection, 'beginTransaction');
|
||||
const connectionCommitSpy = jest.spyOn(fakeConnection, 'commit');
|
||||
|
||||
expect(connectionBeginTransactionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(1);
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"SELECT * FROM `test_table` WHERE `id` > 1 OR `name` = 'test' ORDER BY `id` DESC LIMIT 2",
|
||||
);
|
||||
|
||||
expect(connectionCommitSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('insert, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
dataMode: 'defineBelow',
|
||||
valuesToSend: {
|
||||
values: [
|
||||
{
|
||||
column: 'id',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
value: 'name 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
queryBatching: 'independently',
|
||||
priority: 'HIGH_PRIORITY',
|
||||
detailedOutput: false,
|
||||
skipOnConflict: true,
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await insert.execute.call(
|
||||
fakeExecuteFunction,
|
||||
emptyInputItems,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(1);
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"INSERT HIGH_PRIORITY IGNORE INTO `test_table` (`id`, `name`) VALUES ('2','name 2')",
|
||||
);
|
||||
});
|
||||
|
||||
it('update, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'update',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
dataMode: 'autoMapInputData',
|
||||
columnToMatchOn: 'id',
|
||||
options: {
|
||||
queryBatching: 'independently',
|
||||
},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const inputItems = [
|
||||
{
|
||||
json: {
|
||||
id: 42,
|
||||
name: 'test 4',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 88,
|
||||
name: 'test 88',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await update.execute.call(
|
||||
fakeExecuteFunction,
|
||||
inputItems,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
{ json: { success: true }, pairedItem: { item: 0 } },
|
||||
{ json: { success: true }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(2);
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"UPDATE `test_table` SET `name` = 'test 4' WHERE `id` = 42",
|
||||
);
|
||||
expect(connectionQuerySpy).toBeCalledWith(
|
||||
"UPDATE `test_table` SET `name` = 'test 88' WHERE `id` = 88",
|
||||
);
|
||||
});
|
||||
|
||||
it('upsert, should call runQueries with', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'upsert',
|
||||
table: {
|
||||
__rl: true,
|
||||
value: 'test_table',
|
||||
mode: 'list',
|
||||
cachedResultName: 'test_table',
|
||||
},
|
||||
columnToMatchOn: 'id',
|
||||
dataMode: 'autoMapInputData',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const poolQuerySpy = jest.spyOn(pool, 'query');
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const inputItems = [
|
||||
{
|
||||
json: {
|
||||
id: 42,
|
||||
name: 'test 4',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 88,
|
||||
name: 'test 88',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await upsert.execute.call(
|
||||
fakeExecuteFunction,
|
||||
inputItems,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }, { item: 1 }] }]);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith(
|
||||
"INSERT INTO `test_table`(`id`, `name`) VALUES(42,'test 4') ON DUPLICATE KEY UPDATE `name` = 'test 4';INSERT INTO `test_table`(`id`, `name`) VALUES(88,'test 88') ON DUPLICATE KEY UPDATE `name` = 'test 88'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type { IDataObject, INode } from 'n8n-workflow';
|
||||
|
||||
import { createMockExecuteFunction } from '@test/nodes/Helpers';
|
||||
|
||||
import type { Mysql2Pool, QueryRunner } from '../../v2/helpers/interfaces';
|
||||
import { BATCH_MODE } from '../../v2/helpers/interfaces';
|
||||
import { configureQueryRunner } from '../../v2/helpers/utils';
|
||||
|
||||
const mySqlMockNode: INode = {
|
||||
id: '1',
|
||||
name: 'MySQL node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mySql',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'select',
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConnection = {
|
||||
format(query: string, values: any[]) {
|
||||
return mysql2.format(query, values);
|
||||
},
|
||||
query: jest.fn(async () => [{}]),
|
||||
release: jest.fn(),
|
||||
beginTransaction: jest.fn(),
|
||||
commit: jest.fn(),
|
||||
rollback: jest.fn(),
|
||||
};
|
||||
|
||||
const createFakePool = (connection: IDataObject) => {
|
||||
return {
|
||||
getConnection() {
|
||||
return connection;
|
||||
},
|
||||
query: jest.fn(async () => [{}]),
|
||||
} as unknown as Mysql2Pool;
|
||||
};
|
||||
|
||||
describe('Test MySql V2, runQueries', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('in single query batch mode', () => {
|
||||
it('should set paired items correctly', async () => {
|
||||
const nodeOptions = { queryBatching: BATCH_MODE.SINGLE, nodeVersion: 2 };
|
||||
const pool = createFakePool(fakeConnection);
|
||||
const mockExecuteFns = createMockExecuteFunction({}, mySqlMockNode);
|
||||
|
||||
// @ts-expect-error
|
||||
pool.query = jest.fn(async () => [
|
||||
[[{ finishedAt: '2023-12-30' }], [{ finishedAt: '2023-12-31' }]],
|
||||
]);
|
||||
|
||||
const result = await configureQueryRunner.call(
|
||||
mockExecuteFns,
|
||||
nodeOptions,
|
||||
pool,
|
||||
)([
|
||||
{ query: 'SELECT finishedAt FROM my_table WHERE id = ?', values: [123] },
|
||||
{ query: 'SELECT finishedAt FROM my_table WHERE id = ?', values: [456] },
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { finishedAt: '2023-12-30' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
{
|
||||
json: { finishedAt: '2023-12-31' },
|
||||
pairedItem: { item: 1 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute in "Single" mode, should return success true', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.SINGLE, nodeVersion: 2 };
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
const fakeExecuteFunction = createMockExecuteFunction({}, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const poolGetConnectionSpy = jest.spyOn(pool, 'getConnection');
|
||||
const poolQuerySpy = jest.spyOn(pool, 'query');
|
||||
const connectionReleaseSpy = jest.spyOn(fakeConnection, 'release');
|
||||
const connectionFormatSpy = jest.spyOn(fakeConnection, 'format');
|
||||
|
||||
const result = await runQueries([
|
||||
{ query: 'SELECT * FROM my_table WHERE id = ?', values: [55] },
|
||||
]);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }] }]);
|
||||
|
||||
expect(poolGetConnectionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionReleaseSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith('SELECT * FROM my_table WHERE id = 55');
|
||||
|
||||
expect(connectionFormatSpy).toBeCalledTimes(1);
|
||||
expect(connectionFormatSpy).toBeCalledWith('SELECT * FROM my_table WHERE id = ?', [55]);
|
||||
});
|
||||
|
||||
it('should execute in "independently" mode, should return success true', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.INDEPENDENTLY, nodeVersion: 2 };
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction({}, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const poolGetConnectionSpy = jest.spyOn(pool, 'getConnection');
|
||||
|
||||
const connectionReleaseSpy = jest.spyOn(fakeConnection, 'release');
|
||||
const connectionFormatSpy = jest.spyOn(fakeConnection, 'format');
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
|
||||
const result = await runQueries([
|
||||
{
|
||||
query: 'SELECT * FROM my_table WHERE id = ?; SELECT * FROM my_table WHERE id = ?',
|
||||
values: [55, 42],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
|
||||
|
||||
expect(poolGetConnectionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(2);
|
||||
expect(connectionQuerySpy).toBeCalledWith('SELECT * FROM my_table WHERE id = 55');
|
||||
expect(connectionQuerySpy).toBeCalledWith('SELECT * FROM my_table WHERE id = 42');
|
||||
|
||||
expect(connectionFormatSpy).toBeCalledTimes(1);
|
||||
expect(connectionFormatSpy).toBeCalledWith(
|
||||
'SELECT * FROM my_table WHERE id = ?; SELECT * FROM my_table WHERE id = ?',
|
||||
[55, 42],
|
||||
);
|
||||
|
||||
expect(connectionReleaseSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should execute in "transaction" mode, should return success true', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.TRANSACTION, nodeVersion: 2 };
|
||||
|
||||
const pool = createFakePool(fakeConnection);
|
||||
|
||||
const fakeExecuteFunction = createMockExecuteFunction({}, mySqlMockNode);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
);
|
||||
|
||||
const poolGetConnectionSpy = jest.spyOn(pool, 'getConnection');
|
||||
|
||||
const connectionReleaseSpy = jest.spyOn(fakeConnection, 'release');
|
||||
const connectionFormatSpy = jest.spyOn(fakeConnection, 'format');
|
||||
const connectionQuerySpy = jest.spyOn(fakeConnection, 'query');
|
||||
const connectionBeginTransactionSpy = jest.spyOn(fakeConnection, 'beginTransaction');
|
||||
const connectionCommitSpy = jest.spyOn(fakeConnection, 'commit');
|
||||
|
||||
const result = await runQueries([
|
||||
{
|
||||
query: 'SELECT * FROM my_table WHERE id = ?; SELECT * FROM my_table WHERE id = ?',
|
||||
values: [55, 42],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
|
||||
|
||||
expect(poolGetConnectionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionBeginTransactionSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionQuerySpy).toBeCalledTimes(2);
|
||||
expect(connectionQuerySpy).toBeCalledWith('SELECT * FROM my_table WHERE id = 55');
|
||||
expect(connectionQuerySpy).toBeCalledWith('SELECT * FROM my_table WHERE id = 42');
|
||||
|
||||
expect(connectionFormatSpy).toBeCalledTimes(1);
|
||||
expect(connectionFormatSpy).toBeCalledWith(
|
||||
'SELECT * FROM my_table WHERE id = ?; SELECT * FROM my_table WHERE id = ?',
|
||||
[55, 42],
|
||||
);
|
||||
|
||||
expect(connectionCommitSpy).toBeCalledTimes(1);
|
||||
|
||||
expect(connectionReleaseSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return error item with continueOnFail = true for connection error', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.SINGLE, nodeVersion: 2 };
|
||||
const pool = createFakePool(fakeConnection);
|
||||
pool.getConnection = jest.fn(() => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const fakeExecuteFunction = createMockExecuteFunction({}, mySqlMockNode);
|
||||
fakeExecuteFunction.continueOnFail = () => true;
|
||||
|
||||
const result = await configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
)([{ query: 'SELECT * FROM my_table WHERE id = ?', values: [55] }]);
|
||||
|
||||
expect(result).toEqual([{ json: expect.objectContaining({ message: 'Connection refused' }) }]);
|
||||
});
|
||||
|
||||
it('should throw error when continueOnFail = false for connection error', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.SINGLE, nodeVersion: 2 };
|
||||
const pool = createFakePool(fakeConnection);
|
||||
pool.getConnection = jest.fn(() => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const fakeExecuteFunction = createMockExecuteFunction({}, mySqlMockNode);
|
||||
fakeExecuteFunction.continueOnFail = () => false;
|
||||
|
||||
await expect(
|
||||
configureQueryRunner.call(
|
||||
fakeExecuteFunction,
|
||||
nodeOptions,
|
||||
pool,
|
||||
)([{ query: 'SELECT * FROM my_table WHERE id = ?', values: [55] }]),
|
||||
).rejects.toThrow('Connection refused');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import type { SortRule, WhereClause } from '../../v2/helpers/interfaces';
|
||||
import * as utils from '../../v2/helpers/utils';
|
||||
import {
|
||||
prepareQueryAndReplacements,
|
||||
wrapData,
|
||||
addWhereClauses,
|
||||
addSortRules,
|
||||
replaceEmptyStringsByNulls,
|
||||
escapeSqlIdentifier,
|
||||
splitQueryToStatements,
|
||||
} from '../../v2/helpers/utils';
|
||||
|
||||
const mySqlMockNode: INode = {
|
||||
id: '1',
|
||||
name: 'MySQL node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mySql',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'select',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Test MySql V2, prepareQueryAndReplacements', () => {
|
||||
it('should transform query and values', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
'SELECT * FROM $1:name WHERE id = $2 AND name = $4 AND $3:name = 28',
|
||||
2.5,
|
||||
['table', 15, 'age', 'Name'],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
'SELECT * FROM `table` WHERE id = ? AND name = ? AND `age` = 28',
|
||||
);
|
||||
expect(preparedQuery.values.length).toEqual(2);
|
||||
expect(preparedQuery.values[0]).toEqual(15);
|
||||
expect(preparedQuery.values[1]).toEqual('Name');
|
||||
});
|
||||
|
||||
it('should not replace dollar amounts inside quoted strings', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
"INSERT INTO test_table(content) VALUES('This is for testing $60')",
|
||||
2.5,
|
||||
[],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
"INSERT INTO test_table(content) VALUES('This is for testing $60')",
|
||||
);
|
||||
expect(preparedQuery.values.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should handle mixed parameters and dollar amounts in quotes', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
"INSERT INTO $1:name(content, price) VALUES('Product costs $60', $2)",
|
||||
2.5,
|
||||
['products', 59.99],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
"INSERT INTO `products`(content, price) VALUES('Product costs $60', ?)",
|
||||
);
|
||||
expect(preparedQuery.values.length).toEqual(1);
|
||||
expect(preparedQuery.values[0]).toEqual(59.99);
|
||||
});
|
||||
|
||||
it('should handle parameters in double quotes', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
'INSERT INTO test_table(content) VALUES("Price is $100 and $200")',
|
||||
2.5,
|
||||
[],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
'INSERT INTO test_table(content) VALUES("Price is $100 and $200")',
|
||||
);
|
||||
expect(preparedQuery.values.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should process parameters in correct order despite reverse processing', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
'SELECT * FROM table WHERE col1 = $1 AND col2 = $2 AND col3 = $3 AND col4 = $4 AND col5 = $5',
|
||||
2.5,
|
||||
['value1', 'value2', 'value3', 'value4', 'value5'],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
'SELECT * FROM table WHERE col1 = ? AND col2 = ? AND col3 = ? AND col4 = ? AND col5 = ?',
|
||||
);
|
||||
expect(preparedQuery.values.length).toEqual(5);
|
||||
expect(preparedQuery.values[0]).toEqual('value1');
|
||||
expect(preparedQuery.values[1]).toEqual('value2');
|
||||
expect(preparedQuery.values[2]).toEqual('value3');
|
||||
expect(preparedQuery.values[3]).toEqual('value4');
|
||||
expect(preparedQuery.values[4]).toEqual('value5');
|
||||
});
|
||||
|
||||
it('should handle escaped single quotes correctly', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
"INSERT INTO test_table(content) VALUES('Don''t replace $1 here')",
|
||||
2.5,
|
||||
['should_not_appear', 123],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
"INSERT INTO test_table(content) VALUES('Don''t replace $1 here')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle escaped double quotes correctly', () => {
|
||||
const preparedQuery = prepareQueryAndReplacements(
|
||||
"INSERT INTO test_table(content) VALUES('Don\"'t replace $1 here')",
|
||||
2.5,
|
||||
['should_not_appear', 123],
|
||||
);
|
||||
expect(preparedQuery).toBeDefined();
|
||||
expect(preparedQuery.query).toEqual(
|
||||
"INSERT INTO test_table(content) VALUES('Don\"'t replace $1 here')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should use legacy processing for versions < 2.5', () => {
|
||||
const legacySpy = jest.spyOn(utils, 'prepareQueryLegacy');
|
||||
|
||||
prepareQueryAndReplacements('SELECT * FROM $1:name WHERE id = $2', 2.4, ['users', 123]);
|
||||
|
||||
expect(legacySpy).toHaveBeenCalledWith('SELECT * FROM $1:name WHERE id = $2', ['users', 123]);
|
||||
|
||||
legacySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use new processing for versions >= 2.5', () => {
|
||||
const legacySpy = jest.spyOn(utils, 'prepareQueryLegacy');
|
||||
|
||||
prepareQueryAndReplacements('SELECT * FROM $1:name WHERE id = $2', 2.5, ['users', 123]);
|
||||
|
||||
expect(legacySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when parameter is referenced but no replacement value provided', () => {
|
||||
expect(() => {
|
||||
prepareQueryAndReplacements(
|
||||
'SELECT * FROM users WHERE id = $4',
|
||||
2.5,
|
||||
['value1', 'value2'], // Only 2 values but query references $4
|
||||
);
|
||||
}).toThrow('Parameter $4 referenced in query but no replacement value provided at index 4');
|
||||
});
|
||||
|
||||
it('should throw error when multiple parameters are missing replacement values', () => {
|
||||
expect(() => {
|
||||
prepareQueryAndReplacements(
|
||||
'SELECT * FROM users WHERE id = $3 AND name = $5',
|
||||
2.5,
|
||||
['value1'], // Only 1 value but query references $3 and $5
|
||||
);
|
||||
}).toThrow('Parameter $3 referenced in query but no replacement value provided at index 3');
|
||||
});
|
||||
|
||||
it('should not throw error when all referenced parameters have replacement values', () => {
|
||||
expect(() => {
|
||||
prepareQueryAndReplacements(
|
||||
'SELECT * FROM users WHERE id = $1 AND name = $2',
|
||||
2.5,
|
||||
['123', 'John'], // Correct number of values
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, wrapData', () => {
|
||||
it('should wrap object in json', () => {
|
||||
const data = {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
});
|
||||
it('should wrap each object in array in json', () => {
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Name 2',
|
||||
},
|
||||
];
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data[0] }, { json: data[1] }]);
|
||||
});
|
||||
it('json key from source should be inside json', () => {
|
||||
const data = {
|
||||
json: {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
expect(Object.keys(wrappedData[0].json)).toContain('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, addWhereClauses', () => {
|
||||
it('add where clauses to query', () => {
|
||||
const whereClauses: WhereClause[] = [
|
||||
{ column: 'species', condition: 'equal', value: 'dog' },
|
||||
{ column: 'name', condition: 'equal', value: 'Hunter' },
|
||||
];
|
||||
const [query, values] = addWhereClauses(
|
||||
mySqlMockNode,
|
||||
0,
|
||||
'SELECT * FROM `pet`',
|
||||
whereClauses,
|
||||
[],
|
||||
);
|
||||
expect(query).toEqual('SELECT * FROM `pet` WHERE `species` = ? AND `name` = ?');
|
||||
expect(values.length).toEqual(2);
|
||||
expect(values[0]).toEqual('dog');
|
||||
expect(values[1]).toEqual('Hunter');
|
||||
});
|
||||
it('add where clauses to query combined by OR', () => {
|
||||
const whereClauses: WhereClause[] = [
|
||||
{ column: 'species', condition: 'equal', value: 'dog' },
|
||||
{ column: 'name', condition: 'equal', value: 'Hunter' },
|
||||
];
|
||||
const [query, values] = addWhereClauses(
|
||||
mySqlMockNode,
|
||||
0,
|
||||
'SELECT * FROM `pet`',
|
||||
whereClauses,
|
||||
[],
|
||||
'OR',
|
||||
);
|
||||
expect(query).toEqual('SELECT * FROM `pet` WHERE `species` = ? OR `name` = ?');
|
||||
expect(values.length).toEqual(2);
|
||||
expect(values[0]).toEqual('dog');
|
||||
expect(values[1]).toEqual('Hunter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, addSortRules', () => {
|
||||
it('should add ORDER by', () => {
|
||||
const sortRules: SortRule[] = [
|
||||
{ column: 'name', direction: 'ASC' },
|
||||
{ column: 'age', direction: 'DESC' },
|
||||
];
|
||||
const [query, values] = addSortRules('SELECT * FROM `pet`', sortRules, []);
|
||||
|
||||
expect(query).toEqual('SELECT * FROM `pet` ORDER BY `name` ASC, `age` DESC');
|
||||
expect(values.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, replaceEmptyStringsByNulls', () => {
|
||||
it('should replace empty strings', () => {
|
||||
const data = [
|
||||
{ json: { id: 1, name: '' } },
|
||||
{ json: { id: '', name: '' } },
|
||||
{ json: { id: null, data: '' } },
|
||||
];
|
||||
const replacedData = replaceEmptyStringsByNulls(data, true);
|
||||
expect(replacedData).toBeDefined();
|
||||
expect(replacedData).toEqual([
|
||||
{ json: { id: 1, name: null } },
|
||||
{ json: { id: null, name: null } },
|
||||
{ json: { id: null, data: null } },
|
||||
]);
|
||||
});
|
||||
it('should not replace empty strings', () => {
|
||||
const data = [{ json: { id: 1, name: '' } }];
|
||||
const replacedData = replaceEmptyStringsByNulls(data);
|
||||
expect(replacedData).toBeDefined();
|
||||
expect(replacedData).toEqual([{ json: { id: 1, name: '' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, escapeSqlIdentifier', () => {
|
||||
it('should escape fully qualified identifier', () => {
|
||||
const input = 'db_name.tbl_name.col_name';
|
||||
const escapedIdentifier = escapeSqlIdentifier(input);
|
||||
expect(escapedIdentifier).toEqual('`db_name`.`tbl_name`.`col_name`');
|
||||
});
|
||||
|
||||
it('should escape table name only', () => {
|
||||
const input = 'tbl_name';
|
||||
const escapedIdentifier = escapeSqlIdentifier(input);
|
||||
expect(escapedIdentifier).toEqual('`tbl_name`');
|
||||
});
|
||||
|
||||
it('should escape fully qualified identifier with backticks', () => {
|
||||
const input = '`db_name`.`tbl_name`.`col_name`';
|
||||
const escapedIdentifier = escapeSqlIdentifier(input);
|
||||
expect(escapedIdentifier).toEqual('`db_name`.`tbl_name`.`col_name`');
|
||||
});
|
||||
|
||||
it('should escape identifier with dots', () => {
|
||||
const input = '`db_name`.`some.dotted.tbl_name`';
|
||||
const escapedIdentifier = escapeSqlIdentifier(input);
|
||||
expect(escapedIdentifier).toEqual('`db_name`.`some.dotted.tbl_name`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test MySql V2, splitQueryToStatements', () => {
|
||||
it('should split query into statements', () => {
|
||||
const query =
|
||||
"insert into models (`created_at`, custom_ship_time, id) values ('2023-09-07 10:26:20', 'some random; data with a semicolon', 1); insert into models (`created_at`, custom_ship_time, id) values ('2023-09-07 10:27:55', 'random data without semicolon\n', 2);";
|
||||
|
||||
const statements = splitQueryToStatements(query);
|
||||
|
||||
expect(statements).toBeDefined();
|
||||
expect(statements).toEqual([
|
||||
"insert into models (`created_at`, custom_ship_time, id) values ('2023-09-07 10:26:20', 'some random; data with a semicolon', 1)",
|
||||
"insert into models (`created_at`, custom_ship_time, id) values ('2023-09-07 10:27:55', 'random data without semicolon', 2)",
|
||||
]);
|
||||
});
|
||||
it('should not split by ; inside string literal', () => {
|
||||
const query =
|
||||
"SELECT custom_ship_time FROM models WHERE models.custom_ship_time LIKE CONCAT('%', ';', '%') LIMIT 10";
|
||||
|
||||
const statements = splitQueryToStatements(query);
|
||||
|
||||
expect(statements).toBeDefined();
|
||||
expect(statements).toEqual([
|
||||
"SELECT custom_ship_time FROM models WHERE models.custom_ship_time LIKE CONCAT('%', ';', '%') LIMIT 10",
|
||||
]);
|
||||
});
|
||||
|
||||
describe('where clause handling', () => {
|
||||
const validOperations = [
|
||||
'equal',
|
||||
'=',
|
||||
'!=',
|
||||
'LIKE',
|
||||
'>',
|
||||
'<',
|
||||
'>=',
|
||||
'<=',
|
||||
'IS NULL',
|
||||
'IS NOT NULL',
|
||||
];
|
||||
const invalidOperations = ['=1 or 1--', '=>', ''];
|
||||
|
||||
test.each(validOperations)('isWhereClause returns true for "%s" operation', (operation) => {
|
||||
expect(
|
||||
utils.isWhereClause({
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test.each(invalidOperations)('isWhereClause returns false for "%s" operation', (operation) => {
|
||||
expect(
|
||||
utils.isWhereClause({
|
||||
column: 'name',
|
||||
condition: operation,
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isWhereClause returns false for when column is missing', () => {
|
||||
expect(
|
||||
utils.isWhereClause({
|
||||
condition: 'equal',
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isWhereClause returns false for when condition is missing', () => {
|
||||
expect(
|
||||
utils.isWhereClause({
|
||||
column: 'id',
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each(invalidOperations)(
|
||||
'getWhereClauses throws an exception for "%s" operation',
|
||||
(operation) => {
|
||||
const getNodeParameterMock = jest.fn().mockReturnValue({
|
||||
values: [
|
||||
{
|
||||
column: 'test',
|
||||
condition: '=',
|
||||
value: '3',
|
||||
},
|
||||
{
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = mock<IExecuteFunctions>({ getNodeParameter: getNodeParameterMock });
|
||||
expect(() => utils.getWhereClauses(ctx, 0)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(validOperations)(
|
||||
'getWhereClauses returns valid clauses for "%s" operation',
|
||||
(operation) => {
|
||||
const clauses = [
|
||||
{
|
||||
column: 'name',
|
||||
condition: 'LIKE',
|
||||
value: 'Wohn Jick',
|
||||
},
|
||||
{
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'condition',
|
||||
condition: 'equal',
|
||||
value: 'angry',
|
||||
},
|
||||
];
|
||||
const getNodeParameterMock = jest.fn().mockReturnValue({
|
||||
values: clauses,
|
||||
});
|
||||
const ctx = mock<IExecuteFunctions>({ getNodeParameter: getNodeParameterMock });
|
||||
expect(utils.getWhereClauses(ctx, 0)).toBe(clauses);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export async function createConnection(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
): Promise<mysql2.Connection> {
|
||||
const { ssl, caCertificate, clientCertificate, clientPrivateKey, ...baseCredentials } =
|
||||
credentials;
|
||||
|
||||
if (ssl) {
|
||||
baseCredentials.ssl = {};
|
||||
|
||||
if (caCertificate) {
|
||||
baseCredentials.ssl.ca = caCertificate;
|
||||
}
|
||||
|
||||
if (clientCertificate || clientPrivateKey) {
|
||||
baseCredentials.ssl.cert = clientCertificate;
|
||||
baseCredentials.ssl.key = clientPrivateKey;
|
||||
}
|
||||
}
|
||||
|
||||
return await mysql2.createConnection(baseCredentials);
|
||||
}
|
||||
|
||||
export async function searchTables(
|
||||
this: ILoadOptionsFunctions,
|
||||
tableName?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials('mySql');
|
||||
const connection = await createConnection(credentials);
|
||||
const sql = `SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?
|
||||
AND table_name LIKE ?
|
||||
ORDER BY table_name`;
|
||||
|
||||
const values = [credentials.database, `%${tableName ?? ''}%`];
|
||||
const [rows] = await connection.query(sql, values);
|
||||
const results = (rows as IDataObject[]).map((table) => ({
|
||||
name: (table.table_name as string) || (table.TABLE_NAME as string),
|
||||
value: (table.table_name as string) || (table.TABLE_NAME as string),
|
||||
}));
|
||||
await connection.end();
|
||||
return { results };
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import type mysql2 from 'mysql2/promise';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { oldVersionNotice } from '@utils/descriptions';
|
||||
import { getResolvables } from '@utils/utilities';
|
||||
|
||||
import { createConnection, searchTables } from './GenericFunctions';
|
||||
|
||||
const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'MySQL',
|
||||
name: 'mySql',
|
||||
icon: 'file:mysql.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Get, add and update data in MySQL',
|
||||
defaults: {
|
||||
name: 'MySQL',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mySql',
|
||||
required: true,
|
||||
testedBy: 'mysqlConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
oldVersionNotice,
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Execute Query',
|
||||
value: 'executeQuery',
|
||||
description: 'Execute an SQL query',
|
||||
action: 'Execute a SQL query',
|
||||
},
|
||||
{
|
||||
name: 'Insert',
|
||||
value: 'insert',
|
||||
description: 'Insert rows in database',
|
||||
action: 'Insert rows in database',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update rows in database',
|
||||
action: 'Update rows in database',
|
||||
},
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
sqlDialect: 'MySQL',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'SELECT id, name FROM product WHERE id < 40',
|
||||
required: true,
|
||||
description: 'The SQL query to execute',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a Table...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchTables',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'table_name',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
description: 'Name of the table in which to insert data to',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
requiresDataPath: 'multiple',
|
||||
default: '',
|
||||
placeholder: 'id,name,description',
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for the new rows',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add modifiers',
|
||||
description: 'Modifiers for INSERT statement',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Ignore',
|
||||
name: 'ignore',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to ignore any ignorable errors that occur while executing the INSERT statement',
|
||||
},
|
||||
{
|
||||
displayName: 'Priority',
|
||||
name: 'priority',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Low Prioirity',
|
||||
value: 'LOW_PRIORITY',
|
||||
description:
|
||||
'Delays execution of the INSERT until no other clients are reading from the table',
|
||||
},
|
||||
{
|
||||
name: 'High Priority',
|
||||
value: 'HIGH_PRIORITY',
|
||||
description:
|
||||
'Overrides the effect of the --low-priority-updates option if the server was started with that option. It also causes concurrent inserts not to be used.',
|
||||
},
|
||||
],
|
||||
default: 'LOW_PRIORITY',
|
||||
description:
|
||||
'Ignore any ignorable errors that occur while executing the INSERT statement',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a Table...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchTables',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'table_name',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
description: 'Name of the table in which to update data in',
|
||||
},
|
||||
{
|
||||
displayName: 'Update Key',
|
||||
name: 'updateKey',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: 'id',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Name of the property which decides which rows in the database should be updated. Normally that would be "id".',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'name,description',
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for rows to update',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export class MySqlV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
async mysqlConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as ICredentialDataDecryptedObject;
|
||||
try {
|
||||
const connection = await createConnection(credentials);
|
||||
await connection.end();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
},
|
||||
},
|
||||
listSearch: {
|
||||
searchTables,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials('mySql');
|
||||
const connection = await createConnection(credentials);
|
||||
const items = this.getInputData();
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
let returnItems: INodeExecutionData[] = [];
|
||||
|
||||
if (operation === 'executeQuery') {
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
|
||||
try {
|
||||
const queryQueue = items.map(async (_, index) => {
|
||||
let rawQuery = (this.getNodeParameter('query', index) as string).trim();
|
||||
|
||||
for (const resolvable of getResolvables(rawQuery)) {
|
||||
rawQuery = rawQuery.replace(
|
||||
resolvable,
|
||||
this.evaluateExpression(resolvable, index) as string,
|
||||
);
|
||||
}
|
||||
|
||||
return await connection.query(rawQuery);
|
||||
});
|
||||
|
||||
returnItems = ((await Promise.all(queryQueue)) as mysql2.OkPacket[][]).reduce(
|
||||
(collection, result, index) => {
|
||||
const [rows] = result;
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(rows as unknown as IDataObject[]),
|
||||
{ itemData: { item: index } },
|
||||
);
|
||||
|
||||
collection = collection.concat(executionData);
|
||||
|
||||
return collection;
|
||||
},
|
||||
[] as INodeExecutionData[],
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnItems = this.helpers.returnJsonArray({ error: error.message });
|
||||
} else {
|
||||
await connection.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'insert') {
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
|
||||
try {
|
||||
const table = this.getNodeParameter('table', 0, '', { extractValue: true }) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
const insertItems = this.helpers.copyInputItems(items, columns);
|
||||
const insertPlaceholder = `(${columns.map((_column) => '?').join(',')})`;
|
||||
const options = this.getNodeParameter('options', 0);
|
||||
const insertIgnore = options.ignore as boolean;
|
||||
const insertPriority = options.priority as string;
|
||||
|
||||
const insertSQL = `INSERT ${insertPriority || ''} ${
|
||||
insertIgnore ? 'IGNORE' : ''
|
||||
} INTO ${table}(${columnString}) VALUES ${items
|
||||
.map((_item) => insertPlaceholder)
|
||||
.join(',')};`;
|
||||
const queryItems = insertItems.reduce(
|
||||
(collection: IDataObject[], item) =>
|
||||
collection.concat(Object.values(item) as IDataObject[]),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryResult = await connection.query(insertSQL, queryItems);
|
||||
|
||||
returnItems = this.helpers.returnJsonArray(queryResult[0] as unknown as IDataObject);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnItems = this.helpers.returnJsonArray({ error: error.message });
|
||||
} else {
|
||||
await connection.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'update') {
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
|
||||
try {
|
||||
const table = this.getNodeParameter('table', 0, '', { extractValue: true }) as string;
|
||||
const updateKey = this.getNodeParameter('updateKey', 0) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
|
||||
if (!columns.includes(updateKey)) {
|
||||
columns.unshift(updateKey);
|
||||
}
|
||||
|
||||
const updateItems = this.helpers.copyInputItems(items, columns);
|
||||
const updateSQL = `UPDATE ${table} SET ${columns
|
||||
.map((column) => `${column} = ?`)
|
||||
.join(',')} WHERE ${updateKey} = ?;`;
|
||||
const queryQueue = updateItems.map(
|
||||
async (item) =>
|
||||
await connection.query(updateSQL, Object.values(item).concat(item[updateKey])),
|
||||
);
|
||||
const queryResult = await Promise.all(queryQueue);
|
||||
returnItems = this.helpers.returnJsonArray(
|
||||
queryResult.map((result) => result[0]) as unknown as IDataObject[],
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnItems = this.helpers.returnJsonArray({ error: error.message });
|
||||
} else {
|
||||
await connection.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.continueOnFail()) {
|
||||
returnItems = this.helpers.returnJsonArray({
|
||||
error: `The operation "${operation}" is not supported!`,
|
||||
});
|
||||
} else {
|
||||
await connection.end();
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
|
||||
return [returnItems];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
import { listSearch, credentialTest, loadOptions } from './methods';
|
||||
|
||||
export class MySqlV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = { listSearch, loadOptions, credentialTest };
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { INodeProperties, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
import { BATCH_MODE, SINGLE } from '../helpers/interfaces';
|
||||
|
||||
export const operatorOptions: INodePropertyOptions[] = [
|
||||
{
|
||||
name: 'Equal',
|
||||
value: 'equal',
|
||||
},
|
||||
{
|
||||
name: 'Not Equal',
|
||||
value: '!=',
|
||||
},
|
||||
{
|
||||
name: 'Like',
|
||||
value: 'LIKE',
|
||||
},
|
||||
{
|
||||
name: 'Greater Than',
|
||||
value: '>',
|
||||
},
|
||||
{
|
||||
name: 'Less Than',
|
||||
value: '<',
|
||||
},
|
||||
{
|
||||
name: 'Greater Than Or Equal',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
name: 'Less Than Or Equal',
|
||||
value: '<=',
|
||||
},
|
||||
{
|
||||
name: 'Is Null',
|
||||
value: 'IS NULL',
|
||||
},
|
||||
{
|
||||
name: 'Is Not Null',
|
||||
value: 'IS NOT NULL',
|
||||
},
|
||||
];
|
||||
|
||||
export const tableRLC: INodeProperties = {
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'The table you want to work on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a Table...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchTables',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'table_name',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const optionsCollection: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Connection Timeout',
|
||||
name: 'connectionTimeoutMillis',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
description: 'Number of milliseconds reserved for connecting to the database',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Connections Limit',
|
||||
name: 'connectionLimit',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
description:
|
||||
'Maximum amount of connections to the database, setting high value can lead to performance issues and potential database crashes',
|
||||
},
|
||||
{
|
||||
displayName: 'Query Batching',
|
||||
name: 'queryBatching',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
description: 'The way queries should be sent to the database',
|
||||
options: [
|
||||
{
|
||||
name: 'Single Query',
|
||||
value: BATCH_MODE.SINGLE,
|
||||
description: 'A single query for all incoming items',
|
||||
},
|
||||
{
|
||||
name: 'Independent',
|
||||
value: BATCH_MODE.INDEPENDENTLY,
|
||||
description: 'Execute one query per incoming item of the run',
|
||||
},
|
||||
{
|
||||
name: 'Transaction',
|
||||
value: BATCH_MODE.TRANSACTION,
|
||||
description:
|
||||
'Execute all queries in a transaction, if a failure occurs, all changes are rolled back',
|
||||
},
|
||||
],
|
||||
default: SINGLE,
|
||||
},
|
||||
{
|
||||
displayName: 'Query Parameters',
|
||||
name: 'queryReplacement',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. value1,value2,value3',
|
||||
description:
|
||||
'Comma-separated list of the values you want to use as query parameters. You can drag the values from the input panel on the left. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.mysql/" target="_blank">More info</a>',
|
||||
hint: 'Comma-separated list of values: reference them in your query as $1, $2, $3…',
|
||||
displayOptions: {
|
||||
show: { '/operation': ['executeQuery'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Output Columns',
|
||||
name: 'outputColumns',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsMultiOptions',
|
||||
loadOptionsDependsOn: ['table.value'],
|
||||
},
|
||||
default: [],
|
||||
displayOptions: {
|
||||
show: { '/operation': ['select'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Large-Format Numbers As',
|
||||
name: 'largeNumbersOutput',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Numbers',
|
||||
value: 'numbers',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description:
|
||||
'Use this if you expect numbers longer than 16 digits (otherwise numbers may be incorrect)',
|
||||
},
|
||||
],
|
||||
hint: 'Applies to NUMERIC and BIGINT columns only',
|
||||
default: 'text',
|
||||
displayOptions: {
|
||||
show: { '/operation': ['select', 'executeQuery'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Decimals as Numbers',
|
||||
name: 'decimalNumbers',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to output DECIMAL types as numbers instead of strings',
|
||||
displayOptions: {
|
||||
show: { '/operation': ['select', 'executeQuery'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Priority',
|
||||
name: 'priority',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Low Prioirity',
|
||||
value: 'LOW_PRIORITY',
|
||||
description:
|
||||
'Delays execution of the INSERT until no other clients are reading from the table',
|
||||
},
|
||||
{
|
||||
name: 'High Priority',
|
||||
value: 'HIGH_PRIORITY',
|
||||
description:
|
||||
'Overrides the effect of the --low-priority-updates option if the server was started with that option. It also causes concurrent inserts not to be used.',
|
||||
},
|
||||
],
|
||||
default: 'LOW_PRIORITY',
|
||||
description: 'Ignore any ignorable errors that occur while executing the INSERT statement',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['insert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Replace Empty Strings with NULL',
|
||||
name: 'replaceEmptyStrings',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to replace empty strings with NULL in input, could be useful when data come from spreadsheet',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['insert', 'update', 'upsert', 'executeQuery'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Select Distinct',
|
||||
name: 'selectDistinct',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to remove these duplicate rows',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['select'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Query Execution Details',
|
||||
name: 'detailedOutput',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to show in output details of the ofexecuted query for each statement, or just confirmation of success',
|
||||
},
|
||||
{
|
||||
displayName: 'Skip on Conflict',
|
||||
name: 'skipOnConflict',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to skip the row and do not throw error if a unique constraint or exclusion constraint is violated',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['insert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const selectRowsFixedCollection: INodeProperties = {
|
||||
displayName: 'Select Rows',
|
||||
name: 'where',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Condition',
|
||||
default: {},
|
||||
description: 'If not set, all rows will be selected',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
default: '',
|
||||
placeholder: 'e.g. ID',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Operator',
|
||||
name: 'condition',
|
||||
type: 'options',
|
||||
description:
|
||||
"The operator to check the column against. When using 'LIKE' operator percent sign ( %) matches zero or more characters, underscore ( _ ) matches any single character.",
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: operatorOptions,
|
||||
default: 'equal',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
condition: ['IS NULL', 'IS NOT NULL'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sortFixedCollection: INodeProperties = {
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Sort Rule',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Direction',
|
||||
name: 'direction',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'ASC',
|
||||
value: 'ASC',
|
||||
},
|
||||
{
|
||||
name: 'DESC',
|
||||
value: 'DESC',
|
||||
},
|
||||
],
|
||||
default: 'ASC',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combineConditionsCollection: INodeProperties = {
|
||||
displayName: 'Combine Conditions',
|
||||
name: 'combineConditions',
|
||||
type: 'options',
|
||||
description:
|
||||
'How to combine the conditions defined in "Select Rows": AND requires all conditions to be true, OR requires at least one condition to be true',
|
||||
options: [
|
||||
{
|
||||
name: 'AND',
|
||||
value: 'AND',
|
||||
description: 'Only rows that meet all the conditions are selected',
|
||||
},
|
||||
{
|
||||
name: 'OR',
|
||||
value: 'OR',
|
||||
description: 'Rows that meet at least one condition are selected',
|
||||
},
|
||||
],
|
||||
default: 'AND',
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteTable from './deleteTable.operation';
|
||||
import * as executeQuery from './executeQuery.operation';
|
||||
import * as insert from './insert.operation';
|
||||
import * as select from './select.operation';
|
||||
import * as update from './update.operation';
|
||||
import * as upsert from './upsert.operation';
|
||||
import { tableRLC } from '../common.descriptions';
|
||||
|
||||
export { deleteTable, executeQuery, insert, select, update, upsert };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteTable',
|
||||
description: 'Delete an entire table or rows in a table',
|
||||
action: 'Delete table or rows',
|
||||
},
|
||||
{
|
||||
name: 'Execute SQL',
|
||||
value: 'executeQuery',
|
||||
description: 'Execute an SQL query',
|
||||
action: 'Execute a SQL query',
|
||||
},
|
||||
{
|
||||
name: 'Insert',
|
||||
value: 'insert',
|
||||
description: 'Insert rows in a table',
|
||||
action: 'Insert rows in a table',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-option-name-wrong-for-upsert
|
||||
name: 'Insert or Update',
|
||||
value: 'upsert',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-upsert
|
||||
description: 'Insert or update rows in a table',
|
||||
action: 'Insert or update rows in a table',
|
||||
},
|
||||
{
|
||||
name: 'Select',
|
||||
value: 'select',
|
||||
description: 'Select rows from a table',
|
||||
action: 'Select rows from a table',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update rows in a table',
|
||||
action: 'Update rows in a table',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
},
|
||||
},
|
||||
default: 'insert',
|
||||
},
|
||||
{
|
||||
...tableRLC,
|
||||
displayOptions: { hide: { operation: ['executeQuery'] } },
|
||||
},
|
||||
...deleteTable.description,
|
||||
...executeQuery.description,
|
||||
...insert.description,
|
||||
...select.description,
|
||||
...update.description,
|
||||
...upsert.description,
|
||||
];
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { addWhereClauses, escapeSqlIdentifier, getWhereClauses } from '../../helpers/utils';
|
||||
import {
|
||||
optionsCollection,
|
||||
selectRowsFixedCollection,
|
||||
combineConditionsCollection,
|
||||
} from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Command',
|
||||
name: 'deleteCommand',
|
||||
type: 'options',
|
||||
default: 'truncate',
|
||||
options: [
|
||||
{
|
||||
name: 'Truncate',
|
||||
value: 'truncate',
|
||||
description: "Only removes the table's data and preserves the table's structure",
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description:
|
||||
"Delete the rows that match the 'Select Rows' conditions below. If no selection is made, all rows in the table are deleted.",
|
||||
},
|
||||
{
|
||||
name: 'Drop',
|
||||
value: 'drop',
|
||||
description: "Deletes the table's data and also the table's structure permanently",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...selectRowsFixedCollection,
|
||||
displayOptions: {
|
||||
show: {
|
||||
deleteCommand: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineConditionsCollection,
|
||||
displayOptions: {
|
||||
show: {
|
||||
deleteCommand: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['deleteTable'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < inputItems.length; i++) {
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const deleteCommand = this.getNodeParameter('deleteCommand', i) as string;
|
||||
|
||||
let query = '';
|
||||
let values: QueryValues = [];
|
||||
|
||||
if (deleteCommand === 'drop') {
|
||||
query = `DROP TABLE IF EXISTS ${escapeSqlIdentifier(table)}`;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'truncate') {
|
||||
query = `TRUNCATE TABLE ${escapeSqlIdentifier(table)}`;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'delete') {
|
||||
const whereClauses = getWhereClauses(this, i);
|
||||
|
||||
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string;
|
||||
|
||||
[query, values] = addWhereClauses(
|
||||
this.getNode(),
|
||||
i,
|
||||
`DELETE FROM ${escapeSqlIdentifier(table)}`,
|
||||
whereClauses,
|
||||
values,
|
||||
combineConditions,
|
||||
);
|
||||
}
|
||||
|
||||
if (query === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Invalid delete command, only drop, delete and truncate are supported ',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
const queryWithValues = { query, values };
|
||||
|
||||
queries.push(queryWithValues);
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type { QueryRunner, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { prepareQueryAndReplacements, replaceEmptyStringsByNulls } from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. SELECT id, name FROM product WHERE id < 40',
|
||||
required: true,
|
||||
description:
|
||||
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
sqlDialect: 'MySQL',
|
||||
},
|
||||
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
nodeOptions: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const items = replaceEmptyStringsByNulls(inputItems, nodeOptions.replaceEmptyStrings as boolean);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let rawQuery = this.getNodeParameter('query', i) as string;
|
||||
|
||||
for (const resolvable of getResolvables(rawQuery)) {
|
||||
rawQuery = rawQuery.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const nodeVersion = Number(nodeOptions.nodeVersion);
|
||||
|
||||
let values;
|
||||
let queryReplacement = options.queryReplacement || [];
|
||||
|
||||
if (typeof queryReplacement === 'string') {
|
||||
queryReplacement = queryReplacement.split(',').map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
if (Array.isArray(queryReplacement)) {
|
||||
values = queryReplacement as IDataObject[];
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Query Replacement must be a string of comma-separated values, or an array of values',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
const preparedQuery = prepareQueryAndReplacements(rawQuery, nodeVersion, values);
|
||||
|
||||
if ((nodeOptions.nodeVersion as number) >= 2.3) {
|
||||
const parsedNumbers = preparedQuery.values.map((value) => {
|
||||
return Number(value) ? Number(value) : value;
|
||||
});
|
||||
preparedQuery.values = parsedNumbers;
|
||||
}
|
||||
|
||||
queries.push(preparedQuery);
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
QueryMode,
|
||||
QueryRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import { AUTO_MAP, BATCH_MODE, DATA_MODE } from '../../helpers/interfaces';
|
||||
import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: DATA_MODE.AUTO_MAP,
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Manually',
|
||||
value: DATA_MODE.MANUAL,
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: AUTO_MAP,
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.AUTO_MAP],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.MANUAL],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['insert'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
nodeOptions: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const items = replaceEmptyStringsByNulls(inputItems, nodeOptions.replaceEmptyStrings as boolean);
|
||||
|
||||
const table = this.getNodeParameter('table', 0, '', { extractValue: true }) as string;
|
||||
|
||||
const dataMode = this.getNodeParameter('dataMode', 0) as string;
|
||||
const queryBatching = (nodeOptions.queryBatching as QueryMode) || BATCH_MODE.SINGLE;
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
if (queryBatching === BATCH_MODE.SINGLE) {
|
||||
let columns: string[] = [];
|
||||
let insertItems: IDataObject[] = [];
|
||||
|
||||
const priority = (nodeOptions.priority as string) || '';
|
||||
const ignore = (nodeOptions.skipOnConflict as boolean) ? 'IGNORE' : '';
|
||||
|
||||
if (dataMode === DATA_MODE.AUTO_MAP) {
|
||||
columns = [
|
||||
...new Set(
|
||||
items.reduce((acc, item) => {
|
||||
const itemColumns = Object.keys(item.json);
|
||||
|
||||
return acc.concat(itemColumns);
|
||||
}, [] as string[]),
|
||||
),
|
||||
];
|
||||
insertItems = this.helpers.copyInputItems(items, columns);
|
||||
}
|
||||
|
||||
if (dataMode === DATA_MODE.MANUAL) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[];
|
||||
|
||||
const item = valuesToSend.reduce((acc, { column, value }) => {
|
||||
acc[column as string] = value;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
insertItems.push(item);
|
||||
}
|
||||
columns = [
|
||||
...new Set(
|
||||
insertItems.reduce((acc, item) => {
|
||||
const itemColumns = Object.keys(item);
|
||||
|
||||
return acc.concat(itemColumns);
|
||||
}, [] as string[]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const escapedColumns = columns.map(escapeSqlIdentifier).join(', ');
|
||||
const placeholder = `(${columns.map(() => '?').join(',')})`;
|
||||
const replacements = items.map(() => placeholder).join(',');
|
||||
|
||||
const query = `INSERT ${priority} ${ignore} INTO ${escapeSqlIdentifier(
|
||||
table,
|
||||
)} (${escapedColumns}) VALUES ${replacements}`;
|
||||
|
||||
const values = insertItems.reduce(
|
||||
(acc: IDataObject[], item) => acc.concat(Object.values(item) as IDataObject[]),
|
||||
[],
|
||||
);
|
||||
|
||||
queries.push({ query, values });
|
||||
} else {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let columns: string[] = [];
|
||||
let insertItem: IDataObject = {};
|
||||
|
||||
const options = this.getNodeParameter('options', i);
|
||||
const priority = (options.priority as string) || '';
|
||||
const ignore = (options.skipOnConflict as boolean) ? 'IGNORE' : '';
|
||||
|
||||
if (dataMode === DATA_MODE.AUTO_MAP) {
|
||||
columns = Object.keys(items[i].json);
|
||||
insertItem = columns.reduce((acc, key) => {
|
||||
if (columns.includes(key)) {
|
||||
acc[key] = items[i].json[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
}
|
||||
|
||||
if (dataMode === DATA_MODE.MANUAL) {
|
||||
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[];
|
||||
|
||||
insertItem = valuesToSend.reduce((acc, { column, value }) => {
|
||||
acc[column as string] = value;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
columns = Object.keys(insertItem);
|
||||
}
|
||||
|
||||
const escapedColumns = columns.map(escapeSqlIdentifier).join(', ');
|
||||
const placeholder = `(${columns.map(() => '?').join(',')})`;
|
||||
|
||||
const query = `INSERT ${priority} ${ignore} INTO ${escapeSqlIdentifier(
|
||||
table,
|
||||
)} (${escapedColumns}) VALUES ${placeholder};`;
|
||||
|
||||
const values = Object.values(insertItem) as QueryValues;
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues, SortRule } from '../../helpers/interfaces';
|
||||
import {
|
||||
addSortRules,
|
||||
addWhereClauses,
|
||||
escapeSqlIdentifier,
|
||||
getWhereClauses,
|
||||
} from '../../helpers/utils';
|
||||
import {
|
||||
optionsCollection,
|
||||
sortFixedCollection,
|
||||
selectRowsFixedCollection,
|
||||
combineConditionsCollection,
|
||||
} from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['event'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
default: 50,
|
||||
description: 'Max number of results to return',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
selectRowsFixedCollection,
|
||||
combineConditionsCollection,
|
||||
sortFixedCollection,
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['select'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < inputItems.length; i++) {
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
|
||||
const selectDistinct = this.getNodeParameter('options.selectDistinct', i, false) as boolean;
|
||||
|
||||
let query = '';
|
||||
const SELECT = selectDistinct ? 'SELECT DISTINCT' : 'SELECT';
|
||||
|
||||
if (outputColumns.includes('*')) {
|
||||
query = `${SELECT} * FROM ${escapeSqlIdentifier(table)}`;
|
||||
} else {
|
||||
const escapedColumns = outputColumns.map(escapeSqlIdentifier).join(', ');
|
||||
query = `${SELECT} ${escapedColumns} FROM ${escapeSqlIdentifier(table)}`;
|
||||
}
|
||||
|
||||
let values: QueryValues = [];
|
||||
|
||||
const whereClauses = getWhereClauses(this, i);
|
||||
|
||||
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string;
|
||||
|
||||
[query, values] = addWhereClauses(
|
||||
this.getNode(),
|
||||
i,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
combineConditions,
|
||||
);
|
||||
|
||||
const sortRules =
|
||||
((this.getNodeParameter('sort', i, []) as IDataObject).values as SortRule[]) || [];
|
||||
|
||||
[query, values] = addSortRules(query, sortRules, values);
|
||||
|
||||
const returnAll = this.getNodeParameter('returnAll', i, false);
|
||||
if (!returnAll) {
|
||||
const limit = this.getNodeParameter('limit', i, 50);
|
||||
query += ' LIMIT ?';
|
||||
values.push(limit);
|
||||
}
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
|
||||
import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: DATA_MODE.AUTO_MAP,
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: DATA_MODE.MANUAL,
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: AUTO_MAP,
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.AUTO_MAP],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to Match On',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed.",
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Rows with a value in the specified "Column to Match On" that corresponds to the value in this field will be updated',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.MANUAL],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.MANUAL],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['update'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
nodeOptions: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const items = replaceEmptyStringsByNulls(inputItems, nodeOptions.replaceEmptyStrings as boolean);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', i) as string;
|
||||
|
||||
const dataMode = this.getNodeParameter('dataMode', i) as string;
|
||||
|
||||
let item: IDataObject = {};
|
||||
let valueToMatchOn: string | IDataObject = '';
|
||||
|
||||
if (dataMode === DATA_MODE.AUTO_MAP) {
|
||||
item = items[i].json;
|
||||
valueToMatchOn = item[columnToMatchOn] as string;
|
||||
}
|
||||
|
||||
if (dataMode === DATA_MODE.MANUAL) {
|
||||
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[];
|
||||
|
||||
item = valuesToSend.reduce((acc, { column, value }) => {
|
||||
acc[column as string] = value;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
valueToMatchOn = this.getNodeParameter('valueToMatchOn', i) as string;
|
||||
}
|
||||
|
||||
const values: QueryValues = [];
|
||||
|
||||
const updateColumns = Object.keys(item).filter((column) => column !== columnToMatchOn);
|
||||
|
||||
const updates: string[] = [];
|
||||
|
||||
for (const column of updateColumns) {
|
||||
updates.push(`${escapeSqlIdentifier(column)} = ?`);
|
||||
values.push(item[column] as string);
|
||||
}
|
||||
|
||||
const condition = `${escapeSqlIdentifier(columnToMatchOn)} = ?`;
|
||||
values.push(valueToMatchOn);
|
||||
|
||||
const query = `UPDATE ${escapeSqlIdentifier(table)} SET ${updates.join(
|
||||
', ',
|
||||
)} WHERE ${condition}`;
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
|
||||
import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: DATA_MODE.AUTO_MAP,
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: DATA_MODE.MANUAL,
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: AUTO_MAP,
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.AUTO_MAP],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to Match On',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'The column to compare when finding the rows to update. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>.',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed. Has to be unique.",
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Rows with a value in the specified "Column to Match On" that corresponds to the value in this field will be updated. New rows will be created for non-matching items.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.MANUAL],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: [DATA_MODE.MANUAL],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['upsert'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
inputItems: INodeExecutionData[],
|
||||
runQueries: QueryRunner,
|
||||
nodeOptions: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = replaceEmptyStringsByNulls(inputItems, nodeOptions.replaceEmptyStrings as boolean);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', i) as string;
|
||||
|
||||
const dataMode = this.getNodeParameter('dataMode', i) as string;
|
||||
|
||||
let item: IDataObject = {};
|
||||
|
||||
if (dataMode === DATA_MODE.AUTO_MAP) {
|
||||
item = items[i].json;
|
||||
}
|
||||
|
||||
if (dataMode === DATA_MODE.MANUAL) {
|
||||
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[];
|
||||
|
||||
item = valuesToSend.reduce((acc, { column, value }) => {
|
||||
acc[column as string] = value;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
item[columnToMatchOn] = this.getNodeParameter('valueToMatchOn', i) as string;
|
||||
}
|
||||
|
||||
const onConflict = 'ON DUPLICATE KEY UPDATE';
|
||||
|
||||
const columns = Object.keys(item);
|
||||
const escapedColumns = columns.map(escapeSqlIdentifier).join(', ');
|
||||
const placeholder = `${columns.map(() => '?').join(',')}`;
|
||||
|
||||
const insertQuery = `INSERT INTO ${escapeSqlIdentifier(
|
||||
table,
|
||||
)}(${escapedColumns}) VALUES(${placeholder})`;
|
||||
|
||||
const values = Object.values(item) as QueryValues;
|
||||
|
||||
const updateColumns = Object.keys(item).filter((column) => column !== columnToMatchOn);
|
||||
|
||||
const updates: string[] = [];
|
||||
|
||||
for (const column of updateColumns) {
|
||||
updates.push(`${escapeSqlIdentifier(column)} = ?`);
|
||||
values.push(item[column] as string);
|
||||
}
|
||||
|
||||
const query = `${insertQuery} ${onConflict} ${updates.join(', ')}`;
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AllEntities, Entity } from 'n8n-workflow';
|
||||
|
||||
type MySQLMap = {
|
||||
database: 'deleteTable' | 'executeQuery' | 'insert' | 'select' | 'update' | 'upsert';
|
||||
};
|
||||
|
||||
export type MySqlType = AllEntities<MySQLMap>;
|
||||
|
||||
export type MySQLDatabaseType = Entity<MySQLMap, 'database'>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
import type { MySqlType } from './node.type';
|
||||
import { addExecutionHints } from '../../../../utils/utilities';
|
||||
import type { MysqlNodeCredentials, QueryRunner } from '../helpers/interfaces';
|
||||
import { configureQueryRunner } from '../helpers/utils';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<MySqlType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
const nodeOptions = this.getNodeParameter('options', 0);
|
||||
const node = this.getNode();
|
||||
|
||||
nodeOptions.nodeVersion = node.typeVersion;
|
||||
|
||||
const credentials = await this.getCredentials<MysqlNodeCredentials>('mySql');
|
||||
|
||||
const pool = await createPool.call(this, credentials, nodeOptions);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(this, nodeOptions, pool);
|
||||
|
||||
const mysqlNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as MySqlType;
|
||||
|
||||
try {
|
||||
switch (mysqlNodeData.resource) {
|
||||
case 'database':
|
||||
returnData = await database[mysqlNodeData.operation].execute.call(
|
||||
this,
|
||||
items,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
addExecutionHints(this, node, items, operation, node.executeOnce);
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'MySQL',
|
||||
name: 'mySql',
|
||||
icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' },
|
||||
group: ['input'],
|
||||
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5],
|
||||
subtitle: '={{ $parameter["operation"] }}',
|
||||
description: 'Get, add and update data in MySQL',
|
||||
defaults: {
|
||||
name: 'MySQL',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
credentials: [
|
||||
{
|
||||
name: 'mySql',
|
||||
required: true,
|
||||
testedBy: 'mysqlConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'hidden',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Database',
|
||||
value: 'database',
|
||||
},
|
||||
],
|
||||
default: 'database',
|
||||
},
|
||||
...database.description,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type mysql2 from 'mysql2/promise';
|
||||
import type { IDataObject, INodeExecutionData, SSHCredentials } from 'n8n-workflow';
|
||||
|
||||
export type Mysql2Connection = mysql2.Connection;
|
||||
export type Mysql2Pool = mysql2.Pool;
|
||||
export type Mysql2OkPacket = mysql2.OkPacket;
|
||||
export type Mysql2PoolConnection = mysql2.PoolConnection;
|
||||
|
||||
export type QueryValues = Array<string | number | IDataObject>;
|
||||
export type QueryWithValues = { query: string; values: QueryValues };
|
||||
|
||||
export type QueryRunner = (queries: QueryWithValues[]) => Promise<INodeExecutionData[]>;
|
||||
|
||||
export type WhereClause = { column: string; condition: string; value: string | number };
|
||||
export type SortRule = { column: string; direction: string };
|
||||
|
||||
export const AUTO_MAP = 'autoMapInputData';
|
||||
const MANUAL = 'defineBelow';
|
||||
export const DATA_MODE = {
|
||||
AUTO_MAP,
|
||||
MANUAL,
|
||||
};
|
||||
|
||||
export const SINGLE = 'single';
|
||||
const TRANSACTION = 'transaction';
|
||||
const INDEPENDENTLY = 'independently';
|
||||
export const BATCH_MODE = { SINGLE, TRANSACTION, INDEPENDENTLY };
|
||||
|
||||
export type QueryMode = typeof SINGLE | typeof TRANSACTION | typeof INDEPENDENTLY;
|
||||
|
||||
type WithSSL =
|
||||
| { ssl: false }
|
||||
| { ssl: true; caCertificate: string; clientCertificate: string; clientPrivateKey: string };
|
||||
|
||||
type WithSSHTunnel =
|
||||
| { sshTunnel: false }
|
||||
| ({
|
||||
sshTunnel: true;
|
||||
} & SSHCredentials);
|
||||
|
||||
export type MysqlNodeCredentials = {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
connectTimeout: number;
|
||||
} & WithSSL &
|
||||
WithSSHTunnel;
|
||||
|
||||
export type ParameterMatch = {
|
||||
match: string;
|
||||
index: number;
|
||||
paramNumber: string;
|
||||
isName: boolean;
|
||||
};
|
||||
@@ -0,0 +1,620 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
NodeExecutionWithMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
Mysql2Pool,
|
||||
Mysql2PoolConnection,
|
||||
ParameterMatch,
|
||||
QueryMode,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
SortRule,
|
||||
WhereClause,
|
||||
} from './interfaces';
|
||||
import { BATCH_MODE } from './interfaces';
|
||||
import { generatePairedItemData } from '../../../../utils/utilities';
|
||||
import { operatorOptions } from '../actions/common.descriptions';
|
||||
|
||||
export function escapeSqlIdentifier(identifier: string): string {
|
||||
const parts = identifier.match(/(`[^`]*`|[^.`]+)/g) ?? [];
|
||||
|
||||
return parts
|
||||
.map((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
|
||||
if (trimmedPart.startsWith('`') && trimmedPart.endsWith('`')) {
|
||||
return trimmedPart;
|
||||
}
|
||||
|
||||
return `\`${trimmedPart}\``;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
function findParameterMatches(rawQuery: string, regex: RegExp): ParameterMatch[] {
|
||||
const matches: ParameterMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(rawQuery)) !== null) {
|
||||
matches.push({
|
||||
match: match[0],
|
||||
index: match.index,
|
||||
paramNumber: match[1],
|
||||
isName: match[0].includes(':name'),
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function isInsideQuotes(rawQuery: string, index: number): boolean {
|
||||
const beforeMatch = rawQuery.substring(0, index);
|
||||
const singleQuoteCount = (beforeMatch.match(/'/g) || []).length;
|
||||
const doubleQuoteCount = (beforeMatch.match(/"/g) || []).length;
|
||||
return singleQuoteCount % 2 !== 0 || doubleQuoteCount % 2 !== 0;
|
||||
}
|
||||
|
||||
function filterValidMatches(matches: ParameterMatch[], rawQuery: string): ParameterMatch[] {
|
||||
return matches.filter(({ index }) => !isInsideQuotes(rawQuery, index));
|
||||
}
|
||||
|
||||
function processParameterReplacements(
|
||||
query: string,
|
||||
validMatches: ParameterMatch[],
|
||||
replacements: QueryValues,
|
||||
): string {
|
||||
let processedQuery = query;
|
||||
|
||||
for (const { match: matchStr, paramNumber, isName } of validMatches.reverse()) {
|
||||
const matchIndex = Number(paramNumber) - 1;
|
||||
|
||||
if (matchIndex >= 0 && matchIndex < replacements.length) {
|
||||
if (isName) {
|
||||
processedQuery = processedQuery.replace(
|
||||
matchStr,
|
||||
escapeSqlIdentifier(replacements[matchIndex].toString()),
|
||||
);
|
||||
} else {
|
||||
processedQuery = processedQuery.replace(matchStr, '?');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedQuery;
|
||||
}
|
||||
|
||||
function extractValuesFromMatches(
|
||||
validMatches: ParameterMatch[],
|
||||
replacements: QueryValues,
|
||||
): QueryValues {
|
||||
const nonNameMatches = validMatches.filter((match) => !match.isName);
|
||||
nonNameMatches.sort((a, b) => Number(a.paramNumber) - Number(b.paramNumber));
|
||||
|
||||
const values: QueryValues = [];
|
||||
for (const { paramNumber } of nonNameMatches) {
|
||||
const matchIndex = Number(paramNumber) - 1;
|
||||
if (matchIndex >= 0 && matchIndex < replacements.length) {
|
||||
values.push(replacements[matchIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function validateReferencedParameters(
|
||||
validMatches: ParameterMatch[],
|
||||
replacements: QueryValues,
|
||||
): void {
|
||||
for (const match of validMatches) {
|
||||
const paramIndex = Number(match.paramNumber) - 1;
|
||||
if (paramIndex >= replacements.length || paramIndex < 0) {
|
||||
throw new Error(
|
||||
`Parameter $${match.paramNumber} referenced in query but no replacement value provided at index ${paramIndex + 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const prepareQueryAndReplacements = (
|
||||
rawQuery: string,
|
||||
nodeVersion: number,
|
||||
replacements?: QueryValues,
|
||||
) => {
|
||||
if (replacements === undefined) {
|
||||
return { query: rawQuery, values: [] };
|
||||
}
|
||||
|
||||
if (nodeVersion >= 2.5) {
|
||||
const regex = /\$(\d+)(?::name)?/g;
|
||||
const matches = findParameterMatches(rawQuery, regex);
|
||||
const validMatches = filterValidMatches(matches, rawQuery);
|
||||
|
||||
validateReferencedParameters(validMatches, replacements);
|
||||
|
||||
const query = processParameterReplacements(rawQuery, validMatches, replacements);
|
||||
const values = extractValuesFromMatches(validMatches, replacements);
|
||||
|
||||
return { query, values };
|
||||
}
|
||||
|
||||
return prepareQueryLegacy(rawQuery, replacements);
|
||||
};
|
||||
|
||||
export const prepareQueryLegacy = (
|
||||
rawQuery: string,
|
||||
replacements: QueryValues,
|
||||
): QueryWithValues => {
|
||||
let query: string = rawQuery;
|
||||
const values: QueryValues = [];
|
||||
|
||||
const regex = /\$(\d+)(?::name)?/g;
|
||||
const matches = rawQuery.match(regex) || [];
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.includes(':name')) {
|
||||
const matchIndex = Number(match.replace('$', '').replace(':name', '')) - 1;
|
||||
query = query.replace(match, escapeSqlIdentifier(replacements[matchIndex].toString()));
|
||||
} else {
|
||||
const matchIndex = Number(match.replace('$', '')) - 1;
|
||||
query = query.replace(match, '?');
|
||||
values.push(replacements[matchIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
return { query, values };
|
||||
};
|
||||
|
||||
export function prepareErrorItem(
|
||||
item: IDataObject,
|
||||
error: IDataObject | NodeOperationError | Error,
|
||||
index: number,
|
||||
) {
|
||||
return {
|
||||
json: { message: error.message, item: { ...item }, itemIndex: index, error: { ...error } },
|
||||
pairedItem: { item: index },
|
||||
} as INodeExecutionData;
|
||||
}
|
||||
|
||||
export function parseMySqlError(
|
||||
this: IExecuteFunctions,
|
||||
error: any,
|
||||
itemIndex = 0,
|
||||
queries?: string[],
|
||||
) {
|
||||
let message: string = error.message;
|
||||
const description = `sql: ${error.sql}, code: ${error.code}`;
|
||||
|
||||
if (
|
||||
queries?.length &&
|
||||
(message || '').toLowerCase().includes('you have an error in your sql syntax')
|
||||
) {
|
||||
let queryIndex = itemIndex;
|
||||
const failedStatement = ((message.split("near '")[1] || '').split("' at")[0] || '').split(
|
||||
';',
|
||||
)[0];
|
||||
|
||||
if (failedStatement) {
|
||||
if (queryIndex === 0 && queries.length > 1) {
|
||||
const failedQueryIndex = queries.findIndex((query) => query.includes(failedStatement));
|
||||
if (failedQueryIndex !== -1) {
|
||||
queryIndex = failedQueryIndex;
|
||||
}
|
||||
}
|
||||
const lines = queries[queryIndex].split('\n');
|
||||
|
||||
const failedLine = lines.findIndex((line) => line.includes(failedStatement));
|
||||
if (failedLine !== -1) {
|
||||
message = `You have an error in your SQL syntax on line ${
|
||||
failedLine + 1
|
||||
} near '${failedStatement}'`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((error?.message as string).includes('ECONNREFUSED')) {
|
||||
message = 'Connection refused';
|
||||
}
|
||||
|
||||
return new NodeOperationError(this.getNode(), error as Error, {
|
||||
message,
|
||||
description,
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapData(data: IDataObject | IDataObject[]): INodeExecutionData[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [{ json: data }];
|
||||
}
|
||||
return data.map((item) => ({
|
||||
json: item,
|
||||
}));
|
||||
}
|
||||
|
||||
export function prepareOutput(
|
||||
response: IDataObject[],
|
||||
options: IDataObject,
|
||||
statements: string[],
|
||||
constructExecutionHelper: (
|
||||
inputData: INodeExecutionData[],
|
||||
options: {
|
||||
itemData: IPairedItemData | IPairedItemData[];
|
||||
},
|
||||
) => NodeExecutionWithMetadata[],
|
||||
itemData: IPairedItemData | IPairedItemData[],
|
||||
) {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
if (options.detailedOutput) {
|
||||
response.forEach((entry, index) => {
|
||||
const item = {
|
||||
sql: statements[index],
|
||||
data: entry,
|
||||
};
|
||||
|
||||
const executionData = constructExecutionHelper(wrapData(item), {
|
||||
itemData,
|
||||
});
|
||||
|
||||
returnData = returnData.concat(executionData);
|
||||
});
|
||||
} else {
|
||||
response
|
||||
.filter((entry) => Array.isArray(entry))
|
||||
.forEach((entry, index) => {
|
||||
const executionData = constructExecutionHelper(wrapData(entry), {
|
||||
itemData: Array.isArray(itemData) ? itemData[index] : itemData,
|
||||
});
|
||||
|
||||
returnData = returnData.concat(executionData);
|
||||
});
|
||||
}
|
||||
|
||||
if (!returnData.length) {
|
||||
if ((options?.nodeVersion as number) < 2.2) {
|
||||
returnData.push({ json: { success: true }, pairedItem: itemData });
|
||||
} else {
|
||||
const isSelectQuery = statements
|
||||
.filter((statement) => !statement.startsWith('--'))
|
||||
.every((statement) =>
|
||||
statement
|
||||
.replace(/\/\*.*?\*\//g, '') // remove multiline comments
|
||||
.replace(/\n/g, '')
|
||||
.toLowerCase()
|
||||
.startsWith('select'),
|
||||
);
|
||||
|
||||
if (!isSelectQuery) {
|
||||
returnData.push({ json: { success: true }, pairedItem: itemData });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
const END_OF_STATEMENT = /;(?=(?:[^'\\]|'[^']*?'|\\[\s\S])*?$)/g;
|
||||
export const splitQueryToStatements = (query: string, filterOutEmpty = true) => {
|
||||
const statements = query
|
||||
.replace(/\n/g, '')
|
||||
.split(END_OF_STATEMENT)
|
||||
.map((statement) => statement.trim());
|
||||
return filterOutEmpty ? statements.filter((statement) => statement !== '') : statements;
|
||||
};
|
||||
|
||||
export function configureQueryRunner(
|
||||
this: IExecuteFunctions,
|
||||
options: IDataObject,
|
||||
pool: Mysql2Pool,
|
||||
) {
|
||||
return async (queries: QueryWithValues[]) => {
|
||||
if (queries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const mode = (options.queryBatching as QueryMode) || BATCH_MODE.SINGLE;
|
||||
|
||||
let connection: Mysql2PoolConnection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
} catch (e) {
|
||||
const error = parseMySqlError.call(this, e);
|
||||
if (!this.continueOnFail()) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return [{ json: { message: error.message, error: { ...error } } }];
|
||||
}
|
||||
|
||||
if (mode === BATCH_MODE.SINGLE) {
|
||||
const formattedQueries = queries.map(({ query, values }) => connection.format(query, values));
|
||||
try {
|
||||
//releasing connection after formatting queries, otherwise pool.query() will fail with timeout
|
||||
connection.release();
|
||||
|
||||
let singleQuery = '';
|
||||
if (formattedQueries.length > 1) {
|
||||
singleQuery = formattedQueries.map((query) => query.trim().replace(/;$/, '')).join(';');
|
||||
} else {
|
||||
singleQuery = formattedQueries[0];
|
||||
}
|
||||
|
||||
let response: IDataObject | IDataObject[] = (
|
||||
await pool.query(singleQuery)
|
||||
)[0] as unknown as IDataObject;
|
||||
|
||||
if (!response) return [];
|
||||
|
||||
let statements;
|
||||
if ((options?.nodeVersion as number) <= 2.3) {
|
||||
statements = singleQuery
|
||||
.replace(/\n/g, '')
|
||||
.split(';')
|
||||
.filter((statement) => statement !== '');
|
||||
} else {
|
||||
statements = splitQueryToStatements(singleQuery);
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
if (statements.length === 1) response = [response];
|
||||
} else {
|
||||
response = [response];
|
||||
}
|
||||
|
||||
//because single query is used in this mode mapping itemIndex not possible, setting all items as paired
|
||||
const pairedItem = generatePairedItemData(queries.length);
|
||||
|
||||
returnData = returnData.concat(
|
||||
prepareOutput(
|
||||
response,
|
||||
options,
|
||||
statements,
|
||||
this.helpers.constructExecutionMetaData,
|
||||
pairedItem,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, 0, formattedQueries);
|
||||
|
||||
if (!this.continueOnFail()) throw error;
|
||||
returnData.push({ json: { message: error.message, error: { ...error } } });
|
||||
}
|
||||
} else {
|
||||
if (mode === BATCH_MODE.INDEPENDENTLY) {
|
||||
let formattedQuery = '';
|
||||
for (const [index, queryWithValues] of queries.entries()) {
|
||||
try {
|
||||
const { query, values } = queryWithValues;
|
||||
formattedQuery = connection.format(query, values);
|
||||
|
||||
let statements;
|
||||
if ((options?.nodeVersion as number) <= 2.3) {
|
||||
statements = formattedQuery.split(';').map((q) => q.trim());
|
||||
} else {
|
||||
statements = splitQueryToStatements(formattedQuery, false);
|
||||
}
|
||||
|
||||
const responses: IDataObject[] = [];
|
||||
for (const statement of statements) {
|
||||
if (statement === '') continue;
|
||||
const response = (await connection.query(statement))[0] as unknown as IDataObject;
|
||||
|
||||
responses.push(response);
|
||||
}
|
||||
|
||||
returnData = returnData.concat(
|
||||
prepareOutput(
|
||||
responses,
|
||||
options,
|
||||
statements,
|
||||
this.helpers.constructExecutionMetaData,
|
||||
{ item: index },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, index, [formattedQuery]);
|
||||
|
||||
if (!this.continueOnFail()) {
|
||||
connection.release();
|
||||
throw error;
|
||||
}
|
||||
returnData.push(prepareErrorItem(queries[index], error as Error, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === BATCH_MODE.TRANSACTION) {
|
||||
await connection.beginTransaction();
|
||||
|
||||
let formattedQuery = '';
|
||||
for (const [index, queryWithValues] of queries.entries()) {
|
||||
try {
|
||||
const { query, values } = queryWithValues;
|
||||
formattedQuery = connection.format(query, values);
|
||||
|
||||
let statements;
|
||||
if ((options?.nodeVersion as number) <= 2.3) {
|
||||
statements = formattedQuery.split(';').map((q) => q.trim());
|
||||
} else {
|
||||
statements = splitQueryToStatements(formattedQuery, false);
|
||||
}
|
||||
|
||||
const responses: IDataObject[] = [];
|
||||
for (const statement of statements) {
|
||||
if (statement === '') continue;
|
||||
const response = (await connection.query(statement))[0] as unknown as IDataObject;
|
||||
|
||||
responses.push(response);
|
||||
}
|
||||
|
||||
returnData = returnData.concat(
|
||||
prepareOutput(
|
||||
responses,
|
||||
options,
|
||||
statements,
|
||||
this.helpers.constructExecutionMetaData,
|
||||
{ item: index },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, index, [formattedQuery]);
|
||||
|
||||
if (connection) {
|
||||
await connection.rollback();
|
||||
connection.release();
|
||||
}
|
||||
|
||||
if (!this.continueOnFail()) throw error;
|
||||
returnData.push(prepareErrorItem(queries[index], error as Error, index));
|
||||
|
||||
// Return here because we already rolled back the transaction
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
}
|
||||
|
||||
connection.release();
|
||||
}
|
||||
|
||||
return returnData;
|
||||
};
|
||||
}
|
||||
|
||||
export function addWhereClauses(
|
||||
node: INode,
|
||||
itemIndex: number,
|
||||
query: string,
|
||||
clauses: WhereClause[],
|
||||
replacements: QueryValues,
|
||||
combineConditions?: string,
|
||||
): [string, QueryValues] {
|
||||
if (clauses.length === 0) return [query, replacements];
|
||||
|
||||
let combineWith = 'AND';
|
||||
|
||||
if (combineConditions === 'OR') {
|
||||
combineWith = 'OR';
|
||||
}
|
||||
|
||||
let whereQuery = ' WHERE';
|
||||
const values: QueryValues = [];
|
||||
|
||||
clauses.forEach((clause, index) => {
|
||||
if (clause.condition === 'equal') {
|
||||
clause.condition = '=';
|
||||
}
|
||||
|
||||
if (['>', '<', '>=', '<='].includes(clause.condition)) {
|
||||
const value = Number(clause.value);
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Operator in entry ${index + 1} of 'Select Rows' works with numbers, but value ${
|
||||
clause.value
|
||||
} is not a number`,
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
clause.value = value;
|
||||
}
|
||||
|
||||
let valueReplacement = ' ';
|
||||
if (clause.condition !== 'IS NULL' && clause.condition !== 'IS NOT NULL') {
|
||||
valueReplacement = ' ?';
|
||||
values.push(clause.value);
|
||||
}
|
||||
|
||||
const operator = index === clauses.length - 1 ? '' : ` ${combineWith}`;
|
||||
|
||||
whereQuery += ` ${escapeSqlIdentifier(clause.column)} ${
|
||||
clause.condition
|
||||
}${valueReplacement}${operator}`;
|
||||
});
|
||||
|
||||
return [`${query}${whereQuery}`, replacements.concat.apply(replacements, values)];
|
||||
}
|
||||
|
||||
export function addSortRules(
|
||||
query: string,
|
||||
rules: SortRule[],
|
||||
replacements: QueryValues,
|
||||
): [string, QueryValues] {
|
||||
if (rules.length === 0) return [query, replacements];
|
||||
|
||||
let orderByQuery = ' ORDER BY';
|
||||
const values: string[] = [];
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
const endWith = index === rules.length - 1 ? '' : ',';
|
||||
const direction = rule.direction === 'ASC' ? 'ASC' : 'DESC';
|
||||
orderByQuery += ` ${escapeSqlIdentifier(rule.column)} ${direction}${endWith}`;
|
||||
});
|
||||
|
||||
return [`${query}${orderByQuery}`, replacements.concat.apply(replacements, values)];
|
||||
}
|
||||
|
||||
export function replaceEmptyStringsByNulls(
|
||||
items: INodeExecutionData[],
|
||||
replace?: boolean,
|
||||
): INodeExecutionData[] {
|
||||
if (!replace) return [...items];
|
||||
|
||||
const returnData: INodeExecutionData[] = items.map((item) => {
|
||||
const newItem = { ...item };
|
||||
const keys = Object.keys(newItem.json);
|
||||
|
||||
for (const key of keys) {
|
||||
if (newItem.json[key] === '') {
|
||||
newItem.json[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
// operations use 'equal' instead of '=' because of the way expressions are handled
|
||||
// manually add '=' to allow entering it instead of 'equal'
|
||||
const conditionSet = new Set(operatorOptions.map((option) => option.value)).add('=');
|
||||
|
||||
export const isWhereClause = (clause: unknown): clause is WhereClause => {
|
||||
if (typeof clause !== 'object' || clause === null) return false;
|
||||
if (!('column' in clause)) return false;
|
||||
if (
|
||||
!('condition' in clause) ||
|
||||
typeof clause.condition !== 'string' ||
|
||||
!conditionSet.has(clause.condition)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getWhereClauses = (ctx: IExecuteFunctions, itemIndex: number): WhereClause[] => {
|
||||
const whereClauses = ctx.getNodeParameter('where', itemIndex, []) as IDataObject;
|
||||
const whereClausesValues = whereClauses.values as unknown[];
|
||||
if (!Array.isArray(whereClausesValues)) {
|
||||
return [];
|
||||
}
|
||||
const someInvalid = whereClausesValues.some((clause) => !isWhereClause(clause));
|
||||
if (someInvalid) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'Invalid where clause', {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
return whereClausesValues as WhereClause[];
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
INodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { MysqlNodeCredentials } from '../helpers/interfaces';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
export async function mysqlConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as MysqlNodeCredentials;
|
||||
|
||||
const pool = await createPool.call(this, credentials);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as credentialTest from './credentialTest';
|
||||
export * as listSearch from './listSearch';
|
||||
export * as loadOptions from './loadOptions';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IDataObject, ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import type { MysqlNodeCredentials } from '../helpers/interfaces';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
export async function searchTables(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<MysqlNodeCredentials>('mySql');
|
||||
|
||||
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
|
||||
|
||||
const pool = await createPool.call(this, credentials, nodeOptions);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const query = 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ?';
|
||||
const values = [credentials.database];
|
||||
|
||||
const formatedQuery = connection.format(query, values);
|
||||
|
||||
const response = (await connection.query(formatedQuery))[0];
|
||||
|
||||
connection.release();
|
||||
|
||||
const results = (response as IDataObject[]).map((table) => ({
|
||||
name: (table.table_name as string) || (table.TABLE_NAME as string),
|
||||
value: (table.table_name as string) || (table.TABLE_NAME as string),
|
||||
}));
|
||||
|
||||
return { results };
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
import type { MysqlNodeCredentials } from '../helpers/interfaces';
|
||||
import { escapeSqlIdentifier } from '../helpers/utils';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
export async function getColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const credentials = await this.getCredentials<MysqlNodeCredentials>('mySql');
|
||||
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
|
||||
|
||||
const pool = await createPool.call(this, credentials, nodeOptions);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const table = this.getNodeParameter('table', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columns = (
|
||||
await connection.query(
|
||||
`SHOW COLUMNS FROM ${escapeSqlIdentifier(table)} FROM ${escapeSqlIdentifier(
|
||||
credentials.database,
|
||||
)}`,
|
||||
)
|
||||
)[0] as IDataObject[];
|
||||
|
||||
connection.release();
|
||||
|
||||
return (columns || []).map((column: IDataObject) => ({
|
||||
name: column.Field as string,
|
||||
value: column.Field as string,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-lowercase-first-char
|
||||
description: `type: ${(column.Type as string).toUpperCase()}, nullable: ${
|
||||
column.Null as string
|
||||
}`,
|
||||
}));
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getColumnsMultiOptions(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getColumns.call(this);
|
||||
const returnAll = { name: '*', value: '*', description: 'All columns' };
|
||||
return [returnAll, ...returnData];
|
||||
}
|
||||
|
||||
export async function getColumnsWithoutColumnToMatchOn(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn') as string;
|
||||
const returnData = await getColumns.call(this);
|
||||
return returnData.filter((column) => column.value !== columnToMatchOn);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type {
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { createServer, type AddressInfo } from 'node:net';
|
||||
|
||||
import { LOCALHOST } from '@utils/constants';
|
||||
import { formatPrivateKey } from '@utils/utilities';
|
||||
|
||||
import type { Mysql2Pool, MysqlNodeCredentials } from '../helpers/interfaces';
|
||||
|
||||
export async function createPool(
|
||||
this: IExecuteFunctions | ICredentialTestFunctions | ILoadOptionsFunctions,
|
||||
credentials: MysqlNodeCredentials,
|
||||
options?: IDataObject,
|
||||
): Promise<Mysql2Pool> {
|
||||
const connectionOptions: mysql2.ConnectionOptions = {
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
database: credentials.database,
|
||||
user: credentials.user,
|
||||
password: credentials.password,
|
||||
multipleStatements: true,
|
||||
supportBigNumbers: true,
|
||||
decimalNumbers: false,
|
||||
};
|
||||
|
||||
if (credentials.ssl) {
|
||||
connectionOptions.ssl = {};
|
||||
|
||||
if (credentials.caCertificate) {
|
||||
connectionOptions.ssl.ca = formatPrivateKey(credentials.caCertificate);
|
||||
}
|
||||
|
||||
if (credentials.clientCertificate || credentials.clientPrivateKey) {
|
||||
connectionOptions.ssl.cert = formatPrivateKey(credentials.clientCertificate);
|
||||
connectionOptions.ssl.key = formatPrivateKey(credentials.clientPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.nodeVersion && (options.nodeVersion as number) >= 2.1) {
|
||||
connectionOptions.dateStrings = true;
|
||||
}
|
||||
|
||||
if (options?.connectionLimit) {
|
||||
connectionOptions.connectionLimit = options.connectionLimit as number;
|
||||
}
|
||||
|
||||
if (options?.connectTimeout) {
|
||||
connectionOptions.connectTimeout = options.connectTimeout as number;
|
||||
}
|
||||
|
||||
if (options?.largeNumbersOutput === 'text') {
|
||||
connectionOptions.bigNumberStrings = true;
|
||||
}
|
||||
|
||||
if (options?.decimalNumbers === true) {
|
||||
connectionOptions.decimalNumbers = true;
|
||||
}
|
||||
|
||||
if (!credentials.sshTunnel) {
|
||||
return mysql2.createPool(connectionOptions);
|
||||
} else {
|
||||
if (credentials.sshAuthenticateWith === 'privateKey' && credentials.privateKey) {
|
||||
credentials.privateKey = formatPrivateKey(credentials.privateKey);
|
||||
}
|
||||
const sshClient = await this.helpers.getSSHClient(credentials);
|
||||
|
||||
// Find a free TCP port
|
||||
const localPort = await new Promise<number>((resolve) => {
|
||||
const tempServer = createServer();
|
||||
tempServer.listen(0, LOCALHOST, () => {
|
||||
resolve((tempServer.address() as AddressInfo).port);
|
||||
tempServer.close();
|
||||
});
|
||||
});
|
||||
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
sshClient.forwardOut(
|
||||
LOCALHOST,
|
||||
localPort,
|
||||
credentials.host,
|
||||
credentials.port,
|
||||
(err, clientChannel) => {
|
||||
if (err) return reject(err);
|
||||
resolve(clientChannel);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return mysql2.createPool({
|
||||
...connectionOptions,
|
||||
stream,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user