diff --git a/packages/nodes-base/nodes/MySql/MySql.node.ts b/packages/nodes-base/nodes/MySql/MySql.node.ts index 426c1bff47..fac087686d 100644 --- a/packages/nodes-base/nodes/MySql/MySql.node.ts +++ b/packages/nodes-base/nodes/MySql/MySql.node.ts @@ -11,7 +11,7 @@ export class MySql extends VersionedNodeType { name: 'mySql', icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' }, group: ['input'], - defaultVersion: 2.4, + defaultVersion: 2.5, description: 'Get, add and update data in MySQL', parameterPane: 'wide', }; @@ -23,6 +23,7 @@ export class MySql extends VersionedNodeType { 2.2: new MySqlV2(baseDescription), 2.3: new MySqlV2(baseDescription), 2.4: new MySqlV2(baseDescription), + 2.5: new MySqlV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts b/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts index 0fb385488f..ae45c2012a 100644 --- a/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts +++ b/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts @@ -1,6 +1,7 @@ import type { INode } from 'n8n-workflow'; import type { SortRule, WhereClause } from '../../v2/helpers/interfaces'; +import * as utils from '../../v2/helpers/utils'; import { prepareQueryAndReplacements, wrapData, @@ -26,6 +27,7 @@ describe('Test MySql V2, prepareQueryAndReplacements', () => { it('should transform query and values', () => { const preparedQuery = prepareQueryAndReplacements( 'SELECT * FROM $1:name WHERE id = $2 AND name = $4 AND $3:name = 28', + 2.5, ['table', 15, 'age', 'Name'], ); expect(preparedQuery).toBeDefined(); @@ -36,6 +38,136 @@ describe('Test MySql V2, prepareQueryAndReplacements', () => { expect(preparedQuery.values[0]).toEqual(15); expect(preparedQuery.values[1]).toEqual('Name'); }); + + it('should not replace dollar amounts inside quoted strings', () => { + const preparedQuery = prepareQueryAndReplacements( + "INSERT INTO test_table(content) VALUES('This is for testing $60')", + 2.5, + [], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + "INSERT INTO test_table(content) VALUES('This is for testing $60')", + ); + expect(preparedQuery.values.length).toEqual(0); + }); + + it('should handle mixed parameters and dollar amounts in quotes', () => { + const preparedQuery = prepareQueryAndReplacements( + "INSERT INTO $1:name(content, price) VALUES('Product costs $60', $2)", + 2.5, + ['products', 59.99], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + "INSERT INTO `products`(content, price) VALUES('Product costs $60', ?)", + ); + expect(preparedQuery.values.length).toEqual(1); + expect(preparedQuery.values[0]).toEqual(59.99); + }); + + it('should handle parameters in double quotes', () => { + const preparedQuery = prepareQueryAndReplacements( + 'INSERT INTO test_table(content) VALUES("Price is $100 and $200")', + 2.5, + [], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + 'INSERT INTO test_table(content) VALUES("Price is $100 and $200")', + ); + expect(preparedQuery.values.length).toEqual(0); + }); + + it('should process parameters in correct order despite reverse processing', () => { + const preparedQuery = prepareQueryAndReplacements( + 'SELECT * FROM table WHERE col1 = $1 AND col2 = $2 AND col3 = $3 AND col4 = $4 AND col5 = $5', + 2.5, + ['value1', 'value2', 'value3', 'value4', 'value5'], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + 'SELECT * FROM table WHERE col1 = ? AND col2 = ? AND col3 = ? AND col4 = ? AND col5 = ?', + ); + expect(preparedQuery.values.length).toEqual(5); + expect(preparedQuery.values[0]).toEqual('value1'); + expect(preparedQuery.values[1]).toEqual('value2'); + expect(preparedQuery.values[2]).toEqual('value3'); + expect(preparedQuery.values[3]).toEqual('value4'); + expect(preparedQuery.values[4]).toEqual('value5'); + }); + + it('should handle escaped single quotes correctly', () => { + const preparedQuery = prepareQueryAndReplacements( + "INSERT INTO test_table(content) VALUES('Don''t replace $1 here')", + 2.5, + ['should_not_appear', 123], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + "INSERT INTO test_table(content) VALUES('Don''t replace $1 here')", + ); + }); + + it('should handle escaped double quotes correctly', () => { + const preparedQuery = prepareQueryAndReplacements( + "INSERT INTO test_table(content) VALUES('Don\"'t replace $1 here')", + 2.5, + ['should_not_appear', 123], + ); + expect(preparedQuery).toBeDefined(); + expect(preparedQuery.query).toEqual( + "INSERT INTO test_table(content) VALUES('Don\"'t replace $1 here')", + ); + }); + + it('should use legacy processing for versions < 2.5', () => { + const legacySpy = jest.spyOn(utils, 'prepareQueryLegacy'); + + prepareQueryAndReplacements('SELECT * FROM $1:name WHERE id = $2', 2.4, ['users', 123]); + + expect(legacySpy).toHaveBeenCalledWith('SELECT * FROM $1:name WHERE id = $2', ['users', 123]); + + legacySpy.mockRestore(); + }); + + it('should use new processing for versions >= 2.5', () => { + const legacySpy = jest.spyOn(utils, 'prepareQueryLegacy'); + + prepareQueryAndReplacements('SELECT * FROM $1:name WHERE id = $2', 2.5, ['users', 123]); + + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it('should throw error when parameter is referenced but no replacement value provided', () => { + expect(() => { + prepareQueryAndReplacements( + 'SELECT * FROM users WHERE id = $4', + 2.5, + ['value1', 'value2'], // Only 2 values but query references $4 + ); + }).toThrow('Parameter $4 referenced in query but no replacement value provided at index 4'); + }); + + it('should throw error when multiple parameters are missing replacement values', () => { + expect(() => { + prepareQueryAndReplacements( + 'SELECT * FROM users WHERE id = $3 AND name = $5', + 2.5, + ['value1'], // Only 1 value but query references $3 and $5 + ); + }).toThrow('Parameter $3 referenced in query but no replacement value provided at index 3'); + }); + + it('should not throw error when all referenced parameters have replacement values', () => { + expect(() => { + prepareQueryAndReplacements( + 'SELECT * FROM users WHERE id = $1 AND name = $2', + 2.5, + ['123', 'John'], // Correct number of values + ); + }).not.toThrow(); + }); }); describe('Test MySql V2, wrapData', () => { diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/executeQuery.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/executeQuery.operation.ts index 8991f4590e..e394248df6 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/executeQuery.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/executeQuery.operation.ts @@ -61,6 +61,8 @@ export async function execute( const options = this.getNodeParameter('options', i, {}); + const nodeVersion = Number(nodeOptions.nodeVersion); + let values; let queryReplacement = options.queryReplacement || []; @@ -78,7 +80,7 @@ export async function execute( ); } - const preparedQuery = prepareQueryAndReplacements(rawQuery, values); + const preparedQuery = prepareQueryAndReplacements(rawQuery, nodeVersion, values); if ((nodeOptions.nodeVersion as number) >= 2.3) { const parsedNumbers = preparedQuery.values.map((value) => { diff --git a/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts index ff41a574ca..cdd7121ed1 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts @@ -8,7 +8,7 @@ export const versionDescription: INodeTypeDescription = { name: 'mySql', icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' }, group: ['input'], - version: [2, 2.1, 2.2, 2.3, 2.4], + version: [2, 2.1, 2.2, 2.3, 2.4, 2.5], subtitle: '={{ $parameter["operation"] }}', description: 'Get, add and update data in MySQL', defaults: { diff --git a/packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts b/packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts index ca6ae5c8d1..ef95f65032 100644 --- a/packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts +++ b/packages/nodes-base/nodes/MySql/v2/helpers/interfaces.ts @@ -46,3 +46,10 @@ export type MysqlNodeCredentials = { connectTimeout: number; } & WithSSL & WithSSHTunnel; + +export type ParameterMatch = { + match: string; + index: number; + paramNumber: string; + isName: boolean; +}; diff --git a/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts b/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts index 7d7bbda262..392c7f755d 100644 --- a/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts @@ -10,6 +10,7 @@ import { NodeOperationError } from 'n8n-workflow'; import type { Mysql2Pool, + ParameterMatch, QueryMode, QueryValues, QueryWithValues, @@ -35,11 +36,119 @@ export function escapeSqlIdentifier(identifier: string): string { .join('.'); } -export const prepareQueryAndReplacements = (rawQuery: string, replacements?: QueryValues) => { +function findParameterMatches(rawQuery: string, regex: RegExp): ParameterMatch[] { + const matches: ParameterMatch[] = []; + let match: RegExpExecArray | null; + + while ((match = regex.exec(rawQuery)) !== null) { + matches.push({ + match: match[0], + index: match.index, + paramNumber: match[1], + isName: match[0].includes(':name'), + }); + } + + return matches; +} + +function isInsideQuotes(rawQuery: string, index: number): boolean { + const beforeMatch = rawQuery.substring(0, index); + const singleQuoteCount = (beforeMatch.match(/'/g) || []).length; + const doubleQuoteCount = (beforeMatch.match(/"/g) || []).length; + return singleQuoteCount % 2 !== 0 || doubleQuoteCount % 2 !== 0; +} + +function filterValidMatches(matches: ParameterMatch[], rawQuery: string): ParameterMatch[] { + return matches.filter(({ index }) => !isInsideQuotes(rawQuery, index)); +} + +function processParameterReplacements( + query: string, + validMatches: ParameterMatch[], + replacements: QueryValues, +): string { + let processedQuery = query; + + for (const { match: matchStr, paramNumber, isName } of validMatches.reverse()) { + const matchIndex = Number(paramNumber) - 1; + + if (matchIndex >= 0 && matchIndex < replacements.length) { + if (isName) { + processedQuery = processedQuery.replace( + matchStr, + escapeSqlIdentifier(replacements[matchIndex].toString()), + ); + } else { + processedQuery = processedQuery.replace(matchStr, '?'); + } + } + } + + return processedQuery; +} + +function extractValuesFromMatches( + validMatches: ParameterMatch[], + replacements: QueryValues, +): QueryValues { + const nonNameMatches = validMatches.filter((match) => !match.isName); + nonNameMatches.sort((a, b) => Number(a.paramNumber) - Number(b.paramNumber)); + + const values: QueryValues = []; + for (const { paramNumber } of nonNameMatches) { + const matchIndex = Number(paramNumber) - 1; + if (matchIndex >= 0 && matchIndex < replacements.length) { + values.push(replacements[matchIndex]); + } + } + + return values; +} + +function validateReferencedParameters( + validMatches: ParameterMatch[], + replacements: QueryValues, +): void { + for (const match of validMatches) { + const paramIndex = Number(match.paramNumber) - 1; + if (paramIndex >= replacements.length || paramIndex < 0) { + throw new Error( + `Parameter $${match.paramNumber} referenced in query but no replacement value provided at index ${paramIndex + 1}`, + ); + } + } +} + +export const prepareQueryAndReplacements = ( + rawQuery: string, + nodeVersion: number, + replacements?: QueryValues, +) => { if (replacements === undefined) { return { query: rawQuery, values: [] }; } - // in UI for replacements we use syntax identical to Postgres Query Replacement, but we need to convert it to mysql2 replacement syntax + + if (nodeVersion >= 2.5) { + const regex = /\$(\d+)(?::name)?/g; + const matches = findParameterMatches(rawQuery, regex); + const validMatches = filterValidMatches(matches, rawQuery); + + validateReferencedParameters(validMatches, replacements); + + const query = processParameterReplacements(rawQuery, validMatches, replacements); + const values = extractValuesFromMatches(validMatches, replacements); + + return { query, values }; + } + + return prepareQueryLegacy(rawQuery, replacements); +}; + +export const prepareQueryLegacy = ( + rawQuery: string, + replacements: QueryValues, +): QueryWithValues => { let query: string = rawQuery; const values: QueryValues = [];