fix(MySQL Node): Do not replace $ values with null (#17327)

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Dana
2025-07-24 16:53:52 +02:00
committed by GitHub
parent 251e892a09
commit 4b626e5282
6 changed files with 256 additions and 5 deletions

View File

@@ -11,7 +11,7 @@ export class MySql extends VersionedNodeType {
name: 'mySql', name: 'mySql',
icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' }, icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' },
group: ['input'], group: ['input'],
defaultVersion: 2.4, defaultVersion: 2.5,
description: 'Get, add and update data in MySQL', description: 'Get, add and update data in MySQL',
parameterPane: 'wide', parameterPane: 'wide',
}; };
@@ -23,6 +23,7 @@ export class MySql extends VersionedNodeType {
2.2: new MySqlV2(baseDescription), 2.2: new MySqlV2(baseDescription),
2.3: new MySqlV2(baseDescription), 2.3: new MySqlV2(baseDescription),
2.4: new MySqlV2(baseDescription), 2.4: new MySqlV2(baseDescription),
2.5: new MySqlV2(baseDescription),
}; };
super(nodeVersions, baseDescription); super(nodeVersions, baseDescription);

View File

@@ -1,6 +1,7 @@
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import type { SortRule, WhereClause } from '../../v2/helpers/interfaces'; import type { SortRule, WhereClause } from '../../v2/helpers/interfaces';
import * as utils from '../../v2/helpers/utils';
import { import {
prepareQueryAndReplacements, prepareQueryAndReplacements,
wrapData, wrapData,
@@ -26,6 +27,7 @@ describe('Test MySql V2, prepareQueryAndReplacements', () => {
it('should transform query and values', () => { it('should transform query and values', () => {
const preparedQuery = prepareQueryAndReplacements( const preparedQuery = prepareQueryAndReplacements(
'SELECT * FROM $1:name WHERE id = $2 AND name = $4 AND $3:name = 28', 'SELECT * FROM $1:name WHERE id = $2 AND name = $4 AND $3:name = 28',
2.5,
['table', 15, 'age', 'Name'], ['table', 15, 'age', 'Name'],
); );
expect(preparedQuery).toBeDefined(); expect(preparedQuery).toBeDefined();
@@ -36,6 +38,136 @@ describe('Test MySql V2, prepareQueryAndReplacements', () => {
expect(preparedQuery.values[0]).toEqual(15); expect(preparedQuery.values[0]).toEqual(15);
expect(preparedQuery.values[1]).toEqual('Name'); 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', () => { describe('Test MySql V2, wrapData', () => {

View File

@@ -61,6 +61,8 @@ export async function execute(
const options = this.getNodeParameter('options', i, {}); const options = this.getNodeParameter('options', i, {});
const nodeVersion = Number(nodeOptions.nodeVersion);
let values; let values;
let queryReplacement = options.queryReplacement || []; 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) { if ((nodeOptions.nodeVersion as number) >= 2.3) {
const parsedNumbers = preparedQuery.values.map((value) => { const parsedNumbers = preparedQuery.values.map((value) => {

View File

@@ -8,7 +8,7 @@ export const versionDescription: INodeTypeDescription = {
name: 'mySql', name: 'mySql',
icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' }, icon: { light: 'file:mysql.svg', dark: 'file:mysql.dark.svg' },
group: ['input'], 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"] }}', subtitle: '={{ $parameter["operation"] }}',
description: 'Get, add and update data in MySQL', description: 'Get, add and update data in MySQL',
defaults: { defaults: {

View File

@@ -46,3 +46,10 @@ export type MysqlNodeCredentials = {
connectTimeout: number; connectTimeout: number;
} & WithSSL & } & WithSSL &
WithSSHTunnel; WithSSHTunnel;
export type ParameterMatch = {
match: string;
index: number;
paramNumber: string;
isName: boolean;
};

View File

@@ -10,6 +10,7 @@ import { NodeOperationError } from 'n8n-workflow';
import type { import type {
Mysql2Pool, Mysql2Pool,
ParameterMatch,
QueryMode, QueryMode,
QueryValues, QueryValues,
QueryWithValues, QueryWithValues,
@@ -35,11 +36,119 @@ export function escapeSqlIdentifier(identifier: string): string {
.join('.'); .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) { if (replacements === undefined) {
return { query: rawQuery, values: [] }; 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; let query: string = rawQuery;
const values: QueryValues = []; const values: QueryValues = [];