first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import type { IResult } from 'mssql';
|
||||
import mssql from 'mssql';
|
||||
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
|
||||
import { chunk, flatten } from '@utils/utilities';
|
||||
|
||||
import type { ITables, OperationInputData } from './interfaces';
|
||||
|
||||
/**
|
||||
* Returns a copy of the item which only contains the json data and
|
||||
* of that only the defined properties
|
||||
*
|
||||
* @param {INodeExecutionData} item The item to copy
|
||||
* @param {string[]} properties The properties it should include
|
||||
*/
|
||||
export function copyInputItem(item: INodeExecutionData, properties: string[]): IDataObject {
|
||||
// Prepare the data to insert and copy it to be returned
|
||||
const newItem: IDataObject = {};
|
||||
for (const property of properties) {
|
||||
if (item.json[property] === undefined) {
|
||||
newItem[property] = null;
|
||||
} else {
|
||||
newItem[property] = deepCopy(item.json[property]);
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ITables with the columns for the operations
|
||||
*
|
||||
* @param {INodeExecutionData[]} items The items to extract the tables/columns for
|
||||
* @param {function} getNodeParam getter for the Node's Parameters
|
||||
*/
|
||||
export function createTableStruct(
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
getNodeParam: Function,
|
||||
items: INodeExecutionData[],
|
||||
additionalProperties: string[] = [],
|
||||
keyName?: string,
|
||||
): ITables {
|
||||
return items.reduce((tables, item, index) => {
|
||||
const table = getNodeParam('table', index) as string;
|
||||
const columnString = getNodeParam('columns', index) as string;
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
const itemCopy = copyInputItem(item, columns.concat(additionalProperties));
|
||||
const keyParam = keyName ? (getNodeParam(keyName, index) as string) : undefined;
|
||||
if (tables[table] === undefined) {
|
||||
tables[table] = {};
|
||||
}
|
||||
if (tables[table][columnString] === undefined) {
|
||||
tables[table][columnString] = [];
|
||||
}
|
||||
if (keyName) {
|
||||
itemCopy[keyName] = keyParam;
|
||||
}
|
||||
tables[table][columnString].push(itemCopy);
|
||||
return tables;
|
||||
}, {} as ITables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a queue of queries on given ITables.
|
||||
*
|
||||
* @param {ITables} tables The ITables to be processed.
|
||||
* @param {function} buildQueryQueue function that builds the queue of promises
|
||||
*/
|
||||
export async function executeQueryQueue(
|
||||
tables: ITables,
|
||||
buildQueryQueue: (data: OperationInputData) => Array<Promise<object>>,
|
||||
): Promise<any[]> {
|
||||
return await Promise.all(
|
||||
Object.keys(tables).map(async (table) => {
|
||||
const columnsResults = Object.keys(tables[table]).map(async (columnString) => {
|
||||
return await Promise.all(
|
||||
buildQueryQueue({
|
||||
table,
|
||||
columnString,
|
||||
items: tables[table][columnString],
|
||||
}),
|
||||
);
|
||||
});
|
||||
return await Promise.all(columnsResults);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatColumns(columns: string) {
|
||||
return columns
|
||||
.split(',')
|
||||
.map((column) => escapeIdentifier(column.trim()))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function configurePool(credentials: IDataObject) {
|
||||
const config = {
|
||||
server: credentials.server as string,
|
||||
port: credentials.port as number,
|
||||
database: credentials.database as string,
|
||||
user: credentials.user as string,
|
||||
password: credentials.password as string,
|
||||
domain: credentials.domain ? (credentials.domain as string) : undefined,
|
||||
connectionTimeout: credentials.connectTimeout as number,
|
||||
requestTimeout: credentials.requestTimeout as number,
|
||||
options: {
|
||||
encrypt: credentials.tls as boolean,
|
||||
enableArithAbort: false,
|
||||
tdsVersion: credentials.tdsVersion as string,
|
||||
trustServerCertificate: credentials.allowUnauthorizedCerts as boolean,
|
||||
},
|
||||
};
|
||||
|
||||
return new mssql.ConnectionPool(config);
|
||||
}
|
||||
|
||||
export function escapeIdentifier(identifier: string) {
|
||||
if (identifier.startsWith('[') && identifier.endsWith(']')) {
|
||||
identifier = identifier.slice(1, -1);
|
||||
}
|
||||
|
||||
return `[${identifier.replaceAll(']', ']]')}]`;
|
||||
}
|
||||
|
||||
export function escapeTableName(table: string) {
|
||||
table = table.trim();
|
||||
if (table.startsWith('[') && table.endsWith(']')) {
|
||||
return (
|
||||
table
|
||||
// remove outer brackets
|
||||
.slice(1, -1)
|
||||
// split by inner parts, for example when database name is provided in form of [db].[dbo].[receipts]
|
||||
.split('].[')
|
||||
.map((part) => escapeIdentifier(`[${part}]`))
|
||||
.join('.')
|
||||
);
|
||||
}
|
||||
|
||||
return escapeIdentifier(table);
|
||||
}
|
||||
|
||||
const MSSQL_PARAMETER_LIMIT = 2100;
|
||||
|
||||
export function mssqlChunk(rows: IDataObject[]): IDataObject[][] {
|
||||
const chunked: IDataObject[][] = [[]];
|
||||
let currentParamCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const rowValues = Object.values(row);
|
||||
const valueCount = rowValues.length;
|
||||
|
||||
if (currentParamCount + valueCount >= MSSQL_PARAMETER_LIMIT) {
|
||||
chunked.push([]);
|
||||
currentParamCount = 0;
|
||||
}
|
||||
|
||||
chunked[chunked.length - 1].push(row);
|
||||
|
||||
currentParamCount += valueCount;
|
||||
}
|
||||
|
||||
return chunked;
|
||||
}
|
||||
|
||||
export async function insertOperation(tables: ITables, pool: mssql.ConnectionPool) {
|
||||
return await executeQueryQueue(
|
||||
tables,
|
||||
({ table, columnString, items }: OperationInputData): Array<Promise<object>> => {
|
||||
return mssqlChunk(items).map(async (insertValues) => {
|
||||
const request = pool.request();
|
||||
|
||||
const valuesPlaceholder = [];
|
||||
|
||||
for (const [rIndex, entry] of insertValues.entries()) {
|
||||
const row = Object.values(entry);
|
||||
valuesPlaceholder.push(`(${row.map((_, vIndex) => `@r${rIndex}v${vIndex}`).join(', ')})`);
|
||||
for (const [vIndex, value] of row.entries()) {
|
||||
request.input(`r${rIndex}v${vIndex}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
const query = `INSERT INTO ${escapeTableName(table)} (${formatColumns(
|
||||
columnString,
|
||||
)}) VALUES ${valuesPlaceholder.join(', ')};`;
|
||||
|
||||
return await request.query(query);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateOperation(tables: ITables, pool: mssql.ConnectionPool) {
|
||||
return await executeQueryQueue(
|
||||
tables,
|
||||
({ table, columnString, items }: OperationInputData): Array<Promise<object>> => {
|
||||
return items.map(async (item) => {
|
||||
const request = pool.request();
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
|
||||
const setValues: string[] = [];
|
||||
const updateKey = item.updateKey as string;
|
||||
const condition = `${escapeIdentifier(updateKey)} = @condition`;
|
||||
request.input('condition', item[updateKey]);
|
||||
|
||||
for (const [index, col] of columns.entries()) {
|
||||
setValues.push(`${escapeIdentifier(col)} = @v${index}`);
|
||||
request.input(`v${index}`, item[col]);
|
||||
}
|
||||
|
||||
const query = `UPDATE ${escapeTableName(table)} SET ${setValues.join(
|
||||
', ',
|
||||
)} WHERE ${condition};`;
|
||||
|
||||
return await request.query(query);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteOperation(tables: ITables, pool: mssql.ConnectionPool) {
|
||||
const queriesResults = await Promise.all(
|
||||
Object.keys(tables).map(async (table) => {
|
||||
const deleteKeyResults = Object.keys(tables[table]).map(async (deleteKey) => {
|
||||
const deleteItemsList = chunk(
|
||||
tables[table][deleteKey].map((item) =>
|
||||
copyInputItem(item as INodeExecutionData, [deleteKey]),
|
||||
),
|
||||
1000,
|
||||
);
|
||||
const queryQueue = deleteItemsList.map(async (deleteValues) => {
|
||||
const request = pool.request();
|
||||
const valuesPlaceholder: string[] = [];
|
||||
|
||||
for (const [index, entry] of deleteValues.entries()) {
|
||||
valuesPlaceholder.push(`@v${index}`);
|
||||
request.input(`v${index}`, entry[deleteKey]);
|
||||
}
|
||||
|
||||
const query = `DELETE FROM ${escapeTableName(
|
||||
table,
|
||||
)} WHERE ${escapeIdentifier(deleteKey)} IN (${valuesPlaceholder.join(', ')});`;
|
||||
|
||||
return await request.query(query);
|
||||
});
|
||||
return await Promise.all(queryQueue);
|
||||
});
|
||||
return await Promise.all(deleteKeyResults);
|
||||
}),
|
||||
);
|
||||
|
||||
return flatten(queriesResults).reduce(
|
||||
(acc: number, resp: mssql.IResult<object>): number =>
|
||||
(acc += resp.rowsAffected.reduce((sum, val) => (sum += val))),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeSqlQueryAndPrepareResults(
|
||||
pool: mssql.ConnectionPool,
|
||||
rawQuery: string,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const rawResult: IResult<any> = await pool.request().query(rawQuery);
|
||||
const { recordsets, rowsAffected } = rawResult;
|
||||
if (Array.isArray(recordsets) && recordsets.length > 0) {
|
||||
const result: IDataObject[] = recordsets.length > 1 ? flatten(recordsets) : recordsets[0];
|
||||
|
||||
return result.map((entry) => ({
|
||||
json: entry,
|
||||
pairedItem: [{ item: itemIndex }],
|
||||
}));
|
||||
} else if (rowsAffected && rowsAffected.length > 0) {
|
||||
// Handle non-SELECT queries (e.g., INSERT, UPDATE, DELETE)
|
||||
return rowsAffected.map((affectedRows, idx) => ({
|
||||
json: {
|
||||
message: `Query ${idx + 1} executed successfully`,
|
||||
rowsAffected: affectedRows,
|
||||
},
|
||||
pairedItem: [{ item: itemIndex }],
|
||||
}));
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
json: { message: 'Query executed successfully, but no rows were affected' },
|
||||
pairedItem: [{ item: itemIndex }],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.microsoftSql",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development", "Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/microsoftsql/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.microsoftsql/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { IResult } from 'mssql';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type ICredentialDataDecryptedObject,
|
||||
type ICredentialsDecrypted,
|
||||
type ICredentialTestFunctions,
|
||||
type IDataObject,
|
||||
type INodeCredentialTestResult,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { flatten, generatePairedItemData, getResolvables } from '@utils/utilities';
|
||||
|
||||
import {
|
||||
configurePool,
|
||||
createTableStruct,
|
||||
deleteOperation,
|
||||
executeSqlQueryAndPrepareResults,
|
||||
insertOperation,
|
||||
updateOperation,
|
||||
} from './GenericFunctions';
|
||||
import type { ITables } from './interfaces';
|
||||
|
||||
export class MicrosoftSql implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Microsoft SQL',
|
||||
name: 'microsoftSql',
|
||||
icon: 'file:mssql.svg',
|
||||
group: ['input'],
|
||||
version: [1, 1.1],
|
||||
description: 'Get, add and update data in Microsoft SQL',
|
||||
defaults: {
|
||||
name: 'Microsoft SQL',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
parameterPane: 'wide',
|
||||
credentials: [
|
||||
{
|
||||
name: 'microsoftSql',
|
||||
required: true,
|
||||
testedBy: 'microsoftSqlConnectionTest',
|
||||
},
|
||||
],
|
||||
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',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete rows in database',
|
||||
action: 'Delete rows in database',
|
||||
},
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
sqlDialect: 'MSSQL',
|
||||
},
|
||||
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: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Name of the table in which to insert data to',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'id,name,description',
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for the new rows',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Name of the table in which to update data in',
|
||||
},
|
||||
{
|
||||
displayName: 'Update Key',
|
||||
name: 'updateKey',
|
||||
type: 'string',
|
||||
requiresDataPath: 'single',
|
||||
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',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// delete
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Name of the table in which to delete data',
|
||||
},
|
||||
{
|
||||
displayName: 'Delete Key',
|
||||
name: 'deleteKey',
|
||||
type: 'string',
|
||||
requiresDataPath: 'single',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
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 deleted. Normally that would be "id".',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
async microsoftSqlConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as ICredentialDataDecryptedObject;
|
||||
try {
|
||||
const pool = configurePool(credentials);
|
||||
await pool.connect();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials('microsoftSql');
|
||||
|
||||
let responseData: IDataObject | IDataObject[] = [];
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
const pairedItem = generatePairedItemData(items.length);
|
||||
|
||||
const pool = configurePool(credentials);
|
||||
try {
|
||||
await pool.connect();
|
||||
} catch (error) {
|
||||
void pool.close();
|
||||
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message }, pairedItem }]];
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
if (operation === 'executeQuery' && nodeVersion >= 1.1) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
let rawQuery = this.getNodeParameter('query', i) as string;
|
||||
|
||||
for (const resolvable of getResolvables(rawQuery)) {
|
||||
rawQuery = rawQuery.replace(
|
||||
resolvable,
|
||||
() => this.evaluateExpression(resolvable, i) as string,
|
||||
);
|
||||
}
|
||||
const results = await executeSqlQueryAndPrepareResults(pool, rawQuery, i);
|
||||
returnData = returnData.concat(results);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: [{ item: i }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await pool.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await pool.close();
|
||||
return [returnData];
|
||||
}
|
||||
try {
|
||||
if (operation === 'executeQuery') {
|
||||
let rawQuery = this.getNodeParameter('query', 0) as string;
|
||||
|
||||
for (const resolvable of getResolvables(rawQuery)) {
|
||||
rawQuery = rawQuery.replace(resolvable, this.evaluateExpression(resolvable, 0) as string);
|
||||
}
|
||||
|
||||
const { recordsets }: IResult<any[]> = await pool.request().query(rawQuery);
|
||||
|
||||
const result = recordsets.length > 1 ? flatten(recordsets) : recordsets[0];
|
||||
|
||||
responseData = result;
|
||||
}
|
||||
|
||||
if (operation === 'insert') {
|
||||
const tables = createTableStruct(this.getNodeParameter, items);
|
||||
|
||||
await insertOperation(tables, pool);
|
||||
|
||||
responseData = items;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const updateKeys = items.map(
|
||||
(_, index) => this.getNodeParameter('updateKey', index) as string,
|
||||
);
|
||||
|
||||
const tables = createTableStruct(
|
||||
this.getNodeParameter,
|
||||
items,
|
||||
['updateKey'].concat(updateKeys),
|
||||
'updateKey',
|
||||
);
|
||||
|
||||
await updateOperation(tables, pool);
|
||||
|
||||
responseData = items;
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
const tables = items.reduce((acc, item, index) => {
|
||||
const table = this.getNodeParameter('table', index) as string;
|
||||
const deleteKey = this.getNodeParameter('deleteKey', index) as string;
|
||||
if (acc[table] === undefined) {
|
||||
acc[table] = {};
|
||||
}
|
||||
if (acc[table][deleteKey] === undefined) {
|
||||
acc[table][deleteKey] = [];
|
||||
}
|
||||
acc[table][deleteKey].push(item);
|
||||
return acc;
|
||||
}, {} as ITables);
|
||||
|
||||
responseData = await deleteOperation(tables, pool);
|
||||
}
|
||||
|
||||
const itemData = generatePairedItemData(items.length);
|
||||
|
||||
returnData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData),
|
||||
{ itemData },
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
responseData = items;
|
||||
} else {
|
||||
await pool.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// shuts down the connection pool associated with the db object to allow the process to finish
|
||||
await pool.close();
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export interface ITables {
|
||||
[key: string]: {
|
||||
[key: string]: IDataObject[];
|
||||
};
|
||||
}
|
||||
|
||||
export type OperationInputData = {
|
||||
table: string;
|
||||
columnString: string;
|
||||
items: IDataObject[];
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,108 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import * as mssql from 'mssql';
|
||||
import { constructExecutionMetaData, returnJsonArray } from 'n8n-core';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { MicrosoftSql } from '../MicrosoftSql.node';
|
||||
|
||||
jest.mock('mssql');
|
||||
|
||||
function getMockedExecuteFunctions(overrides: Partial<IExecuteFunctions> = {}) {
|
||||
return mock<IExecuteFunctions>({
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
server: 'localhost',
|
||||
database: 'testdb',
|
||||
user: 'testuser',
|
||||
password: 'testpass',
|
||||
port: 1433,
|
||||
tls: false,
|
||||
allowUnauthorizedCerts: true,
|
||||
tdsVersion: '7_4',
|
||||
connectTimeout: 1000,
|
||||
requestTimeout: 10000,
|
||||
}),
|
||||
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
|
||||
getNode: jest.fn().mockReturnValue({ typeVersion: 1.1 }),
|
||||
continueOnFail: jest.fn().mockReturnValue(true),
|
||||
helpers: {
|
||||
constructExecutionMetaData,
|
||||
returnJsonArray,
|
||||
},
|
||||
evaluateExpression: jest.fn((_val) => _val),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('MicrosoftSql Node', () => {
|
||||
let mockedConnectionPool: jest.MockedClass<typeof mssql.ConnectionPool>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedConnectionPool = mssql.ConnectionPool as jest.MockedClass<typeof mssql.ConnectionPool>;
|
||||
});
|
||||
|
||||
test('handles connection error with continueOnFail', async () => {
|
||||
const fakeError = new Error('Connection failed');
|
||||
mockedConnectionPool.mockReturnValue(
|
||||
mock<mssql.ConnectionPool>({
|
||||
connect: jest.fn().mockRejectedValue(fakeError),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const node = new MicrosoftSql();
|
||||
const context = getMockedExecuteFunctions();
|
||||
const result = await node.execute.call(context);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'Connection failed' }, pairedItem: [{ item: 0 }] }]]);
|
||||
});
|
||||
|
||||
test('executes query on happy path', async () => {
|
||||
const queryResult = { recordsets: [[{ value: 1 }]] };
|
||||
const mockRequest = { query: jest.fn().mockResolvedValue(queryResult) };
|
||||
const mockPool = mock<mssql.ConnectionPool>({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn(),
|
||||
request: jest.fn().mockReturnValue(mockRequest),
|
||||
});
|
||||
|
||||
mockedConnectionPool.mockReturnValue(mockPool);
|
||||
|
||||
const node = new MicrosoftSql();
|
||||
const context = getMockedExecuteFunctions({
|
||||
getNodeParameter: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('executeQuery')
|
||||
.mockReturnValueOnce('SELECT 1 AS value'),
|
||||
});
|
||||
const result = await node.execute.call(context);
|
||||
|
||||
expect(result).toEqual([[{ json: { value: 1 }, pairedItem: [{ item: 0 }] }]]);
|
||||
expect(mockRequest.query).toHaveBeenCalledWith('SELECT 1 AS value');
|
||||
expect(mockPool.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('correctly resolves expressions (does not remove $ characters)', async () => {
|
||||
const queryResult = { recordsets: [[{ value: 1 }]] };
|
||||
const mockRequest = { query: jest.fn().mockResolvedValue(queryResult) };
|
||||
const mockPool = mock<mssql.ConnectionPool>({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn(),
|
||||
request: jest.fn().mockReturnValue(mockRequest),
|
||||
});
|
||||
|
||||
mockedConnectionPool.mockReturnValue(mockPool);
|
||||
|
||||
const node = new MicrosoftSql();
|
||||
const context = getMockedExecuteFunctions({
|
||||
getNodeParameter: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('executeQuery')
|
||||
.mockReturnValueOnce("SELECT '{{ '$$$' }}'"),
|
||||
});
|
||||
context.evaluateExpression.mockReturnValue('$$$');
|
||||
|
||||
await node.execute.call(context);
|
||||
|
||||
expect(mockRequest.query).toHaveBeenCalledWith("SELECT '$$$'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Request } from 'mssql';
|
||||
import type { IResult } from 'mssql';
|
||||
import type mssql from 'mssql';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
configurePool,
|
||||
deleteOperation,
|
||||
escapeIdentifier,
|
||||
escapeTableName,
|
||||
executeSqlQueryAndPrepareResults,
|
||||
insertOperation,
|
||||
mssqlChunk,
|
||||
updateOperation,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
describe('MSSQL tests', () => {
|
||||
let querySpy: jest.SpyInstance;
|
||||
let request: Request;
|
||||
|
||||
const assertParameters = (parameters: unknown[][] | IDataObject) => {
|
||||
if (Array.isArray(parameters)) {
|
||||
parameters.forEach((values, rowIndex) => {
|
||||
values.forEach((value, index) => {
|
||||
const received = (request.parameters[`r${rowIndex}v${index}`] as IDataObject).value;
|
||||
expect(received).toEqual(value);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
for (const key in parameters) {
|
||||
expect((request.parameters[key] as IDataObject).value).toEqual(parameters[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
querySpy = jest.spyOn(Request.prototype, 'query').mockImplementation(async function (
|
||||
this: Request,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
request = this;
|
||||
return {
|
||||
recordsets: [],
|
||||
recordset: [],
|
||||
output: {},
|
||||
rowsAffected: [0],
|
||||
} as unknown as IResult<unknown>;
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform insert operation', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
users: {
|
||||
'id, name, age, active': [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Sam',
|
||||
age: 31,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Jon',
|
||||
age: null,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: undefined,
|
||||
age: 25,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await insertOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
'INSERT INTO [users] ([id], [name], [age], [active]) VALUES (@r0v0, @r0v1, @r0v2, @r0v3), (@r1v0, @r1v1, @r1v2, @r1v3), (@r2v0, @r2v1, @r2v2, @r2v3);',
|
||||
);
|
||||
assertParameters([
|
||||
[1, 'Sam', 31, false],
|
||||
[3, 'Jon', null, true],
|
||||
[4, null, 25, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should perform insert operation with escaped identifiers', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
"users] set text='asdf' where id=1;": {
|
||||
'id, name, age, active]': [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Sam',
|
||||
age: 31,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await insertOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
"INSERT INTO [users]] set text='asdf' where id=1;] ([id], [name], [age], [active]]]) VALUES (@r0v0, @r0v1, @r0v2, @r0v3);",
|
||||
);
|
||||
assertParameters([[1, 'Sam', 31, false]]);
|
||||
});
|
||||
|
||||
it('should perform update operation', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
users: {
|
||||
'name, age, active': [
|
||||
{
|
||||
name: 'Greg',
|
||||
age: 43,
|
||||
active: 0,
|
||||
updateKey: 'id',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await updateOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
'UPDATE [users] SET [name] = @v0, [age] = @v1, [active] = @v2 WHERE [id] = @condition;',
|
||||
);
|
||||
assertParameters({
|
||||
v0: 'Greg',
|
||||
v1: 43,
|
||||
v2: 0,
|
||||
condition: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform update operation with escaped identifiers', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
"users] set text='asdf' where id=1;": {
|
||||
'name, age, active]': [
|
||||
{
|
||||
name: 'Greg',
|
||||
age: 43,
|
||||
active: 0,
|
||||
updateKey: 'id] -- -',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await updateOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
"UPDATE [users]] set text='asdf' where id=1;] SET [name] = @v0, [age] = @v1, [active]]] = @v2 WHERE [id]] -- -] = @condition;",
|
||||
);
|
||||
});
|
||||
|
||||
it('should perform update operation with enclosed key', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
users: {
|
||||
'name, age, active': [
|
||||
{
|
||||
name: 'Greg',
|
||||
age: 43,
|
||||
active: 0,
|
||||
updateKey: '[id]',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await updateOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
'UPDATE [users] SET [name] = @v0, [age] = @v1, [active] = @v2 WHERE [id] = @condition;',
|
||||
);
|
||||
});
|
||||
|
||||
it('should perform delete operation', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
users: {
|
||||
id: [
|
||||
{
|
||||
json: {
|
||||
id: 2,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await deleteOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith('DELETE FROM [users] WHERE [id] IN (@v0);');
|
||||
assertParameters({ v0: 2 });
|
||||
});
|
||||
|
||||
it('should perform delete operation with escaped identifiers', async () => {
|
||||
const pool = configurePool({});
|
||||
const tables = {
|
||||
"users] set text='asdf' where id=1;": {
|
||||
'id]': [
|
||||
{
|
||||
json: {
|
||||
id: 2,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await deleteOperation(tables, pool);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
"DELETE FROM [users]] set text='asdf' where id=1;] WHERE [id]]] IN (@v0);",
|
||||
);
|
||||
});
|
||||
|
||||
describe('mssqlChunk', () => {
|
||||
it('should chunk insert values correctly', () => {
|
||||
const chunks = mssqlChunk(
|
||||
new Array(3000)
|
||||
.fill(null)
|
||||
.map((_, index) => ({ id: index, name: 'John Doe', verified: true })),
|
||||
);
|
||||
expect(chunks.map((chunk) => chunk.length)).toEqual([699, 699, 699, 699, 204]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeSqlQueryAndPrepareResults', () => {
|
||||
it('should handle SELECT query with single record', async () => {
|
||||
querySpy.mockResolvedValueOnce({
|
||||
recordsets: [[{ id: 1, name: 'Test' }]] as any,
|
||||
recordset: [{ id: 1, name: 'Test', columns: [{ name: 'id' }, { name: 'name' }] }],
|
||||
rowsAffected: [1],
|
||||
output: {},
|
||||
} as unknown as IResult<unknown>);
|
||||
|
||||
const pool = { request: () => new Request() } as any as mssql.ConnectionPool;
|
||||
const result = await executeSqlQueryAndPrepareResults(pool, 'SELECT * FROM users', 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: [{ item: 0 }],
|
||||
},
|
||||
]);
|
||||
expect(querySpy).toHaveBeenCalledWith('SELECT * FROM users');
|
||||
});
|
||||
|
||||
it('should handle SELECT query with multiple records', async () => {
|
||||
querySpy.mockResolvedValueOnce({
|
||||
recordsets: [[{ id: 1 }], [{ name: 'Test' }]] as unknown,
|
||||
rowsAffected: [1, 1],
|
||||
output: {},
|
||||
} as unknown as IResult<unknown>);
|
||||
|
||||
const pool = { request: () => new Request() } as any as mssql.ConnectionPool;
|
||||
const result = await executeSqlQueryAndPrepareResults(pool, 'SELECT id; SELECT name', 1);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ json: { id: 1 }, pairedItem: [{ item: 1 }] },
|
||||
{ json: { name: 'Test' }, pairedItem: [{ item: 1 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-SELECT query', async () => {
|
||||
querySpy.mockResolvedValueOnce({
|
||||
recordsets: [],
|
||||
recordset: [],
|
||||
rowsAffected: [5],
|
||||
output: {},
|
||||
} as unknown as IResult<unknown>);
|
||||
|
||||
const pool = { request: () => new Request() } as any as mssql.ConnectionPool;
|
||||
const result = await executeSqlQueryAndPrepareResults(pool, 'UPDATE users SET active = 1', 2);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { message: 'Query 1 executed successfully', rowsAffected: 5 },
|
||||
pairedItem: [{ item: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle query with no affected rows', async () => {
|
||||
querySpy.mockResolvedValueOnce({
|
||||
recordsets: [],
|
||||
recordset: [],
|
||||
rowsAffected: [],
|
||||
output: {},
|
||||
} as unknown as IResult<unknown>);
|
||||
|
||||
const pool = { request: () => new Request() } as any as mssql.ConnectionPool;
|
||||
const result = await executeSqlQueryAndPrepareResults(
|
||||
pool,
|
||||
'DELETE FROM users WHERE id = 999',
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { message: 'Query executed successfully, but no rows were affected' },
|
||||
pairedItem: [{ item: 3 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error when query fails', async () => {
|
||||
const errorMessage = 'Database error';
|
||||
querySpy.mockRejectedValueOnce(new Error(errorMessage));
|
||||
|
||||
const pool = { request: () => new Request() } as any as mssql.ConnectionPool;
|
||||
await expect(executeSqlQueryAndPrepareResults(pool, 'INVALID SQL', 4)).rejects.toThrow(
|
||||
errorMessage,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeIdentifier', () => {
|
||||
it('keeps outer brackets', () => {
|
||||
expect(escapeIdentifier('[test]')).toEqual('[test]');
|
||||
});
|
||||
|
||||
it('escapes content while using outer brackets', () => {
|
||||
expect(escapeIdentifier('[test.hello]]')).toEqual('[test.hello]]]');
|
||||
});
|
||||
|
||||
it('escapes content while not using outer brackets', () => {
|
||||
expect(escapeIdentifier('test.hello]]')).toEqual('[test.hello]]]]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeTableName', () => {
|
||||
it('should escape table name correctly', () => {
|
||||
expect(escapeTableName('test')).toEqual('[test]');
|
||||
expect(escapeTableName('[test]')).toEqual('[test]');
|
||||
expect(escapeTableName('test.test')).toEqual('[test.test]');
|
||||
expect(escapeTableName('[test].[test]')).toEqual('[test].[test]');
|
||||
expect(escapeTableName('[test]--.ok].[[test]]')).toEqual('[test]]--.ok].[[test]]]');
|
||||
expect(escapeTableName("test] SET mytext='1' where id=2; -- -")).toEqual(
|
||||
"[test]] SET mytext='1' where id=2; -- -]",
|
||||
);
|
||||
expect(escapeTableName("[[test] (id,text) values(1,'123'); DROP TABLE users; -- ]")).toEqual(
|
||||
"[[test]] (id,text) values(1,'123'); DROP TABLE users; -- ]",
|
||||
);
|
||||
expect(escapeTableName('schema.[table]')).toEqual('[schema.[table]]]');
|
||||
expect(escapeTableName('[schema].table')).toEqual('[[schema]].table]');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user