fix(Postgres Node): Keep comma-containing values as one query parameter (#40240)

This commit is contained in:
symbiote15 2026-10-16 13:28:55 +00:00 committed by GitHub
parent b78913f75d
commit f5dc06f753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 238 additions and 6 deletions

View File

@ -11,7 +11,7 @@ export class Postgres extends VersionedNodeType {
name: 'postgres',
icon: 'file:postgres.svg',
group: ['input'],
defaultVersion: 2.7,
defaultVersion: 2.8,
description: 'Get, add and update data in Postgres',
parameterPane: 'wide',
};
@ -26,6 +26,7 @@ export class Postgres extends VersionedNodeType {
2.5: new PostgresV2(baseDescription),
2.6: new PostgresV2(baseDescription),
2.7: new PostgresV2(baseDescription),
2.8: new PostgresV2(baseDescription),
};
super(nodeVersions, baseDescription);

View File

@ -0,0 +1,36 @@
import type { INodeProperties } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { optionsCollection } from '../../v2/actions/common.descriptions';
import { versionDescription } from '../../v2/actions/versionDescription';
/** The Query Parameters options that the editor shows for Execute Query at a given version */
const visibleQueryParameterOptions = (typeVersion: number) =>
(optionsCollection.options as INodeProperties[]).filter(
(option) =>
option.name === 'queryReplacement' &&
NodeHelpers.displayParameter({}, option, { typeVersion }, versionDescription, {
operation: 'executeQuery',
}),
);
describe('PostgresV2, Query Parameters option', () => {
it.each(versionDescription.version as number[])(
'should show exactly one Query Parameters option at v%s',
(typeVersion) => {
expect(visibleQueryParameterOptions(typeVersion)).toHaveLength(1);
},
);
it.each([
{ typeVersion: 2.7, mentionsOneValue: false },
{ typeVersion: 2.8, mentionsOneValue: true },
])(
'should tell that an expression gives one value only from v2.8 (v$typeVersion)',
({ typeVersion, mentionsOneValue }) => {
const [option] = visibleQueryParameterOptions(typeVersion);
expect(option.hint?.includes('An expression always gives one value')).toBe(mentionsOneValue);
},
);
});

View File

@ -586,6 +586,166 @@ describe('Test PostgresV2, executeQuery operation', () => {
);
},
);
describe('query parameters with commas in evaluated values', () => {
const query = 'SELECT * FROM users WHERE name = $1 AND id = $2';
it.each([
{
description: 'keep a value that contains a comma as one bind value',
nodeVersion: 2.8,
queryReplacement: '={{ $json.name }}, {{ $json.id }}',
evaluated: { '{{ $json.name }}': 'Doe, John', '{{ $json.id }}': 'abc' },
expectedValues: ['Doe, John', 'abc'],
},
{
description: 'pass a numeric value as one bind value',
nodeVersion: 2.8,
queryReplacement: '={{ $json.name }}, {{ $json.id }}',
evaluated: { '{{ $json.name }}': 'Doe, John', '{{ $json.id }}': 42 },
expectedValues: ['Doe, John', '42'],
},
{
description: 'pass a JSON string as one bind value',
nodeVersion: 2.8,
queryReplacement: '={{ $json.payload }}',
evaluated: { '{{ $json.payload }}': '{"id": "7", "name": "x"}' },
expectedValues: ['{"id": "7", "name": "x"}'],
},
{
description: 'pass an object as one JSON bind value',
nodeVersion: 2.8,
queryReplacement: '={{ $json.payload }}, {{ $json.id }}',
evaluated: {
'{{ $json.payload }}': { id: '7', tags: ['a', 'b'] },
'{{ $json.id }}': 'abc',
},
expectedValues: ['{"id":"7","tags":["a","b"]}', 'abc'],
},
{
description: 'keep an empty value as one bind value',
nodeVersion: 2.8,
queryReplacement: '={{ $json.name }}, {{ $json.id }}',
evaluated: { '{{ $json.name }}': '', '{{ $json.id }}': 'abc' },
expectedValues: ['', 'abc'],
},
{
description: 'pass null as SQL NULL',
nodeVersion: 2.8,
queryReplacement: '={{ $json.name }}, {{ $json.note }}',
evaluated: { '{{ $json.name }}': 'Doe, John', '{{ $json.note }}': null },
expectedValues: ['Doe, John', null],
},
{
description: 'pass a missing field as SQL NULL',
nodeVersion: 2.8,
queryReplacement: '={{ $json.name }}, {{ $json.note }}',
evaluated: { '{{ $json.name }}': 'Doe, John' },
expectedValues: ['Doe, John', null],
},
{
description: 'spread an array across bind values',
nodeVersion: 2.8,
queryReplacement: '={{ $json.list }}, {{ $json.id }}',
evaluated: { '{{ $json.list }}': ['x', 'y'], '{{ $json.id }}': 'abc' },
expectedValues: ['x', 'y', 'abc'],
},
{
description: 'pass a missing array element as SQL NULL',
nodeVersion: 2.8,
queryReplacement: '={{ [$json.a, $json.b] }}, {{ $json.c }}',
evaluated: { '{{ [$json.a, $json.b] }}': [undefined, 'x'], '{{ $json.c }}': 'abc' },
expectedValues: [null, 'x', 'abc'],
},
{
description: 'skip a missing array element before v2.8',
nodeVersion: 2.7,
queryReplacement: '={{ [$json.a, $json.b] }}, {{ $json.c }}',
evaluated: { '{{ [$json.a, $json.b] }}': [undefined, 'x'], '{{ $json.c }}': 'abc' },
expectedValues: ['x', 'abc'],
},
{
description: 'split a literal comma-separated list without expressions',
nodeVersion: 2.8,
queryReplacement: 'a, b, c',
evaluated: {},
expectedValues: ['a', 'b', 'c'],
},
{
description: 'split a value that contains a comma before v2.8',
nodeVersion: 2.7,
queryReplacement: '={{ $json.name }}, {{ $json.id }}',
evaluated: { '{{ $json.name }}': 'Doe, John', '{{ $json.id }}': 'abc' },
expectedValues: ['Doe', 'John', 'abc'],
},
])(
'should $description',
async ({ nodeVersion, queryReplacement, evaluated, expectedValues }) => {
const nodeParameters: IDataObject = {
operation: 'executeQuery',
query,
options: { queryReplacement, nodeVersion },
};
const nodeOptions = nodeParameters.options as IDataObject;
const evaluatedByExpression = new Map<string, unknown>(Object.entries(evaluated));
const mockExecute = {
...createMockExecuteFunction(nodeParameters),
evaluateExpression: (str: string) => evaluatedByExpression.get(str.trim()),
} as unknown as IExecuteFunctions;
await executeQuery.execute.call(mockExecute, runQueries, items, nodeOptions);
expect(runQueries).toHaveBeenCalledWith(
[{ query, values: expectedValues, options: { partial: true } }],
nodeOptions,
);
},
);
describe('when the field is only one expression', () => {
const singleQuery = 'SELECT $1::int AS age';
// In a real run, getNodeParameter resolves a single-expression field to its value
const runWithResolvedValue = async (nodeVersion: number, resolvedValue: unknown) => {
const nodeParameters: IDataObject = {
operation: 'executeQuery',
query: singleQuery,
options: { queryReplacement: '={{ $json.age }}', nodeVersion },
};
const nodeOptions = nodeParameters.options as IDataObject;
const mockExecute = {
...createMockExecuteFunction(nodeParameters),
getNodeParameter: (parameterName: string, _itemIndex: number, fallbackValue?: unknown) =>
parameterName === 'options.queryReplacement'
? resolvedValue
: get(nodeParameters, parameterName, fallbackValue),
} as unknown as IExecuteFunctions;
await executeQuery.execute.call(mockExecute, runQueries, items, nodeOptions);
return nodeOptions;
};
it.each([
{ description: 'null', resolvedValue: null },
{ description: 'a missing field', resolvedValue: undefined },
])('should pass $description as SQL NULL from v2.8', async ({ resolvedValue }) => {
const nodeOptions = await runWithResolvedValue(2.8, resolvedValue);
expect(runQueries).toHaveBeenCalledWith(
[{ query: singleQuery, values: [null], options: { partial: true } }],
nodeOptions,
);
});
it('should still reject null before v2.8', async () => {
await expect(runWithResolvedValue(2.7, null)).rejects.toThrow(
'Query Parameters must be a string of comma-separated values or an array of values',
);
});
});
});
});
describe('Test PostgresV2, insert operation', () => {

View File

@ -115,7 +115,20 @@ export const optionsCollection: INodeProperties = {
hint: 'Comma-separated list of values: reference them in your query as $1, $2, $3…',
placeholder: 'e.g. value1,value2,value3',
displayOptions: {
show: { '/operation': ['executeQuery'] },
show: { '/operation': ['executeQuery'], '@version': [{ _cnd: { lt: 2.8 } }] },
},
},
{
displayName: 'Query Parameters',
name: 'queryReplacement',
type: 'string',
default: '',
description:
'Comma-separated list of the values you want to use as query parameters. Each expression gives one value, also when the value contains commas. To give more than one value from an expression, return an array. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/#use-query-parameters" target="_blank">More info</a>.',
hint: 'Comma-separated list of values: reference them in your query as $1, $2, $3… An expression always gives one value.',
placeholder: 'e.g. value1,value2,value3',
displayOptions: {
show: { '/operation': ['executeQuery'], '@version': [{ _cnd: { gte: 2.8 } }] },
},
},
{

View File

@ -71,6 +71,8 @@ export async function execute(
let values: QueryValue[] = [];
const nodeVersion = nodeOptions.nodeVersion as number;
let queryReplacement = this.getNodeParameter('options.queryReplacement', index, '');
if (typeof queryReplacement === 'number') {
@ -83,8 +85,6 @@ export async function execute(
const rawReplacements = (node.parameters.options as IDataObject)?.queryReplacement as string;
if (rawReplacements) {
const nodeVersion = nodeOptions.nodeVersion as number;
if (nodeVersion >= 2.5) {
const rawValues = rawReplacements.replace(/^=+/, '');
const resolvables = getResolvables(rawValues);
@ -94,7 +94,11 @@ export async function execute(
if (Array.isArray(rawEvaluated)) {
for (const item of rawEvaluated) {
if (item === undefined) continue;
if (item === undefined) {
// From v2.8 a missing element becomes SQL NULL, so later values keep their position
if (nodeVersion >= 2.8) values.push(null);
continue;
}
values.push(
typeof item === 'object' && item !== null ? JSON.stringify(item) : item,
);
@ -102,7 +106,19 @@ export async function execute(
continue;
}
// From v2.8 an expression gives one bind value, so commas in data do not split it.
// null and a missing field (undefined) become SQL NULL
if (nodeVersion >= 2.8) {
values.push(
rawEvaluated === null || rawEvaluated === undefined
? null
: evaluateExpression(rawEvaluated),
);
continue;
}
const evaluatedExpression = evaluateExpression(rawEvaluated);
const evaluatedValues = isJSON(evaluatedExpression)
? [evaluatedExpression]
: stringToArray(evaluatedExpression);
@ -135,6 +151,12 @@ export async function execute(
} else {
if (Array.isArray(queryReplacement)) {
values = queryReplacement as IDataObject[];
} else if (
nodeVersion >= 2.8 &&
(queryReplacement === null || queryReplacement === undefined)
) {
// From v2.8 a single expression that gives null or a missing field becomes SQL NULL
values = [null];
} else {
throw new NodeOperationError(
this.getNode(),

View File

@ -8,7 +8,7 @@ export const versionDescription: INodeTypeDescription = {
name: 'postgres',
icon: 'file:postgres.svg',
group: ['input'],
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7],
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8],
subtitle: '={{ $parameter["operation"] }}',
description: 'Get, add and update data in Postgres',
defaults: {