diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 053cf80cd76..7aceb9078ea 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -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); diff --git a/packages/nodes-base/nodes/Postgres/test/v2/descriptions.test.ts b/packages/nodes-base/nodes/Postgres/test/v2/descriptions.test.ts new file mode 100644 index 00000000000..a9a5b11384c --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/test/v2/descriptions.test.ts @@ -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); + }, + ); +}); diff --git a/packages/nodes-base/nodes/Postgres/test/v2/operations.test.ts b/packages/nodes-base/nodes/Postgres/test/v2/operations.test.ts index 8035fe95859..faa6c3fdb69 100644 --- a/packages/nodes-base/nodes/Postgres/test/v2/operations.test.ts +++ b/packages/nodes-base/nodes/Postgres/test/v2/operations.test.ts @@ -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(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', () => { diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/common.descriptions.ts b/packages/nodes-base/nodes/Postgres/v2/actions/common.descriptions.ts index 34538318e40..f688076a7bf 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/common.descriptions.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/common.descriptions.ts @@ -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. More info.', + 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 } }] }, }, }, { diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts b/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts index 49265ac6d6d..ae97e7abe80 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts @@ -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(), diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts index 1096112088b..2a8b953ad7f 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts @@ -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: {