mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-23 11:44:06 -08:00
feat(MySQL Node): Overhaul
This commit is contained in:
parent
29959be688
commit
0a53c957c4
|
@ -97,5 +97,120 @@ export class MySql implements ICredentialType {
|
|||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Tunnel',
|
||||
name: 'sshTunnel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Authenticate with',
|
||||
name: 'sshAuthenticateWith',
|
||||
type: 'options',
|
||||
default: 'password',
|
||||
options: [
|
||||
{
|
||||
name: 'Password',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
name: 'Private Key',
|
||||
value: 'privateKey',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Host',
|
||||
name: 'sshHost',
|
||||
type: 'string',
|
||||
default: 'localhost',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Port',
|
||||
name: 'sshPort',
|
||||
type: 'number',
|
||||
default: 22,
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH MySQL Port',
|
||||
name: 'sshMysqlPort',
|
||||
type: 'number',
|
||||
default: 3306,
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH User',
|
||||
name: 'sshUser',
|
||||
type: 'string',
|
||||
default: 'root',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'SSH Password',
|
||||
name: 'sshPassword',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['password'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Private Key',
|
||||
name: 'privateKey',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['privateKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Passphrase',
|
||||
name: 'passphrase',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Passphase used to create the key, if no passphase was used leave empty',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sshTunnel: [true],
|
||||
sshAuthenticateWith: ['privateKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -1,407 +1,25 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
// @ts-ignore
|
||||
import type mysql2 from 'mysql2/promise';
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { copyInputItems, createConnection, searchTables } from './GenericFunctions';
|
||||
import { MySqlV1 } from './v1/MySqlV1.node';
|
||||
import { MySqlV2 } from './v2/MySqlV2.node';
|
||||
|
||||
export class MySql implements INodeType {
|
||||
description: 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: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mySql',
|
||||
required: true,
|
||||
testedBy: 'mysqlConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
export class MySql extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'MySQL',
|
||||
name: 'mySql',
|
||||
icon: 'file:mysql.svg',
|
||||
group: ['input'],
|
||||
defaultVersion: 2,
|
||||
description: 'Get, add and update data in MySQL',
|
||||
};
|
||||
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'SELECT id, name FROM product WHERE id < 40',
|
||||
required: true,
|
||||
description: 'The SQL query to execute',
|
||||
},
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new MySqlV1(baseDescription),
|
||||
2: new MySqlV2(baseDescription),
|
||||
};
|
||||
|
||||
// ----------------------------------
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 (item, index) => {
|
||||
const rawQuery = this.getNodeParameter('query', index) as string;
|
||||
|
||||
return 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.push(...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 = 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 = copyInputItems(items, columns);
|
||||
const updateSQL = `UPDATE ${table} SET ${columns
|
||||
.map((column) => `${column} = ?`)
|
||||
.join(',')} WHERE ${updateKey} = ?;`;
|
||||
const queryQueue = updateItems.map(async (item) =>
|
||||
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 this.prepareOutputData(returnItems);
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
|
|
511
packages/nodes-base/nodes/MySql/test/v2/operations.test.ts
Normal file
511
packages/nodes-base/nodes/MySql/test/v2/operations.test.ts
Normal file
|
@ -0,0 +1,511 @@
|
|||
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';
|
||||
|
||||
import mysql2 from 'mysql2/promise';
|
||||
|
||||
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 = ''): Promise<any> => Promise.resolve([{}])),
|
||||
release: jest.fn(),
|
||||
beginTransaction: jest.fn(),
|
||||
commit: jest.fn(),
|
||||
rollback: jest.fn(),
|
||||
};
|
||||
|
||||
const createFakePool = (connection: IDataObject) => {
|
||||
return {
|
||||
getConnection() {
|
||||
return connection;
|
||||
},
|
||||
query: jest.fn(async () => Promise.resolve([{}])),
|
||||
} 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 } }]);
|
||||
|
||||
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 } }]);
|
||||
|
||||
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 } }]);
|
||||
|
||||
expect(poolQuerySpy).toBeCalledTimes(1);
|
||||
expect(poolQuerySpy).toBeCalledWith(
|
||||
"DELETE FROM `test_table` WHERE `id` = '1' AND `name` LIKE 'some%'",
|
||||
);
|
||||
});
|
||||
|
||||
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 = [];
|
||||
console.log(query);
|
||||
if (query?.toLowerCase().includes('select')) {
|
||||
result.push([{ id: 1, name: 'test 1' }]);
|
||||
} else {
|
||||
result.push({});
|
||||
}
|
||||
return Promise.resolve(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('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',
|
||||
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,
|
||||
pool,
|
||||
);
|
||||
|
||||
const result = await select.execute.call(fakeExecuteFunction, emptyInputItems, runQueries);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([{ json: { success: true } }]);
|
||||
|
||||
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` undefined '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 } }]);
|
||||
|
||||
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 } }, { json: { success: true } }]);
|
||||
|
||||
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 } }]);
|
||||
|
||||
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'",
|
||||
);
|
||||
});
|
||||
});
|
178
packages/nodes-base/nodes/MySql/test/v2/runQueries.test.ts
Normal file
178
packages/nodes-base/nodes/MySql/test/v2/runQueries.test.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { createMockExecuteFunction } from '../../../../test/nodes/Helpers';
|
||||
|
||||
import { configureQueryRunner } from '../../v2/helpers/utils';
|
||||
import type { Mysql2Pool, QueryRunner } from '../../v2/helpers/interfaces';
|
||||
import { BATCH_MODE } from '../../v2/helpers/interfaces';
|
||||
|
||||
import type { IDataObject, INode } from 'n8n-workflow';
|
||||
|
||||
import mysql2 from 'mysql2/promise';
|
||||
|
||||
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 () => Promise.resolve([{}])),
|
||||
release: jest.fn(),
|
||||
beginTransaction: jest.fn(),
|
||||
commit: jest.fn(),
|
||||
rollback: jest.fn(),
|
||||
};
|
||||
|
||||
const createFakePool = (connection: IDataObject) => {
|
||||
return {
|
||||
getConnection() {
|
||||
return connection;
|
||||
},
|
||||
query: jest.fn(async () => Promise.resolve([{}])),
|
||||
} as unknown as Mysql2Pool;
|
||||
};
|
||||
|
||||
describe('Test MySql V2, runQueries', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute in "Single" mode, should return success true', async () => {
|
||||
const nodeOptions: IDataObject = { queryBatching: BATCH_MODE.SINGLE };
|
||||
|
||||
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 } }]);
|
||||
|
||||
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 };
|
||||
|
||||
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 } }]);
|
||||
|
||||
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 };
|
||||
|
||||
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 } }]);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
150
packages/nodes-base/nodes/MySql/test/v2/utils.test.ts
Normal file
150
packages/nodes-base/nodes/MySql/test/v2/utils.test.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import type { INode } from 'n8n-workflow';
|
||||
import type { SortRule, WhereClause } from '../../v2/helpers/interfaces';
|
||||
|
||||
import {
|
||||
prepareQueryAndReplacements,
|
||||
wrapData,
|
||||
addWhereClauses,
|
||||
addSortRules,
|
||||
replaceEmptyStringsByNulls,
|
||||
} 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',
|
||||
['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');
|
||||
});
|
||||
});
|
||||
|
||||
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: '' } }]);
|
||||
});
|
||||
});
|
424
packages/nodes-base/nodes/MySql/v1/MySqlV1.node.ts
Normal file
424
packages/nodes-base/nodes/MySql/v1/MySqlV1.node.ts
Normal file
|
@ -0,0 +1,424 @@
|
|||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type mysql2 from 'mysql2/promise';
|
||||
|
||||
import { copyInputItems, createConnection, searchTables } from './GenericFunctions';
|
||||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
|
||||
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: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mySql',
|
||||
required: true,
|
||||
testedBy: 'mysqlConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Version 1',
|
||||
name: 'versionNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
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',
|
||||
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 (item, index) => {
|
||||
const rawQuery = this.getNodeParameter('query', index) as string;
|
||||
|
||||
return 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.push(...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 = 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 = copyInputItems(items, columns);
|
||||
const updateSQL = `UPDATE ${table} SET ${columns
|
||||
.map((column) => `${column} = ?`)
|
||||
.join(',')} WHERE ${updateKey} = ?;`;
|
||||
const queryQueue = updateItems.map(async (item) =>
|
||||
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 this.prepareOutputData(returnItems);
|
||||
}
|
||||
}
|
32
packages/nodes-base/nodes/MySql/v2/MySqlV2.node.ts
Normal file
32
packages/nodes-base/nodes/MySql/v2/MySqlV2.node.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type {
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
|
||||
import { listSearch, credentialTest, loadOptions } from './methods';
|
||||
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
|
||||
import { router } from './actions/router';
|
||||
|
||||
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 router.call(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,361 @@
|
|||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { BATCH_MODE, SINGLE } from '../helpers/interfaces';
|
||||
|
||||
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: 'Independently',
|
||||
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/">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',
|
||||
description:
|
||||
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code-examples/expressions/">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: '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',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
default: 'equal',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
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',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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,137 @@
|
|||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
QueryRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
WhereClause,
|
||||
} from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
import { addWhereClauses } 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 \`${table}\``;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'truncate') {
|
||||
query = `TRUNCATE TABLE \`${table}\``;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'delete') {
|
||||
const whereClauses =
|
||||
((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || [];
|
||||
|
||||
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string;
|
||||
|
||||
[query, values] = addWhereClauses(
|
||||
this.getNode(),
|
||||
i,
|
||||
`DELETE FROM \`${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,89 @@
|
|||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { QueryRunner, QueryWithValues } from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
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.",
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
hint: 'Prefer using query parameters over n8n expressions to avoid SQL injection attacks',
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
To use query parameters in your SQL query, reference them as $1, $2, $3, etc in the corresponding order. <a target="_blank" href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.mysql/">More info</a>.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
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++) {
|
||||
const rawQuery = this.getNodeParameter('query', i) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
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, values);
|
||||
|
||||
queries.push(preparedQuery);
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
QueryMode,
|
||||
QueryRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
|
||||
import { AUTO_MAP, BATCH_MODE, DATA_MODE } from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
import { copyInputItems, 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 a 'Set' 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',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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 = 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((column) => `\`${column}\``).join(', ');
|
||||
const placeholder = `(${columns.map(() => '?').join(',')})`;
|
||||
const replacements = items.map(() => placeholder).join(',');
|
||||
|
||||
const query = `INSERT ${priority} ${ignore} INTO \`${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((column) => `\`${column}\``).join(', ');
|
||||
const placeholder = `(${columns.map(() => '?').join(',')})`;
|
||||
|
||||
const query = `INSERT ${priority} ${ignore} INTO \`${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 { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
QueryRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
SortRule,
|
||||
WhereClause,
|
||||
} from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
import { addSortRules, addWhereClauses } 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 \`${table}\``;
|
||||
} else {
|
||||
const escapedColumns = outputColumns.map((column) => `\`${column}\``).join(', ');
|
||||
query = `${SELECT} ${escapedColumns} FROM \`${table}\``;
|
||||
}
|
||||
|
||||
let values: QueryValues = [];
|
||||
|
||||
const whereClauses =
|
||||
((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || [];
|
||||
|
||||
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,195 @@
|
|||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
import { 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 a 'Set' 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-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to Match On',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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(`\`${column}\` = ?`);
|
||||
values.push(item[column] as string);
|
||||
}
|
||||
|
||||
const condition = `\`${columnToMatchOn}\` = ?`;
|
||||
values.push(valueToMatchOn);
|
||||
|
||||
const query = `UPDATE \`${table}\` SET ${updates.join(', ')} WHERE ${condition}`;
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
|
||||
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
|
||||
|
||||
import { updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
|
||||
import { 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 a 'Set' 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-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to Match On',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
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-examples/expressions/">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',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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((column) => `\`${column}\``).join(', ');
|
||||
const placeholder = `${columns.map(() => '?').join(',')}`;
|
||||
|
||||
const insertQuery = `INSERT INTO \`${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(`\`${column}\` = ?`);
|
||||
values.push(item[column] as string);
|
||||
}
|
||||
|
||||
const query = `${insertQuery} ${onConflict} ${updates.join(', ')}`;
|
||||
|
||||
queries.push({ query, values });
|
||||
}
|
||||
|
||||
returnData = await runQueries(queries);
|
||||
|
||||
return returnData;
|
||||
}
|
9
packages/nodes-base/nodes/MySql/v2/actions/node.type.ts
Normal file
9
packages/nodes-base/nodes/MySql/v2/actions/node.type.ts
Normal file
|
@ -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'>;
|
66
packages/nodes-base/nodes/MySql/v2/actions/router.ts
Normal file
66
packages/nodes-base/nodes/MySql/v2/actions/router.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import type { INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions } from 'n8n-core';
|
||||
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
import type { MySqlType } from './node.type';
|
||||
import type { QueryRunner } from '../helpers/interfaces';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
|
||||
import { createPool } from '../transport';
|
||||
import { configureQueryRunner } from '../helpers/utils';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const resource = this.getNodeParameter<MySqlType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
const nodeOptions = this.getNodeParameter('options', 0);
|
||||
|
||||
const credentials = await this.getCredentials('mySql');
|
||||
|
||||
let sshClient: Client | undefined = undefined;
|
||||
|
||||
if (credentials.sshTunnel) {
|
||||
sshClient = new Client();
|
||||
}
|
||||
const pool = await createPool(credentials, nodeOptions, sshClient);
|
||||
|
||||
const runQueries: QueryRunner = configureQueryRunner.call(this, nodeOptions, pool);
|
||||
|
||||
const mysqlNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as MySqlType;
|
||||
|
||||
try {
|
||||
switch (mysqlNodeData.resource) {
|
||||
case 'database':
|
||||
const items = this.getInputData();
|
||||
|
||||
returnData = await database[mysqlNodeData.operation].execute.call(
|
||||
this,
|
||||
items,
|
||||
runQueries,
|
||||
nodeOptions,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
return this.prepareOutputData(returnData);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'MySQL',
|
||||
name: 'mySql',
|
||||
icon: 'file:mysql.svg',
|
||||
group: ['input'],
|
||||
version: 2,
|
||||
subtitle: '={{ $parameter["operation"] }}',
|
||||
description: 'Get, add and update data in MySQL',
|
||||
defaults: {
|
||||
name: 'MySQL',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
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,
|
||||
],
|
||||
};
|
28
packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts
Normal file
28
packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import type mysql2 from 'mysql2/promise';
|
||||
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
export type Mysql2Connection = mysql2.Connection;
|
||||
export type Mysql2Pool = mysql2.Pool;
|
||||
export type Mysql2OkPacket = mysql2.OkPacket;
|
||||
|
||||
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;
|
414
packages/nodes-base/nodes/MySql/v2/helpers/utils.ts
Normal file
414
packages/nodes-base/nodes/MySql/v2/helpers/utils.ts
Normal file
|
@ -0,0 +1,414 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
NodeExecutionWithMetadata,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeOperationError, deepCopy } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
Mysql2Pool,
|
||||
QueryMode,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
SortRule,
|
||||
WhereClause,
|
||||
} from './interfaces';
|
||||
|
||||
import { BATCH_MODE } from './interfaces';
|
||||
|
||||
export function copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[] {
|
||||
// Prepare the data to insert and copy it to be returned
|
||||
let newItem: IDataObject;
|
||||
return items.map((item) => {
|
||||
newItem = {};
|
||||
for (const property of properties) {
|
||||
if (item.json[property] === undefined) {
|
||||
newItem[property] = null;
|
||||
} else {
|
||||
newItem[property] = deepCopy(item.json[property]);
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
|
||||
export const prepareQueryAndReplacements = (rawQuery: string, replacements?: QueryValues) => {
|
||||
if (replacements === undefined) {
|
||||
return { query: rawQuery, values: [] };
|
||||
}
|
||||
// in UI for replacements we use syntax identical to Postgres Query Replacement, but we need to convert it to mysql2 replacement syntax
|
||||
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, `\`${replacements[matchIndex]}\``);
|
||||
} 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[],
|
||||
) {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
if (options.detailedOutput) {
|
||||
response.forEach((entry, index) => {
|
||||
const item = {
|
||||
sql: statements[index],
|
||||
data: entry,
|
||||
};
|
||||
|
||||
const executionData = constructExecutionHelper(wrapData(item), {
|
||||
itemData: { item: index },
|
||||
});
|
||||
|
||||
returnData.push(...executionData);
|
||||
});
|
||||
} else {
|
||||
response
|
||||
.filter((entry) => Array.isArray(entry))
|
||||
.forEach((entry, index) => {
|
||||
const executionData = constructExecutionHelper(wrapData(entry), {
|
||||
itemData: { item: index },
|
||||
});
|
||||
|
||||
returnData.push(...executionData);
|
||||
});
|
||||
}
|
||||
|
||||
if (!returnData.length) {
|
||||
returnData.push({ json: { success: true } });
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function configureQueryRunner(
|
||||
this: IExecuteFunctions,
|
||||
options: IDataObject,
|
||||
pool: Mysql2Pool,
|
||||
) {
|
||||
return async (queries: QueryWithValues[]) => {
|
||||
if (queries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const mode = (options.queryBatching as QueryMode) || BATCH_MODE.SINGLE;
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
if (mode === BATCH_MODE.SINGLE) {
|
||||
const formatedQueries = queries.map(({ query, values }) => connection.format(query, values));
|
||||
try {
|
||||
//releasing connection after formating queries, otherwise pool.query() will fail with timeout
|
||||
connection.release();
|
||||
|
||||
let singleQuery = '';
|
||||
if (formatedQueries.length > 1) {
|
||||
singleQuery = formatedQueries.map((query) => query.trim().replace(/;$/, '')).join(';');
|
||||
} else {
|
||||
singleQuery = formatedQueries[0];
|
||||
}
|
||||
|
||||
let response: IDataObject | IDataObject[] = (
|
||||
await pool.query(singleQuery)
|
||||
)[0] as unknown as IDataObject;
|
||||
|
||||
if (!response) return [];
|
||||
|
||||
const statements = singleQuery
|
||||
.replace(/\n/g, '')
|
||||
.split(';')
|
||||
.filter((statement) => statement !== '');
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
if (statements.length === 1) response = [response];
|
||||
} else {
|
||||
response = [response];
|
||||
}
|
||||
|
||||
returnData.push(
|
||||
...prepareOutput(response, options, statements, this.helpers.constructExecutionMetaData),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, 0, formatedQueries);
|
||||
|
||||
if (!this.continueOnFail()) throw error;
|
||||
returnData.push({ json: { message: error.message, error: { ...error } } });
|
||||
}
|
||||
} else {
|
||||
if (mode === BATCH_MODE.INDEPENDENTLY) {
|
||||
let formatedQuery = '';
|
||||
for (const [index, queryWithValues] of queries.entries()) {
|
||||
try {
|
||||
const { query, values } = queryWithValues;
|
||||
formatedQuery = connection.format(query, values);
|
||||
const statements = formatedQuery.split(';').map((q) => q.trim());
|
||||
|
||||
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.push(
|
||||
...prepareOutput(
|
||||
responses,
|
||||
options,
|
||||
statements,
|
||||
this.helpers.constructExecutionMetaData,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, index, [formatedQuery]);
|
||||
|
||||
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 formatedQuery = '';
|
||||
for (const [index, queryWithValues] of queries.entries()) {
|
||||
try {
|
||||
const { query, values } = queryWithValues;
|
||||
formatedQuery = connection.format(query, values);
|
||||
const statements = formatedQuery.split(';').map((q) => q.trim());
|
||||
|
||||
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.push(
|
||||
...prepareOutput(
|
||||
responses,
|
||||
options,
|
||||
statements,
|
||||
this.helpers.constructExecutionMetaData,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = parseMySqlError.call(this, err, index, [formatedQuery]);
|
||||
|
||||
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') {
|
||||
valueReplacement = ' ?';
|
||||
values.push(clause.value);
|
||||
}
|
||||
|
||||
const operator = index === clauses.length - 1 ? '' : ` ${combineWith}`;
|
||||
|
||||
whereQuery += ` \`${clause.column}\` ${clause.condition}${valueReplacement}${operator}`;
|
||||
});
|
||||
|
||||
return [`${query}${whereQuery}`, replacements.concat(...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 ? '' : ',';
|
||||
|
||||
orderByQuery += ` \`${rule.column}\` ${rule.direction}${endWith}`;
|
||||
});
|
||||
|
||||
return [`${query}${orderByQuery}`, replacements.concat(...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;
|
||||
}
|
44
packages/nodes-base/nodes/MySql/v2/methods/credentialTest.ts
Normal file
44
packages/nodes-base/nodes/MySql/v2/methods/credentialTest.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
INodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { createPool } from '../transport';
|
||||
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
export async function mysqlConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as ICredentialDataDecryptedObject;
|
||||
|
||||
let sshClient: Client | undefined = undefined;
|
||||
|
||||
if (credentials.sshTunnel) {
|
||||
sshClient = new Client();
|
||||
}
|
||||
const pool = await createPool(credentials, {}, sshClient);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
} finally {
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
}
|
3
packages/nodes-base/nodes/MySql/v2/methods/index.ts
Normal file
3
packages/nodes-base/nodes/MySql/v2/methods/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * as credentialTest from './credentialTest';
|
||||
export * as listSearch from './listSearch';
|
||||
export * as loadOptions from './loadOptions';
|
44
packages/nodes-base/nodes/MySql/v2/methods/listSearch.ts
Normal file
44
packages/nodes-base/nodes/MySql/v2/methods/listSearch.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { IDataObject, ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
export async function searchTables(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials('mySql');
|
||||
|
||||
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
|
||||
|
||||
let sshClient: Client | undefined = undefined;
|
||||
|
||||
if (credentials.sshTunnel) {
|
||||
sshClient = new Client();
|
||||
}
|
||||
const pool = await createPool(credentials, nodeOptions, sshClient);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const query = 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ?';
|
||||
const values = [credentials.database as string];
|
||||
|
||||
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 };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
}
|
64
packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts
Normal file
64
packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
import { createPool } from '../transport';
|
||||
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
export async function getColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const credentials = await this.getCredentials('mySql');
|
||||
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
|
||||
|
||||
let sshClient: Client | undefined = undefined;
|
||||
|
||||
if (credentials.sshTunnel) {
|
||||
sshClient = new Client();
|
||||
}
|
||||
const pool = await createPool(credentials, nodeOptions, sshClient);
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const table = this.getNodeParameter('table', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columns = (
|
||||
await connection.query(
|
||||
`SHOW COLUMNS FROM \`${table}\` FROM \`${credentials.database as string}\``,
|
||||
)
|
||||
)[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
|
||||
}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
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);
|
||||
}
|
139
packages/nodes-base/nodes/MySql/v2/transport/index.ts
Normal file
139
packages/nodes-base/nodes/MySql/v2/transport/index.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import mysql2 from 'mysql2/promise';
|
||||
import type { Client, ConnectConfig } from 'ssh2';
|
||||
import { rm, writeFile } from 'fs/promises';
|
||||
|
||||
import { file } from 'tmp-promise';
|
||||
import type { Mysql2Pool } from '../helpers/interfaces';
|
||||
|
||||
async function createSshConnectConfig(credentials: IDataObject) {
|
||||
if (credentials.sshAuthenticateWith === 'password') {
|
||||
return {
|
||||
host: credentials.sshHost as string,
|
||||
port: credentials.sshPort as number,
|
||||
username: credentials.sshUser as string,
|
||||
password: credentials.sshPassword as string,
|
||||
} as ConnectConfig;
|
||||
} else {
|
||||
const { path } = await file({ prefix: 'n8n-ssh-' });
|
||||
await writeFile(path, credentials.privateKey as string);
|
||||
|
||||
const options: ConnectConfig = {
|
||||
host: credentials.host as string,
|
||||
username: credentials.username as string,
|
||||
port: credentials.port as number,
|
||||
privateKey: path,
|
||||
};
|
||||
|
||||
if (credentials.passphrase) {
|
||||
options.passphrase = credentials.passphrase as string;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPool(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
options?: IDataObject,
|
||||
sshClient?: Client,
|
||||
): Promise<Mysql2Pool> {
|
||||
if (credentials === undefined) {
|
||||
throw new Error('Credentials not selected, select or add new credentials');
|
||||
}
|
||||
const {
|
||||
ssl,
|
||||
caCertificate,
|
||||
clientCertificate,
|
||||
clientPrivateKey,
|
||||
sshTunnel,
|
||||
sshHost,
|
||||
sshUser,
|
||||
sshPassword,
|
||||
sshPort,
|
||||
sshMysqlPort,
|
||||
privateKey,
|
||||
passphrase,
|
||||
sshAuthenticateWith,
|
||||
...baseCredentials
|
||||
} = credentials;
|
||||
|
||||
if (ssl) {
|
||||
baseCredentials.ssl = {};
|
||||
|
||||
if (caCertificate) {
|
||||
baseCredentials.ssl.ca = caCertificate;
|
||||
}
|
||||
|
||||
if (clientCertificate || clientPrivateKey) {
|
||||
baseCredentials.ssl.cert = clientCertificate;
|
||||
baseCredentials.ssl.key = clientPrivateKey;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionOptions: mysql2.ConnectionOptions = {
|
||||
...baseCredentials,
|
||||
multipleStatements: true,
|
||||
supportBigNumbers: 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 (!sshTunnel) {
|
||||
return mysql2.createPool(connectionOptions);
|
||||
} else {
|
||||
if (!sshClient) {
|
||||
throw new Error('SSH Tunnel is enabled but no SSH Client was provided');
|
||||
}
|
||||
|
||||
const tunnelConfig = await createSshConnectConfig(credentials);
|
||||
|
||||
const forwardConfig = {
|
||||
srcHost: '127.0.0.1',
|
||||
srcPort: sshMysqlPort as number,
|
||||
dstHost: credentials.host as string,
|
||||
dstPort: credentials.port as number,
|
||||
};
|
||||
|
||||
if (sshAuthenticateWith === 'privateKey') {
|
||||
sshClient.on('end', async () => {
|
||||
await rm(tunnelConfig.privateKey as string);
|
||||
});
|
||||
}
|
||||
|
||||
const poolSetup = new Promise<mysql2.Pool>((resolve, reject) => {
|
||||
sshClient
|
||||
.on('ready', () => {
|
||||
sshClient.forwardOut(
|
||||
forwardConfig.srcHost,
|
||||
forwardConfig.srcPort,
|
||||
forwardConfig.dstHost,
|
||||
forwardConfig.dstPort,
|
||||
(err, stream) => {
|
||||
if (err) reject(err);
|
||||
const updatedDbServer = {
|
||||
...connectionOptions,
|
||||
stream,
|
||||
};
|
||||
const connection = mysql2.createPool(updatedDbServer);
|
||||
resolve(connection);
|
||||
},
|
||||
);
|
||||
})
|
||||
.connect(tunnelConfig);
|
||||
});
|
||||
|
||||
return poolSetup;
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
|
||||
import { BinaryDataManager, Credentials } from 'n8n-core';
|
||||
import { BinaryDataManager, Credentials, constructExecutionMetaData } from 'n8n-core';
|
||||
import {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
IDataObject,
|
||||
IDeferredPromise,
|
||||
IExecuteFunctions,
|
||||
IExecuteWorkflowInfo,
|
||||
IGetNodeParameterOptions,
|
||||
IHttpRequestHelper,
|
||||
IHttpRequestOptions,
|
||||
ILogger,
|
||||
|
@ -29,6 +31,7 @@ import { WorkflowTestData } from './types';
|
|||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { get } from 'lodash';
|
||||
|
||||
import { FAKE_CREDENTIALS_DATA } from './FakeCredentialsMap';
|
||||
|
||||
|
@ -328,3 +331,31 @@ export const getWorkflowFilenames = (dirname: string) => {
|
|||
|
||||
return workflows;
|
||||
};
|
||||
|
||||
export const createMockExecuteFunction = (
|
||||
nodeParameters: IDataObject,
|
||||
nodeMock: INode,
|
||||
continueBool = false,
|
||||
) => {
|
||||
const fakeExecuteFunction = {
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
_itemIndex: number,
|
||||
fallbackValue?: IDataObject | undefined,
|
||||
options?: IGetNodeParameterOptions | undefined,
|
||||
) {
|
||||
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
|
||||
return get(nodeParameters, parameter, fallbackValue);
|
||||
},
|
||||
getNode() {
|
||||
return nodeMock;
|
||||
},
|
||||
continueOnFail() {
|
||||
return continueBool;
|
||||
},
|
||||
helpers: {
|
||||
constructExecutionMetaData,
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
return fakeExecuteFunction;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue