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,50 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.postgres",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development", "Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/postgres/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "Love at first sight: Ricardo’s n8n journey",
|
||||
"icon": "❤️",
|
||||
"url": "https://n8n.io/blog/love-at-first-sight-ricardos-n8n-journey/"
|
||||
},
|
||||
{
|
||||
"label": "Why I chose n8n over Zapier in 2020",
|
||||
"icon": "😍",
|
||||
"url": "https://n8n.io/blog/why-i-chose-n8n-over-zapier-in-2020/"
|
||||
},
|
||||
{
|
||||
"label": "Database Monitoring and Alerting with n8n",
|
||||
"icon": "📡",
|
||||
"url": "https://n8n.io/blog/database-monitoring-and-alerting-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Running n8n on ships: An interview with Maranics",
|
||||
"icon": "🛳",
|
||||
"url": "https://n8n.io/blog/running-n8n-on-ships-an-interview-with-maranics/"
|
||||
},
|
||||
{
|
||||
"label": "Automate your data processing pipeline in 9 steps",
|
||||
"icon": "⚙️",
|
||||
"url": "https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "How Honest Burgers Use Automation to Save $100k per year",
|
||||
"icon": "🍔",
|
||||
"url": "https://n8n.io/blog/how-honest-burgers-use-automation-to-save-100k-per-year/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { PostgresV1 } from './v1/PostgresV1.node';
|
||||
import { PostgresV2 } from './v2/PostgresV2.node';
|
||||
|
||||
export class Postgres extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Postgres',
|
||||
name: 'postgres',
|
||||
icon: 'file:postgres.svg',
|
||||
group: ['input'],
|
||||
defaultVersion: 2.6,
|
||||
description: 'Get, add and update data in Postgres',
|
||||
parameterPane: 'wide',
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new PostgresV1(baseDescription),
|
||||
2: new PostgresV2(baseDescription),
|
||||
2.1: new PostgresV2(baseDescription),
|
||||
2.2: new PostgresV2(baseDescription),
|
||||
2.3: new PostgresV2(baseDescription),
|
||||
2.4: new PostgresV2(baseDescription),
|
||||
2.5: new PostgresV2(baseDescription),
|
||||
2.6: new PostgresV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface IPostgresTrigger {
|
||||
triggerName: string;
|
||||
functionName: string;
|
||||
channelName: string;
|
||||
target: string;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import type {
|
||||
ITriggerFunctions,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
INodeListSearchItems,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { configurePostgres } from './transport';
|
||||
import type { PgpDatabase, PostgresNodeCredentials } from './v2/helpers/interfaces';
|
||||
|
||||
export function prepareNames(id: string, mode: string, additionalFields: IDataObject) {
|
||||
let suffix = id.replace(/-/g, '_');
|
||||
|
||||
if (mode === 'manual') {
|
||||
suffix = `${suffix}_manual`;
|
||||
}
|
||||
|
||||
let functionName =
|
||||
(additionalFields.functionName as string) || `n8n_trigger_function_${suffix}()`;
|
||||
|
||||
if (!(functionName.includes('(') && functionName.includes(')'))) {
|
||||
functionName = `${functionName}()`;
|
||||
}
|
||||
|
||||
const triggerName = (additionalFields.triggerName as string) || `n8n_trigger_${suffix}`;
|
||||
const channelName = (additionalFields.channelName as string) || `n8n_channel_${suffix}`;
|
||||
|
||||
if (channelName.includes('-')) {
|
||||
throw new ApplicationError('Channel name cannot contain hyphens (-)', { level: 'warning' });
|
||||
}
|
||||
|
||||
return { functionName, triggerName, channelName };
|
||||
}
|
||||
|
||||
export async function pgTriggerFunction(
|
||||
this: ITriggerFunctions,
|
||||
db: PgpDatabase,
|
||||
additionalFields: IDataObject,
|
||||
functionName: string,
|
||||
triggerName: string,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
const schema = this.getNodeParameter('schema', 'public', { extractValue: true }) as string;
|
||||
const tableName = this.getNodeParameter('tableName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const target = `${schema}."${tableName}"`;
|
||||
|
||||
const firesOn = this.getNodeParameter('firesOn', 0) as string;
|
||||
|
||||
const functionReplace =
|
||||
"CREATE OR REPLACE FUNCTION $1:raw RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF AS $BODY$ begin perform pg_notify('$2:raw', row_to_json($3:raw)::text); return null; end; $BODY$;";
|
||||
|
||||
const dropIfExist = 'DROP TRIGGER IF EXISTS $1:raw ON $2:raw';
|
||||
|
||||
const functionExists =
|
||||
"CREATE FUNCTION $1:raw RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF AS $BODY$ begin perform pg_notify('$2:raw', row_to_json($3:raw)::text); return null; end; $BODY$";
|
||||
|
||||
const trigger =
|
||||
'CREATE TRIGGER $4:raw AFTER $3:raw ON $1:raw FOR EACH ROW EXECUTE FUNCTION $2:raw';
|
||||
|
||||
const whichData = firesOn === 'DELETE' ? 'old' : 'new';
|
||||
|
||||
if (channelName.includes('-')) {
|
||||
throw new ApplicationError('Channel name cannot contain hyphens (-)', { level: 'warning' });
|
||||
}
|
||||
|
||||
const replaceIfExists = additionalFields.replaceIfExists ?? false;
|
||||
|
||||
try {
|
||||
if (replaceIfExists || !(additionalFields.triggerName ?? additionalFields.functionName)) {
|
||||
await db.any(functionReplace, [functionName, channelName, whichData]);
|
||||
await db.any(dropIfExist, [triggerName, target, whichData]);
|
||||
} else {
|
||||
await db.any(functionExists, [functionName, channelName, whichData]);
|
||||
}
|
||||
await db.any(trigger, [target, functionName, firesOn, triggerName]);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('near "-"')) {
|
||||
throw new ApplicationError('Names cannot contain hyphens (-)', { level: 'warning' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDB(this: ITriggerFunctions | ILoadOptionsFunctions) {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const options = this.getNodeParameter('options', {}) as {
|
||||
connectionTimeout?: number;
|
||||
delayClosingIdleConnection?: number;
|
||||
};
|
||||
return await configurePostgres.call(this, credentials, options);
|
||||
}
|
||||
|
||||
export async function searchSchema(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const { db } = await initDB.call(this);
|
||||
const schemaList = await db.any('SELECT schema_name FROM information_schema.schemata');
|
||||
const results: INodeListSearchItems[] = (schemaList as IDataObject[]).map((s) => ({
|
||||
name: s.schema_name as string,
|
||||
value: s.schema_name as string,
|
||||
}));
|
||||
return { results };
|
||||
}
|
||||
|
||||
export async function searchTables(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const schema = this.getNodeParameter('schema', 0) as IDataObject;
|
||||
const { db } = await initDB.call(this);
|
||||
let tableList = [];
|
||||
try {
|
||||
tableList = await db.any(
|
||||
'SELECT table_name FROM information_schema.tables WHERE table_schema = $1',
|
||||
[schema.value],
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ApplicationError(error as string);
|
||||
}
|
||||
const results: INodeListSearchItems[] = (tableList as IDataObject[]).map((s) => ({
|
||||
name: s.table_name as string,
|
||||
value: s.table_name as string,
|
||||
}));
|
||||
return { results };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.postgresTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/postgres/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.postgrestrigger/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
TriggerCloseError,
|
||||
type IDataObject,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ITriggerFunctions,
|
||||
type ITriggerResponse,
|
||||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
pgTriggerFunction,
|
||||
initDB,
|
||||
searchSchema,
|
||||
searchTables,
|
||||
prepareNames,
|
||||
} from './PostgresTrigger.functions';
|
||||
|
||||
export class PostgresTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Postgres Trigger',
|
||||
name: 'postgresTrigger',
|
||||
icon: 'file:postgres.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Listens to Postgres messages',
|
||||
eventTriggerDescription: '',
|
||||
defaults: {
|
||||
name: 'Postgres Trigger',
|
||||
},
|
||||
triggerPanel: {
|
||||
header: '',
|
||||
executionsHelp: {
|
||||
inactive:
|
||||
"<b>While building your workflow</b>, click the 'execute step' button, then trigger a Postgres event. This will trigger an execution, which will show up in this editor.<br /> <br /><b>Once you're happy with your workflow</b>, publish it. Then every time a change is detected, the workflow will execute. These executions will show up in the <a data-key='executions'>executions list</a>, but not in the editor.",
|
||||
active:
|
||||
"<b>While building your workflow</b>, click the 'execute step' button, then trigger a Postgres event. This will trigger an execution, which will show up in this editor.<br /> <br /><b>Your workflow will also execute automatically</b>, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the <a data-key='executions'>executions list</a>, but not in the editor.",
|
||||
},
|
||||
activationHint:
|
||||
"Once you've finished building your workflow, publish it to have it also listen continuously (you just won't see those executions here).",
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'postgres',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Listen For',
|
||||
name: 'triggerMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Table Row Change Events',
|
||||
value: 'createTrigger',
|
||||
description: 'Insert, update or delete',
|
||||
},
|
||||
{
|
||||
name: 'Advanced',
|
||||
value: 'listenTrigger',
|
||||
description: 'Listen to existing Postgres channel',
|
||||
},
|
||||
],
|
||||
default: 'createTrigger',
|
||||
},
|
||||
{
|
||||
displayName: 'Schema Name',
|
||||
name: 'schema',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: 'public' },
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerMode: ['createTrigger'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a schema',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchSchema',
|
||||
searchFilterRequired: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. public',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Table Name',
|
||||
name: 'tableName',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerMode: ['createTrigger'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a table',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchTables',
|
||||
searchFilterRequired: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. table_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Channel Name',
|
||||
name: 'channelName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. n8n_channel',
|
||||
description: 'Name of the channel to listen to',
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerMode: ['listenTrigger'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Event to listen for',
|
||||
name: 'firesOn',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerMode: ['createTrigger'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Insert',
|
||||
value: 'INSERT',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'UPDATE',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'DELETE',
|
||||
},
|
||||
],
|
||||
default: 'INSERT',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerMode: ['createTrigger'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Channel Name',
|
||||
name: 'channelName',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. n8n_channel',
|
||||
description: 'Name of the channel to listen to',
|
||||
default: '',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Function Name',
|
||||
name: 'functionName',
|
||||
type: 'string',
|
||||
description: 'Name of the function to create',
|
||||
placeholder: 'e.g. n8n_trigger_function()',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Replace if Exists',
|
||||
name: 'replaceIfExists',
|
||||
type: 'boolean',
|
||||
description: 'Whether to replace an existing function and trigger with the same name',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger Name',
|
||||
name: 'triggerName',
|
||||
type: 'string',
|
||||
description: 'Name of the trigger to create',
|
||||
placeholder: 'e.g. n8n_trigger',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Connection Timeout',
|
||||
name: 'connectionTimeout',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
description: 'Number of seconds reserved for connecting to the database',
|
||||
},
|
||||
{
|
||||
displayName: 'Delay Closing Idle Connection',
|
||||
name: 'delayClosingIdleConnection',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'Number of seconds to wait before idle connection would be eligible for closing',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: {
|
||||
searchSchema,
|
||||
searchTables,
|
||||
},
|
||||
};
|
||||
|
||||
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
|
||||
const triggerMode = this.getNodeParameter('triggerMode', 0) as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', 0) as IDataObject;
|
||||
|
||||
// initialize and connect to database
|
||||
const { db } = await initDB.call(this);
|
||||
const connection = await db.connect({ direct: true });
|
||||
|
||||
// prepare and set up listener
|
||||
const onNotification = async (data: IDataObject) => {
|
||||
if (data.payload) {
|
||||
try {
|
||||
data.payload = JSON.parse(data.payload as string) as IDataObject;
|
||||
} catch (error) {}
|
||||
}
|
||||
this.emit([this.helpers.returnJsonArray([data])]);
|
||||
};
|
||||
|
||||
// create trigger, function and channel or use existing channel
|
||||
const pgNames = prepareNames(this.getNode().id, this.getMode(), additionalFields);
|
||||
if (triggerMode === 'createTrigger') {
|
||||
await pgTriggerFunction.call(
|
||||
this,
|
||||
db,
|
||||
additionalFields,
|
||||
pgNames.functionName,
|
||||
pgNames.triggerName,
|
||||
pgNames.channelName,
|
||||
);
|
||||
} else {
|
||||
pgNames.channelName = this.getNodeParameter('channelName', '') as string;
|
||||
}
|
||||
|
||||
// listen to channel
|
||||
await connection.none(`LISTEN ${pgNames.channelName}`);
|
||||
|
||||
const cleanUpDb = async () => {
|
||||
try {
|
||||
try {
|
||||
// check if the connection is healthy
|
||||
await connection.query('SELECT 1');
|
||||
} catch {
|
||||
// connection already closed. Can't perform cleanup
|
||||
|
||||
throw new TriggerCloseError(this.getNode(), { level: 'warning' });
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.none('UNLISTEN $1:name', [pgNames.channelName]);
|
||||
if (triggerMode === 'createTrigger') {
|
||||
const functionName = pgNames.functionName.includes('(')
|
||||
? pgNames.functionName.split('(')[0]
|
||||
: pgNames.functionName;
|
||||
await connection.any('DROP FUNCTION IF EXISTS $1:name CASCADE', [functionName]);
|
||||
|
||||
const schema = this.getNodeParameter('schema', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const table = this.getNodeParameter('tableName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
await connection.any('DROP TRIGGER IF EXISTS $1:name ON $2:name.$3:name CASCADE', [
|
||||
pgNames.triggerName,
|
||||
schema,
|
||||
table,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TriggerCloseError(this.getNode(), { cause: error as Error, level: 'error' });
|
||||
}
|
||||
} finally {
|
||||
connection.client.removeListener('notification', onNotification);
|
||||
}
|
||||
};
|
||||
|
||||
connection.client.on('notification', onNotification);
|
||||
|
||||
// The "closeFunction" function gets called by n8n whenever
|
||||
// the workflow gets deactivated and can so clean up.
|
||||
const closeFunction = async () => {
|
||||
await cleanUpDb();
|
||||
};
|
||||
|
||||
const manualTriggerFunction = async () => {
|
||||
await new Promise(async (resolve, reject) => {
|
||||
const timeoutHandler = setTimeout(async () => {
|
||||
reject(
|
||||
new Error(
|
||||
await (async () => {
|
||||
await cleanUpDb();
|
||||
return 'Aborted, no data received within 30secs. This 30sec timeout is only set for "manually triggered execution". Active Workflows will listen indefinitely.';
|
||||
})(),
|
||||
),
|
||||
);
|
||||
}, 60000);
|
||||
connection.client.on('notification', async (data: IDataObject) => {
|
||||
if (data.payload) {
|
||||
try {
|
||||
data.payload = JSON.parse(data.payload as string) as IDataObject;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
this.emit([this.helpers.returnJsonArray([data])]);
|
||||
clearTimeout(timeoutHandler);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
manualTriggerFunction: this.getMode() === 'manual' ? manualTriggerFunction : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,162 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import pgPromise from 'pg-promise';
|
||||
|
||||
import * as PostgresFun from '../v1/genericFunctions';
|
||||
import type { PgpDatabase } from '../v2/helpers/interfaces';
|
||||
|
||||
type NodeParams = Record<string, string | {}>;
|
||||
|
||||
const pgp = pgPromise();
|
||||
const db = mock<PgpDatabase>();
|
||||
|
||||
describe('pgUpdate', () => {
|
||||
it('runs query to update db', async () => {
|
||||
const updateItem = { id: 1234, name: 'test' };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
updateKey: 'id',
|
||||
columns: 'id,name',
|
||||
additionalFields: {},
|
||||
returnFields: '*',
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: updateItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgUpdate(getNodeParam, pgp, db, items);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'update "myschema"."mytable" as t set "id"=v."id","name"=v."name" from (values(1234,\'test\')) as v("id","name") WHERE v."id" = t."id" RETURNING *',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs query to update db if updateKey is not in columns', async () => {
|
||||
const updateItem = { id: 1234, name: 'test' };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
updateKey: 'id',
|
||||
columns: 'name',
|
||||
additionalFields: {},
|
||||
returnFields: '*',
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: updateItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgUpdate(getNodeParam, pgp, db, items);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'update "myschema"."mytable" as t set "id"=v."id","name"=v."name" from (values(1234,\'test\')) as v("id","name") WHERE v."id" = t."id" RETURNING *',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs query to update db with cast as updateKey', async () => {
|
||||
const updateItem = { id: '1234', name: 'test' };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
updateKey: 'id:uuid',
|
||||
columns: 'name',
|
||||
additionalFields: {},
|
||||
returnFields: '*',
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: updateItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgUpdate(getNodeParam, pgp, db, items);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'update "myschema"."mytable" as t set "id"=v."id","name"=v."name" from (values(\'1234\'::uuid,\'test\')) as v("id","name") WHERE v."id" = t."id" RETURNING *',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs query to update db with cast in target columns', async () => {
|
||||
const updateItem = { id: '1234', name: 'test' };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
updateKey: 'id',
|
||||
columns: 'id:uuid,name',
|
||||
additionalFields: {},
|
||||
returnFields: '*',
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: updateItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgUpdate(getNodeParam, pgp, db, items);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'update "myschema"."mytable" as t set "id"=v."id","name"=v."name" from (values(\'1234\'::uuid,\'test\')) as v("id","name") WHERE v."id" = t."id" RETURNING *',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pgInsert', () => {
|
||||
it('runs query to insert', async () => {
|
||||
const insertItem = { id: 1234, name: 'test', age: 34 };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
columns: 'id,name,age',
|
||||
returnFields: '*',
|
||||
additionalFields: {},
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: insertItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgInsert(getNodeParam, pgp, db, items, false);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'insert into "myschema"."mytable"("id","name","age") values(1234,\'test\',34) RETURNING *',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs query to insert with type casting', async () => {
|
||||
const insertItem = { id: 1234, name: 'test', age: 34 };
|
||||
const nodeParams: NodeParams = {
|
||||
table: 'mytable',
|
||||
schema: 'myschema',
|
||||
columns: 'id:int,name:text,age',
|
||||
returnFields: '*',
|
||||
additionalFields: {},
|
||||
};
|
||||
const getNodeParam = (key: string) => nodeParams[key];
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: insertItem,
|
||||
},
|
||||
];
|
||||
|
||||
await PostgresFun.pgInsert(getNodeParam, pgp, db, items, false);
|
||||
|
||||
expect(db.any).toHaveBeenCalledWith(
|
||||
'insert into "myschema"."mytable"("id","name","age") values(1234::int,\'test\'::text,34) RETURNING *',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { ColumnInfo } from '../../v2/helpers/interfaces';
|
||||
import { getEnums, getEnumValues, getTableSchema } from '../../v2/helpers/utils';
|
||||
import { getMappingColumns } from '../../v2/methods/resourceMapping';
|
||||
|
||||
jest.mock('../../transport', () => {
|
||||
const originalModule = jest.requireActual('../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
configurePostgres: jest.fn(async () => ({ db: {} })),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../v2/helpers/utils', () => {
|
||||
const originalModule = jest.requireActual('../../v2/helpers/utils');
|
||||
return {
|
||||
...originalModule,
|
||||
getEnums: jest.fn(() => new Map()),
|
||||
getEnumValues: jest.fn(),
|
||||
getTableSchema: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Postgres, resourceMapping', () => {
|
||||
let loadOptionsFunctions: MockProxy<ILoadOptionsFunctions>;
|
||||
|
||||
const createColumnData = (
|
||||
columnName: string,
|
||||
dataType: string,
|
||||
overrides: Partial<ColumnInfo> = {},
|
||||
): ColumnInfo => ({
|
||||
column_name: columnName,
|
||||
data_type: dataType,
|
||||
is_nullable: 'NO',
|
||||
udt_name: dataType,
|
||||
column_default: null,
|
||||
is_generated: 'NEVER',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadOptionsFunctions = mock<ILoadOptionsFunctions>();
|
||||
loadOptionsFunctions.getCredentials.mockResolvedValue({});
|
||||
loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('public');
|
||||
loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('test_table');
|
||||
loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('insert');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'should mark id as not required if identity_generation is "BY_DEFAULT"',
|
||||
columnData: createColumnData('id', 'bigint', {
|
||||
udt_name: 'int8',
|
||||
identity_generation: 'BY DEFAULT',
|
||||
}),
|
||||
expectedType: 'number',
|
||||
expectedRequired: false,
|
||||
expectedDefaultMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'should map citext to string type',
|
||||
columnData: createColumnData('email', 'citext'),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map varchar to string type',
|
||||
columnData: createColumnData('name', 'varchar', { is_nullable: 'YES' }),
|
||||
expectedType: 'string',
|
||||
expectedRequired: false,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map integer to number type',
|
||||
columnData: createColumnData('count', 'integer', { udt_name: 'int4' }),
|
||||
expectedType: 'number',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map boolean to boolean type',
|
||||
columnData: createColumnData('is_active', 'boolean', {
|
||||
udt_name: 'bool',
|
||||
column_default: 'false',
|
||||
}),
|
||||
expectedType: 'boolean',
|
||||
expectedRequired: false, // has default
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map timestamp to dateTime type',
|
||||
columnData: createColumnData('created_at', 'timestamp'),
|
||||
expectedType: 'dateTime',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map json to object type',
|
||||
columnData: createColumnData('metadata', 'json', { is_nullable: 'YES' }),
|
||||
expectedType: 'object',
|
||||
expectedRequired: false,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map USER-DEFINED enum to options type',
|
||||
columnData: createColumnData('status', 'USER-DEFINED', { udt_name: 'status_enum' }),
|
||||
expectedType: 'options',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
isEnum: true,
|
||||
expectedOptions: [
|
||||
{ name: 'Active', value: 'active' },
|
||||
{ name: 'Inactive', value: 'inactive' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'should map unknown USER-DEFINED type to string by default',
|
||||
columnData: createColumnData('custom_field', 'USER-DEFINED', { udt_name: 'unknown_type' }),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map uuid to string type',
|
||||
columnData: createColumnData('user_id', 'uuid'),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map PostGIS geometry to string type',
|
||||
columnData: createColumnData('location', 'USER-DEFINED', { udt_name: 'geometry' }),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map inet address to string type',
|
||||
columnData: createColumnData('ip_address', 'inet'),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map hstore to object type',
|
||||
columnData: createColumnData('attributes', 'USER-DEFINED', { udt_name: 'hstore' }),
|
||||
expectedType: 'object',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map range types to string type',
|
||||
columnData: createColumnData('price_range', 'USER-DEFINED', { udt_name: 'numrange' }),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'should map unknown data type to string by default',
|
||||
columnData: createColumnData('unknown_field', 'unknown_postgres_type', {
|
||||
udt_name: 'unknown',
|
||||
}),
|
||||
expectedType: 'string',
|
||||
expectedRequired: true,
|
||||
expectedDefaultMatch: false,
|
||||
},
|
||||
])(
|
||||
'$name',
|
||||
async ({
|
||||
columnData,
|
||||
expectedType,
|
||||
expectedRequired,
|
||||
expectedDefaultMatch,
|
||||
isEnum,
|
||||
expectedOptions,
|
||||
}) => {
|
||||
jest.mocked(getTableSchema).mockResolvedValueOnce([columnData]);
|
||||
|
||||
// Mock enum data if this test case represents an enum
|
||||
if (isEnum && columnData.udt_name) {
|
||||
jest
|
||||
.mocked(getEnums)
|
||||
.mockResolvedValueOnce(new Map([[columnData.udt_name, ['active', 'inactive']]]));
|
||||
jest.mocked(getEnumValues).mockReturnValueOnce([
|
||||
{ name: 'Active', value: 'active' },
|
||||
{ name: 'Inactive', value: 'inactive' },
|
||||
]);
|
||||
}
|
||||
|
||||
const fields = await getMappingColumns.call(loadOptionsFunctions);
|
||||
|
||||
expect(fields).toEqual({
|
||||
fields: [
|
||||
{
|
||||
canBeUsedToMatch: true,
|
||||
defaultMatch: expectedDefaultMatch,
|
||||
display: true,
|
||||
displayName: columnData.column_name,
|
||||
id: columnData.column_name,
|
||||
options: expectedOptions ?? undefined,
|
||||
required: expectedRequired,
|
||||
type: expectedType,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import pgPromise from 'pg-promise';
|
||||
|
||||
import type { PgpDatabase } from '../../v2/helpers/interfaces';
|
||||
import { configureQueryRunner } from '../../v2/helpers/utils';
|
||||
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Postgres node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.postgres',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'executeQuery',
|
||||
},
|
||||
};
|
||||
|
||||
const createMockDb = (returnData: IDataObject | IDataObject[]) => {
|
||||
return {
|
||||
async any() {
|
||||
return returnData;
|
||||
},
|
||||
async multi() {
|
||||
return returnData;
|
||||
},
|
||||
async tx() {
|
||||
return returnData;
|
||||
},
|
||||
async task() {
|
||||
return returnData;
|
||||
},
|
||||
} as unknown as PgpDatabase;
|
||||
};
|
||||
|
||||
describe('Test PostgresV2, runQueries', () => {
|
||||
it('should execute, should return success true', async () => {
|
||||
const pgp = pgPromise();
|
||||
const db = createMockDb([]);
|
||||
|
||||
const dbMultiSpy = jest.spyOn(db, 'multi');
|
||||
|
||||
const thisArg = mock<IExecuteFunctions>();
|
||||
const runQueries = configureQueryRunner.call(thisArg, node, false, pgp, db);
|
||||
|
||||
const result = await runQueries([{ query: 'SELECT * FROM table', values: [] }], {
|
||||
nodeVersion: 2.2,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual([{ json: { success: true }, pairedItem: [{ item: 0 }] }]);
|
||||
expect(dbMultiSpy).toHaveBeenCalledWith('SELECT * FROM table');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode, INodeExecutionData, IPairedItemData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import pgPromise from 'pg-promise';
|
||||
|
||||
import type {
|
||||
ColumnInfo,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryMode,
|
||||
QueryWithValues,
|
||||
} from '../../v2/helpers/interfaces';
|
||||
import {
|
||||
addSortRules,
|
||||
addReturning,
|
||||
addWhereClauses,
|
||||
checkItemAgainstSchema,
|
||||
parsePostgresError,
|
||||
prepareErrorItem,
|
||||
prepareItem,
|
||||
replaceEmptyStringsByNulls,
|
||||
wrapData,
|
||||
convertArraysToPostgresFormat,
|
||||
isJSON,
|
||||
convertValuesToJsonWithPgp,
|
||||
hasJsonDataTypeInSchema,
|
||||
evaluateExpression,
|
||||
isWhereClause,
|
||||
getWhereClauses,
|
||||
runQueriesAndHandleErrors,
|
||||
} from '../../v2/helpers/utils';
|
||||
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Postgres node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.postgres',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'executeQuery',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Test PostgresV2, isJSON', () => {
|
||||
it('should return true for valid JSON', () => {
|
||||
expect(isJSON('{"key": "value"}')).toEqual(true);
|
||||
});
|
||||
it('should return false for invalid JSON', () => {
|
||||
expect(isJSON('{"key": "value"')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, evaluateExpression', () => {
|
||||
it('should evaluate undefined to an empty string', () => {
|
||||
expect(evaluateExpression(undefined)).toEqual('');
|
||||
});
|
||||
it('should evaluate null to a string with value null', () => {
|
||||
expect(evaluateExpression(null)).toEqual('null');
|
||||
});
|
||||
it('should evaluate object to a string', () => {
|
||||
expect(evaluateExpression({ key: '' })).toEqual('{"key":""}');
|
||||
expect(evaluateExpression([])).toEqual('[]');
|
||||
expect(evaluateExpression([1, 2, 4])).toEqual('[1,2,4]');
|
||||
});
|
||||
it('should evaluate everything else to a string', () => {
|
||||
expect(evaluateExpression(1)).toEqual('1');
|
||||
expect(evaluateExpression('string')).toEqual('string');
|
||||
expect(evaluateExpression(true)).toEqual('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, wrapData', () => {
|
||||
it('should wrap object in json', () => {
|
||||
const data = {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
});
|
||||
it('should wrap each object in array in json', () => {
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Name 2',
|
||||
},
|
||||
];
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data[0] }, { json: data[1] }]);
|
||||
});
|
||||
it('json key from source should be inside json', () => {
|
||||
const data = {
|
||||
json: {
|
||||
id: 1,
|
||||
name: 'Name',
|
||||
},
|
||||
};
|
||||
const wrappedData = wrapData(data);
|
||||
expect(wrappedData).toBeDefined();
|
||||
expect(wrappedData).toEqual([{ json: data }]);
|
||||
expect(Object.keys(wrappedData[0].json)).toContain('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, prepareErrorItem', () => {
|
||||
it('should return error info item', () => {
|
||||
const error = new Error('Test error');
|
||||
const item = prepareErrorItem(error, 1);
|
||||
expect(item).toBeDefined();
|
||||
|
||||
expect((item.pairedItem as IPairedItemData).item).toEqual(1);
|
||||
expect(item.json.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, parsePostgresError', () => {
|
||||
it('should return NodeOperationError', () => {
|
||||
const error = new Error('Test error');
|
||||
|
||||
const parsedError = parsePostgresError(node, error, [], 1);
|
||||
expect(parsedError).toBeDefined();
|
||||
expect(parsedError.message).toEqual('Test error');
|
||||
expect(parsedError instanceof NodeOperationError).toEqual(true);
|
||||
});
|
||||
|
||||
it('should update message that includes ECONNREFUSED', () => {
|
||||
const error = new Error('ECONNREFUSED');
|
||||
|
||||
const parsedError = parsePostgresError(node, error, [], 1);
|
||||
expect(parsedError).toBeDefined();
|
||||
expect(parsedError.message).toEqual('Connection refused');
|
||||
expect(parsedError instanceof NodeOperationError).toEqual(true);
|
||||
});
|
||||
|
||||
it('should update message with syntax error', () => {
|
||||
// eslint-disable-next-line n8n-local-rules/no-unneeded-backticks
|
||||
const errorMessage = String.raw`syntax error at or near "select"`;
|
||||
const error = new Error();
|
||||
error.message = errorMessage;
|
||||
|
||||
const parsedError = parsePostgresError(node, error, [
|
||||
{ query: 'select * from my_table', values: [] },
|
||||
]);
|
||||
expect(parsedError).toBeDefined();
|
||||
expect(parsedError.message).toEqual('Syntax error at line 1 near "select"');
|
||||
expect(parsedError instanceof NodeOperationError).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, addWhereClauses', () => {
|
||||
it('should add where clauses to query', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [{ column: 'id', condition: 'equal', value: '1' }];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'AND',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual('SELECT * FROM $1:name.$2:name WHERE $3:name = $4');
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id', '1']);
|
||||
});
|
||||
|
||||
it('should combine where clauses by OR', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'id', condition: 'equal', value: '1' },
|
||||
{ column: 'foo', condition: 'equal', value: 'select 2' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'OR',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name = $4 OR $5:name = $6',
|
||||
);
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id', '1', 'foo', 'select 2']);
|
||||
});
|
||||
|
||||
it('should ignore incorrect combine condition ad use AND', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'id', condition: 'equal', value: '1' },
|
||||
{ column: 'foo', condition: 'equal', value: 'select 2' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'SELECT * FROM my_table',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name = $4 AND $5:name = $6',
|
||||
);
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id', '1', 'foo', 'select 2']);
|
||||
});
|
||||
|
||||
it('should handle numeric comparison operators', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'age', condition: '>', value: '25' },
|
||||
{ column: 'salary', condition: '>=', value: '50000' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'AND',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name > $4 AND $5:name >= $6',
|
||||
);
|
||||
// Values should be converted to numbers
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'age', 25, 'salary', 50000]);
|
||||
});
|
||||
|
||||
it('should handle date comparison operators', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'created_at', condition: '>=', value: '2025-04-28T00:00:00.000Z' },
|
||||
{ column: 'updated_at', condition: '<', value: '2025-05-01' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'AND',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name >= $4 AND $5:name < $6',
|
||||
);
|
||||
// Date strings should remain as strings
|
||||
expect(updatedValues).toEqual([
|
||||
'public',
|
||||
'my_table',
|
||||
'created_at',
|
||||
'2025-04-28T00:00:00.000Z',
|
||||
'updated_at',
|
||||
'2025-05-01',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle string comparison operators', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'name', condition: '>', value: 'M' },
|
||||
{ column: 'category', condition: '<=', value: 'Electronics' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'AND',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name > $4 AND $5:name <= $6',
|
||||
);
|
||||
// Text strings should remain as strings
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'name', 'M', 'category', 'Electronics']);
|
||||
});
|
||||
|
||||
it('should not convert empty strings or whitespace-only strings to numbers', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const whereClauses = [
|
||||
{ column: 'empty_field', condition: '>', value: '' },
|
||||
{ column: 'whitespace_field', condition: '>=', value: ' ' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addWhereClauses(
|
||||
node,
|
||||
0,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
'AND',
|
||||
);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name WHERE $3:name > $4 AND $5:name >= $6',
|
||||
);
|
||||
// These should NOT be converted to numbers
|
||||
expect(updatedValues).toEqual([
|
||||
'public',
|
||||
'my_table',
|
||||
'empty_field',
|
||||
'',
|
||||
'whitespace_field',
|
||||
' ',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, addSortRules', () => {
|
||||
it('should ORDER BY ASC', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const sortRules = [{ column: 'id', direction: 'ASC' }];
|
||||
|
||||
const [updatedQuery, updatedValues] = addSortRules(query, sortRules, values);
|
||||
|
||||
expect(updatedQuery).toEqual('SELECT * FROM $1:name.$2:name ORDER BY $3:name ASC');
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id']);
|
||||
});
|
||||
it('should ORDER BY DESC', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const sortRules = [{ column: 'id', direction: 'DESC' }];
|
||||
|
||||
const [updatedQuery, updatedValues] = addSortRules(query, sortRules, values);
|
||||
|
||||
expect(updatedQuery).toEqual('SELECT * FROM $1:name.$2:name ORDER BY $3:name DESC');
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id']);
|
||||
});
|
||||
it('should ignore incorrect direction', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const sortRules = [{ column: 'id', direction: 'SELECT * FROM my_table' }];
|
||||
|
||||
const [updatedQuery, updatedValues] = addSortRules(query, sortRules, values);
|
||||
|
||||
expect(updatedQuery).toEqual('SELECT * FROM $1:name.$2:name ORDER BY $3:name ASC');
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id']);
|
||||
});
|
||||
it('should add multiple sort rules', () => {
|
||||
const query = 'SELECT * FROM $1:name.$2:name';
|
||||
const values = ['public', 'my_table'];
|
||||
const sortRules = [
|
||||
{ column: 'id', direction: 'ASC' },
|
||||
{ column: 'foo', direction: 'DESC' },
|
||||
];
|
||||
|
||||
const [updatedQuery, updatedValues] = addSortRules(query, sortRules, values);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'SELECT * FROM $1:name.$2:name ORDER BY $3:name ASC, $4:name DESC',
|
||||
);
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id', 'foo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, addReturning', () => {
|
||||
it('should add RETURNING', () => {
|
||||
const query = 'UPDATE $1:name.$2:name SET $5:name = $6 WHERE $3:name = $4';
|
||||
const values = ['public', 'my_table', 'id', '1', 'foo', 'updated'];
|
||||
const outputColumns = ['id', 'foo'];
|
||||
|
||||
const [updatedQuery, updatedValues] = addReturning(query, outputColumns, values);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'UPDATE $1:name.$2:name SET $5:name = $6 WHERE $3:name = $4 RETURNING $7:name',
|
||||
);
|
||||
expect(updatedValues).toEqual([
|
||||
'public',
|
||||
'my_table',
|
||||
'id',
|
||||
'1',
|
||||
'foo',
|
||||
'updated',
|
||||
['id', 'foo'],
|
||||
]);
|
||||
});
|
||||
it('should add RETURNING *', () => {
|
||||
const query = 'UPDATE $1:name.$2:name SET $5:name = $6 WHERE $3:name = $4';
|
||||
const values = ['public', 'my_table', 'id', '1', 'foo', 'updated'];
|
||||
const outputColumns = ['id', 'foo', '*'];
|
||||
|
||||
const [updatedQuery, updatedValues] = addReturning(query, outputColumns, values);
|
||||
|
||||
expect(updatedQuery).toEqual(
|
||||
'UPDATE $1:name.$2:name SET $5:name = $6 WHERE $3:name = $4 RETURNING *',
|
||||
);
|
||||
expect(updatedValues).toEqual(['public', 'my_table', 'id', '1', 'foo', 'updated']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, replaceEmptyStringsByNulls', () => {
|
||||
it('should replace empty string by null', () => {
|
||||
const items = [
|
||||
{ json: { foo: 'bar', bar: '', spam: undefined } },
|
||||
{ json: { foo: '', bar: '', spam: '' } },
|
||||
{ json: { foo: 0, bar: NaN, spam: false } },
|
||||
];
|
||||
|
||||
const updatedItems = replaceEmptyStringsByNulls(items, true);
|
||||
|
||||
expect(updatedItems).toBeDefined();
|
||||
expect(updatedItems).toEqual([
|
||||
{ json: { foo: 'bar', bar: null, spam: undefined } },
|
||||
{ json: { foo: null, bar: null, spam: null } },
|
||||
{ json: { foo: 0, bar: NaN, spam: false } },
|
||||
]);
|
||||
});
|
||||
it('should do nothing', () => {
|
||||
const items = [
|
||||
{ json: { foo: 'bar', bar: '', spam: undefined } },
|
||||
{ json: { foo: '', bar: '', spam: '' } },
|
||||
{ json: { foo: 0, bar: NaN, spam: false } },
|
||||
];
|
||||
|
||||
const updatedItems = replaceEmptyStringsByNulls(items);
|
||||
|
||||
expect(updatedItems).toBeDefined();
|
||||
expect(updatedItems).toEqual(items);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, prepareItem', () => {
|
||||
it('should convert fixedCollection values to object', () => {
|
||||
const values = [
|
||||
{
|
||||
column: 'id',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'foo',
|
||||
value: 'bar',
|
||||
},
|
||||
{
|
||||
column: 'bar',
|
||||
value: 'foo',
|
||||
},
|
||||
];
|
||||
|
||||
const item = prepareItem(values);
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item).toEqual({
|
||||
id: '1',
|
||||
foo: 'bar',
|
||||
bar: 'foo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, checkItemAgainstSchema', () => {
|
||||
it('should not throw error', () => {
|
||||
const item = { foo: 'updated', id: 2 };
|
||||
const columnsInfo = [
|
||||
{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ column_name: 'json', data_type: 'json', is_nullable: 'NO' },
|
||||
{ column_name: 'foo', data_type: 'text', is_nullable: 'NO' },
|
||||
];
|
||||
|
||||
const result = checkItemAgainstSchema(node, item, columnsInfo, 0);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual(item);
|
||||
});
|
||||
it('should throw error on not existing column', () => {
|
||||
const item = { foo: 'updated', bar: 'updated' };
|
||||
const columnsInfo = [
|
||||
{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ column_name: 'json', data_type: 'json', is_nullable: 'NO' },
|
||||
{ column_name: 'foo', data_type: 'text', is_nullable: 'NO' },
|
||||
];
|
||||
|
||||
try {
|
||||
checkItemAgainstSchema(node, item, columnsInfo, 0);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual("Column 'bar' does not exist in selected table");
|
||||
}
|
||||
});
|
||||
it('should throw error on not nullable column', () => {
|
||||
const item = { foo: null };
|
||||
const columnsInfo = [
|
||||
{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ column_name: 'foo', data_type: 'text', is_nullable: 'NO' },
|
||||
];
|
||||
|
||||
try {
|
||||
checkItemAgainstSchema(node, item, columnsInfo, 0);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual("Column 'foo' is not nullable");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, hasJsonDataType', () => {
|
||||
it('returns true if there are columns which are of type json', () => {
|
||||
const schema: ColumnInfo[] = [
|
||||
{ column_name: 'data', data_type: 'json', is_nullable: 'YES' },
|
||||
{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' },
|
||||
];
|
||||
|
||||
expect(hasJsonDataTypeInSchema(schema)).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false if there are columns which are of type json', () => {
|
||||
const schema: ColumnInfo[] = [{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' }];
|
||||
|
||||
expect(hasJsonDataTypeInSchema(schema)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, convertValuesToJsonWithPgp', () => {
|
||||
const pgp = pgPromise();
|
||||
const pgpJsonSpy = jest.spyOn(pgp.as, 'json');
|
||||
const schema: ColumnInfo[] = [
|
||||
{ column_name: 'data', data_type: 'json', is_nullable: 'YES' },
|
||||
{ column_name: 'id', data_type: 'integer', is_nullable: 'NO' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
pgpJsonSpy.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
value: { data: [], id: 1 },
|
||||
expected: { data: '[]', id: 1 },
|
||||
},
|
||||
{
|
||||
value: { data: [0], id: 1 },
|
||||
expected: { data: '[0]', id: 1 },
|
||||
},
|
||||
{
|
||||
value: { data: { key: 2 }, id: 1 },
|
||||
expected: { data: '{"key":2}', id: 1 },
|
||||
},
|
||||
{
|
||||
value: { data: null, id: 1 },
|
||||
expected: { data: null, id: 1 },
|
||||
shouldSkipPgp: true,
|
||||
},
|
||||
{
|
||||
value: { data: undefined, id: 1 },
|
||||
expected: { data: undefined, id: 1 },
|
||||
shouldSkipPgp: true,
|
||||
},
|
||||
])('should convert $value.data to json correctly', ({ value, expected, shouldSkipPgp }) => {
|
||||
const data = value.data;
|
||||
expect(convertValuesToJsonWithPgp(pgp, schema, value)).toEqual(expected);
|
||||
expect(value).toEqual(expected);
|
||||
if (!shouldSkipPgp) {
|
||||
expect(pgpJsonSpy).toHaveBeenCalledWith(data, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, convertArraysToPostgresFormat', () => {
|
||||
it('should convert js arrays to postgres format', () => {
|
||||
const item = {
|
||||
jsonb_array: [
|
||||
{
|
||||
key: 'value44',
|
||||
},
|
||||
],
|
||||
json_array: [
|
||||
{
|
||||
key: 'value54',
|
||||
},
|
||||
],
|
||||
int_array: [1, 2, 5],
|
||||
text_array: ['one', 't"w"o'],
|
||||
bool_array: [true, false],
|
||||
};
|
||||
|
||||
const schema: ColumnInfo[] = [
|
||||
{
|
||||
column_name: 'id',
|
||||
data_type: 'integer',
|
||||
is_nullable: 'NO',
|
||||
udt_name: 'int4',
|
||||
column_default: "nextval('test_data_array_id_seq'::regclass)",
|
||||
},
|
||||
{
|
||||
column_name: 'jsonb_array',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_jsonb',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
column_name: 'json_array',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_json',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
column_name: 'int_array',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_int4',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
column_name: 'bool_array',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_bool',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
column_name: 'text_array',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_text',
|
||||
column_default: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertArraysToPostgresFormat(item, schema, node, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
jsonb_array: '{"{\\"key\\":\\"value44\\"}"}',
|
||||
json_array: '{"{\\"key\\":\\"value54\\"}"}',
|
||||
int_array: '{1,2,5}',
|
||||
text_array: '{"one","t\\"w\\"o"}',
|
||||
bool_array: '{"true","false"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the original data object', () => {
|
||||
const referenceItem = {
|
||||
arr: [1, 2, 3],
|
||||
};
|
||||
const item = {
|
||||
arr: [1, 2, 3],
|
||||
};
|
||||
const schema: ColumnInfo[] = [
|
||||
{
|
||||
column_name: 'arr',
|
||||
data_type: 'ARRAY',
|
||||
is_nullable: 'YES',
|
||||
udt_name: '_int4',
|
||||
column_default: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertArraysToPostgresFormat(item, schema, node, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
arr: '{1,2,3}',
|
||||
});
|
||||
expect(item).toEqual(referenceItem);
|
||||
});
|
||||
|
||||
describe('where clause handling', () => {
|
||||
const validOperations = [
|
||||
'equal',
|
||||
'=',
|
||||
'!=',
|
||||
'LIKE',
|
||||
'>',
|
||||
'<',
|
||||
'>=',
|
||||
'<=',
|
||||
'IS NULL',
|
||||
'IS NOT NULL',
|
||||
];
|
||||
const invalidOperations = ['=1 or 1--', '=>', ''];
|
||||
|
||||
test.each(validOperations)('isWhereClause returns true for "%s" operation', (operation) => {
|
||||
expect(
|
||||
isWhereClause({
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test.each(invalidOperations)('isWhereClause returns false for "%s" operation', (operation) => {
|
||||
expect(
|
||||
isWhereClause({
|
||||
column: 'name',
|
||||
condition: operation,
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isWhereClause returns false for when column is missing', () => {
|
||||
expect(
|
||||
isWhereClause({
|
||||
condition: 'equal',
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isWhereClause returns false for when condition is missing', () => {
|
||||
expect(
|
||||
isWhereClause({
|
||||
column: 'id',
|
||||
value: 'ok',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each(invalidOperations)(
|
||||
'getWhereClauses throws an exception for "%s" operation',
|
||||
(operation) => {
|
||||
const getNodeParameterMock = jest.fn().mockReturnValue({
|
||||
values: [
|
||||
{
|
||||
column: 'test',
|
||||
condition: '=',
|
||||
value: '3',
|
||||
},
|
||||
{
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = mock<IExecuteFunctions>({ getNodeParameter: getNodeParameterMock });
|
||||
expect(() => getWhereClauses(ctx, 0)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(validOperations)(
|
||||
'getWhereClauses returns valid clauses for "%s" operation',
|
||||
(operation) => {
|
||||
const clauses = [
|
||||
{
|
||||
column: 'name',
|
||||
condition: 'LIKE',
|
||||
value: 'Wohn Jick',
|
||||
},
|
||||
{
|
||||
column: 'id',
|
||||
condition: operation,
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
column: 'condition',
|
||||
condition: 'equal',
|
||||
value: 'angry',
|
||||
},
|
||||
];
|
||||
const getNodeParameterMock = jest.fn().mockReturnValue({
|
||||
values: clauses,
|
||||
});
|
||||
const ctx = mock<IExecuteFunctions>({ getNodeParameter: getNodeParameterMock });
|
||||
expect(getWhereClauses(ctx, 0)).toBe(clauses);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, runQueriesAndHandleErrors', () => {
|
||||
it.each([['single'], ['transaction']] as QueryMode[][])(
|
||||
'should return errors without running queries when batching is %s',
|
||||
async (batching) => {
|
||||
const runQueries: QueriesRunner = jest.fn().mockResolvedValue([]);
|
||||
const queries: QueryWithValues[] = [
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (1)', values: [] },
|
||||
];
|
||||
const nodeOptions: PostgresNodeOptions = { queryBatching: batching };
|
||||
const errorItemsMap: Map<number, INodeExecutionData> = new Map();
|
||||
errorItemsMap.set(1, { json: { error: new Error('Test error') }, pairedItem: { item: 1 } });
|
||||
|
||||
const result = await runQueriesAndHandleErrors(
|
||||
runQueries,
|
||||
queries,
|
||||
nodeOptions,
|
||||
errorItemsMap,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ json: { error: new Error('Test error') }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
expect(runQueries).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should run queries and return errors when batching is independently', async () => {
|
||||
const runQueries: QueriesRunner = jest.fn().mockResolvedValue([
|
||||
{ json: { id: 1 }, pairedItem: { item: 0 } },
|
||||
{ json: { id: 3 }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
const queries: QueryWithValues[] = [
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (1)', values: [] },
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (3)', values: [] },
|
||||
];
|
||||
const nodeOptions: PostgresNodeOptions = { queryBatching: 'independently' };
|
||||
const errorItemsMap: Map<number, INodeExecutionData> = new Map();
|
||||
errorItemsMap.set(1, { json: { error: new Error('Test error') }, pairedItem: { item: 1 } });
|
||||
|
||||
const result = await runQueriesAndHandleErrors(runQueries, queries, nodeOptions, errorItemsMap);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ json: { id: 1 }, pairedItem: { item: 0 } },
|
||||
{ json: { error: new Error('Test error') }, pairedItem: { item: 1 } },
|
||||
{ json: { id: 3 }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([['single'], ['transaction'], ['independently']] as QueryMode[][])(
|
||||
'should run queries when batching is %s and there are no errors',
|
||||
async (batching) => {
|
||||
const runQueries: QueriesRunner = jest.fn().mockResolvedValue([
|
||||
{ json: { id: 1 }, pairedItem: { item: 0 } },
|
||||
{ json: { id: 2 }, pairedItem: { item: 1 } },
|
||||
{ json: { id: 3 }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
const queries: QueryWithValues[] = [
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (1)', values: [] },
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (2)', values: [] },
|
||||
{ query: 'INSERT INTO my_table (id) VALUES (3)', values: [] },
|
||||
];
|
||||
const nodeOptions: PostgresNodeOptions = { queryBatching: batching };
|
||||
const errorItemsMap: Map<number, INodeExecutionData> = new Map();
|
||||
|
||||
const result = await runQueriesAndHandleErrors(
|
||||
runQueries,
|
||||
queries,
|
||||
nodeOptions,
|
||||
errorItemsMap,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ json: { id: 1 }, pairedItem: { item: 0 } },
|
||||
{ json: { id: 2 }, pairedItem: { item: 1 } },
|
||||
{ json: { id: 3 }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ICredentialTestFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
ITriggerFunctions,
|
||||
Logger,
|
||||
} from 'n8n-workflow';
|
||||
import { createServer, type AddressInfo, type Server } from 'node:net';
|
||||
import pgPromise from 'pg-promise';
|
||||
|
||||
import { ConnectionPoolManager } from '@utils/connection-pool-manager';
|
||||
import { LOCALHOST } from '@utils/constants';
|
||||
import { formatPrivateKey } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
ConnectionsData,
|
||||
PgpConnectionParameters,
|
||||
PostgresNodeCredentials,
|
||||
PostgresNodeOptions,
|
||||
} from '../v2/helpers/interfaces';
|
||||
|
||||
const getPostgresConfig = (
|
||||
credentials: PostgresNodeCredentials,
|
||||
options: PostgresNodeOptions = {},
|
||||
) => {
|
||||
const dbConfig: PgpConnectionParameters = {
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
database: credentials.database,
|
||||
user: credentials.user,
|
||||
password: credentials.password,
|
||||
keepAlive: true,
|
||||
max: credentials.maxConnections,
|
||||
};
|
||||
|
||||
if (options.connectionTimeout) {
|
||||
dbConfig.connectionTimeoutMillis = options.connectionTimeout * 1000;
|
||||
}
|
||||
|
||||
if (options.delayClosingIdleConnection) {
|
||||
dbConfig.keepAliveInitialDelayMillis = options.delayClosingIdleConnection * 1000;
|
||||
}
|
||||
|
||||
if (credentials.allowUnauthorizedCerts === true) {
|
||||
dbConfig.ssl = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
} else {
|
||||
dbConfig.ssl = !['disable', undefined].includes(credentials.ssl as string | undefined);
|
||||
// @ts-ignore these typings need to be updated
|
||||
dbConfig.sslmode = credentials.ssl || 'disable';
|
||||
}
|
||||
|
||||
return dbConfig;
|
||||
};
|
||||
|
||||
function withCleanupHandler(proxy: Server, abortController: AbortController, logger: Logger) {
|
||||
proxy.on('error', (error) => {
|
||||
logger.error('TCP Proxy: Got error, calling abort controller', { error });
|
||||
abortController.abort();
|
||||
});
|
||||
proxy.on('close', () => {
|
||||
logger.error('TCP Proxy: Was closed, calling abort controller');
|
||||
abortController.abort();
|
||||
});
|
||||
proxy.on('drop', (dropArgument) => {
|
||||
logger.error('TCP Proxy: Connection was dropped, calling abort controller', {
|
||||
dropArgument,
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
logger.debug('Got abort signal. Closing TCP proxy server.');
|
||||
proxy.close();
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export async function configurePostgres(
|
||||
this: IExecuteFunctions | ICredentialTestFunctions | ILoadOptionsFunctions | ITriggerFunctions,
|
||||
credentials: PostgresNodeCredentials,
|
||||
options: PostgresNodeOptions = {},
|
||||
): Promise<ConnectionsData> {
|
||||
const poolManager = ConnectionPoolManager.getInstance(this.logger);
|
||||
|
||||
const fallBackHandler = async (abortController: AbortController) => {
|
||||
const pgp = pgPromise({
|
||||
// prevent spam in console "WARNING: Creating a duplicate database object for the same connection."
|
||||
// duplicate connections created when auto loading parameters, they are closed immediately after, but several could be open at the same time
|
||||
noWarnings: true,
|
||||
});
|
||||
|
||||
if (typeof options.nodeVersion === 'number' && options.nodeVersion >= 2.1) {
|
||||
// Always return dates as ISO strings
|
||||
[pgp.pg.types.builtins.TIMESTAMP, pgp.pg.types.builtins.TIMESTAMPTZ].forEach((type) => {
|
||||
pgp.pg.types.setTypeParser(type, (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return parsedDate.toISOString();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (options.largeNumbersOutput === 'numbers') {
|
||||
pgp.pg.types.setTypeParser(20, (value: string) => {
|
||||
return parseInt(value, 10);
|
||||
});
|
||||
pgp.pg.types.setTypeParser(1700, (value: string) => {
|
||||
return parseFloat(value);
|
||||
});
|
||||
}
|
||||
|
||||
const dbConfig = getPostgresConfig(credentials, options);
|
||||
|
||||
if (!credentials.sshTunnel) {
|
||||
const db = pgp(dbConfig);
|
||||
|
||||
return { db, pgp };
|
||||
} else {
|
||||
if (credentials.sshAuthenticateWith === 'privateKey' && credentials.privateKey) {
|
||||
credentials.privateKey = formatPrivateKey(credentials.privateKey);
|
||||
}
|
||||
const sshClient = await this.helpers.getSSHClient(credentials, abortController);
|
||||
|
||||
// Create a TCP proxy listening on a random available port
|
||||
const proxy = withCleanupHandler(createServer(), abortController, this.logger);
|
||||
|
||||
const proxyPort = await new Promise<number>((resolve) => {
|
||||
proxy.listen(0, LOCALHOST, () => {
|
||||
resolve((proxy.address() as AddressInfo).port);
|
||||
});
|
||||
});
|
||||
|
||||
proxy.on('connection', (localSocket) => {
|
||||
sshClient.forwardOut(
|
||||
LOCALHOST,
|
||||
localSocket.remotePort!,
|
||||
credentials.host,
|
||||
credentials.port,
|
||||
(error, clientChannel) => {
|
||||
if (error) {
|
||||
this.logger.error('SSH Client: Port forwarding encountered an error', { error });
|
||||
abortController.abort();
|
||||
} else {
|
||||
localSocket.pipe(clientChannel);
|
||||
clientChannel.pipe(localSocket);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const db = pgp({
|
||||
...dbConfig,
|
||||
port: proxyPort,
|
||||
host: LOCALHOST,
|
||||
});
|
||||
|
||||
abortController.signal.addEventListener('abort', async () => {
|
||||
this.logger.debug('configurePostgres: Got abort signal, closing pg connection.');
|
||||
try {
|
||||
if (!db.$pool.ended) await db.$pool.end();
|
||||
} catch (error) {
|
||||
this.logger.error('configurePostgres: Encountered error while closing the pool.', {
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return { db, pgp, sshClient };
|
||||
}
|
||||
};
|
||||
|
||||
return await poolManager.getConnection({
|
||||
credentials,
|
||||
nodeType: 'postgres',
|
||||
nodeVersion: options.nodeVersion as unknown as string,
|
||||
fallBackHandler,
|
||||
wasUsed: ({ sshClient }) => {
|
||||
if (sshClient) {
|
||||
this.helpers.updateLastUsed(sshClient);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IExecuteFunctions,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { oldVersionNotice } from '@utils/descriptions';
|
||||
|
||||
import { pgInsertV2, pgQueryV2, pgUpdate, wrapData } from './genericFunctions';
|
||||
import { configurePostgres } from '../transport';
|
||||
import type { PgpConnection, PostgresNodeCredentials } from '../v2/helpers/interfaces';
|
||||
|
||||
const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Postgres',
|
||||
name: 'postgres',
|
||||
icon: 'file:postgres.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Get, add and update data in Postgres',
|
||||
defaults: {
|
||||
name: 'Postgres',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'postgres',
|
||||
required: true,
|
||||
testedBy: 'postgresConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
oldVersionNotice,
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
sqlDialect: 'PostgreSQL',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'SELECT id, name FROM product WHERE quantity > $1 AND price <= $2',
|
||||
required: true,
|
||||
description:
|
||||
'The SQL query to execute. You can use n8n expressions or $1 and $2 in conjunction with query parameters.',
|
||||
},
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Schema',
|
||||
name: 'schema',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: 'public',
|
||||
required: true,
|
||||
description: 'Name of the schema the table belongs to',
|
||||
},
|
||||
{
|
||||
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',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
|
||||
placeholder: 'id:int,name:text,description',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for the new rows. You can use type casting with colons (:) like id:int.',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Schema',
|
||||
name: 'schema',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: 'public',
|
||||
description: 'Name of the schema the table belongs to',
|
||||
},
|
||||
{
|
||||
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',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: 'id',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Comma-separated list of the properties which decides which rows in the database should be updated. Normally that would be "id".',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'name:text,description',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for rows to update. You can use type casting with colons (:) like id:int.',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// insert,update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Return Fields',
|
||||
name: 'returnFields',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert', 'update'],
|
||||
},
|
||||
},
|
||||
default: '*',
|
||||
description: 'Comma-separated list of the fields that the operation will return',
|
||||
},
|
||||
// ----------------------------------
|
||||
// Additional fields
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Independently',
|
||||
value: 'independently',
|
||||
description: 'Execute each query independently',
|
||||
},
|
||||
{
|
||||
name: 'Multiple Queries',
|
||||
value: 'multiple',
|
||||
description: '<b>Default</b>. Sends multiple queries at once to database.',
|
||||
},
|
||||
{
|
||||
name: 'Transaction',
|
||||
value: 'transaction',
|
||||
description: 'Executes all queries in a single transaction',
|
||||
},
|
||||
],
|
||||
default: 'multiple',
|
||||
description:
|
||||
'The way queries should be sent to database. Can be used in conjunction with <b>Continue on Fail</b>. See <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/">the docs</a> for more examples',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Large-Format Numbers As',
|
||||
name: 'largeNumbersOutput',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Numbers',
|
||||
value: 'numbers',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description:
|
||||
'Use this if you expect numbers longer than 16 digits (otherwise numbers may be incorrect)',
|
||||
},
|
||||
],
|
||||
hint: 'Applies to NUMERIC and BIGINT columns only',
|
||||
default: 'text',
|
||||
},
|
||||
{
|
||||
displayName: 'Query Parameters',
|
||||
name: 'queryParams',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['executeQuery'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'quantity,price',
|
||||
description:
|
||||
'Comma-separated list of properties which should be used as query parameters',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export class PostgresV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
async postgresConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as PostgresNodeCredentials;
|
||||
|
||||
let connection: PgpConnection | undefined;
|
||||
|
||||
try {
|
||||
const { db } = await configurePostgres.call(this, credentials, {});
|
||||
|
||||
// Acquires a new connection that can be used to to run multiple
|
||||
// queries on the same connection and must be released again
|
||||
// manually.
|
||||
connection = await db.connect();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
} finally {
|
||||
if (connection) {
|
||||
// release connection
|
||||
await connection.done();
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const largeNumbersOutput = this.getNodeParameter(
|
||||
'additionalFields.largeNumbersOutput',
|
||||
0,
|
||||
'',
|
||||
) as string;
|
||||
|
||||
const { db, pgp } = await configurePostgres.call(this, credentials, {
|
||||
largeNumbersOutput:
|
||||
largeNumbersOutput === 'numbers' || largeNumbersOutput === 'text'
|
||||
? largeNumbersOutput
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let returnItems: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
if (operation === 'executeQuery') {
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
|
||||
const queryResult = await pgQueryV2.call(this, pgp, db, items, this.continueOnFail());
|
||||
returnItems = queryResult as INodeExecutionData[];
|
||||
} else if (operation === 'insert') {
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
|
||||
const insertData = await pgInsertV2.call(this, pgp, db, items, this.continueOnFail());
|
||||
|
||||
// returnItems = this.helpers.returnJsonArray(insertData);
|
||||
returnItems = insertData as INodeExecutionData[];
|
||||
} else if (operation === 'update') {
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
|
||||
const updateItems = await pgUpdate(
|
||||
this.getNodeParameter,
|
||||
pgp,
|
||||
db,
|
||||
items,
|
||||
this.continueOnFail(),
|
||||
);
|
||||
|
||||
returnItems = wrapData(updateItems);
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
return [returnItems];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData, JsonObject } from 'n8n-workflow';
|
||||
import type pgPromise from 'pg-promise';
|
||||
import type pg from 'pg-promise/typescript/pg-subset';
|
||||
|
||||
import { getResolvables } from '@utils/utilities';
|
||||
|
||||
import type { PgpDatabase } from '../v2/helpers/interfaces';
|
||||
|
||||
/**
|
||||
* Returns of a shallow copy of the items which only contains the json data and
|
||||
* of that only the define properties
|
||||
*
|
||||
* @param {INodeExecutionData[]} items The items to copy
|
||||
* @param {string[]} properties The properties it should include
|
||||
*/
|
||||
export function getItemsCopy(
|
||||
items: INodeExecutionData[],
|
||||
properties: string[],
|
||||
guardedColumns?: { [key: string]: string },
|
||||
): IDataObject[] {
|
||||
let newItem: IDataObject;
|
||||
return items.map((item) => {
|
||||
newItem = {};
|
||||
if (guardedColumns) {
|
||||
Object.keys(guardedColumns).forEach((column) => {
|
||||
newItem[column] = item.json[guardedColumns[column]];
|
||||
});
|
||||
} else {
|
||||
for (const property of properties) {
|
||||
newItem[property] = item.json[property];
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns of a shallow copy of the item which only contains the json data and
|
||||
* of that only the define properties
|
||||
*
|
||||
* @param {INodeExecutionData} item The item to copy
|
||||
* @param {string[]} properties The properties it should include
|
||||
*/
|
||||
export function getItemCopy(
|
||||
item: INodeExecutionData,
|
||||
properties: string[],
|
||||
guardedColumns?: { [key: string]: string },
|
||||
): IDataObject {
|
||||
const newItem: IDataObject = {};
|
||||
if (guardedColumns) {
|
||||
Object.keys(guardedColumns).forEach((column) => {
|
||||
newItem[column] = item.json[guardedColumns[column]];
|
||||
});
|
||||
} else {
|
||||
for (const property of properties) {
|
||||
newItem[property] = item.json[property];
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a returning clause from a comma separated string
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param string returning The comma separated string
|
||||
*/
|
||||
export function generateReturning(pgp: pgPromise.IMain<{}, pg.IClient>, returning: string): string {
|
||||
return (
|
||||
' RETURNING ' +
|
||||
returning
|
||||
.split(',')
|
||||
.map((returnedField) => pgp.as.name(returnedField.trim()))
|
||||
.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapData(data: IDataObject[]): INodeExecutionData[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [{ json: data }];
|
||||
}
|
||||
return data.map((item) => ({
|
||||
json: item,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given SQL query on the database.
|
||||
*
|
||||
* @param {Function} getNodeParam The getter for the Node's parameters
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param {PgpDatabase} db The pgPromise database connection
|
||||
* @param {input[]} input The Node's input data
|
||||
*/
|
||||
export async function pgQuery(
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
getNodeParam: Function,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail: boolean,
|
||||
overrideMode?: string,
|
||||
): Promise<IDataObject[]> {
|
||||
const additionalFields = getNodeParam('additionalFields', 0) as IDataObject;
|
||||
|
||||
let valuesArray = [] as string[][];
|
||||
if (additionalFields.queryParams) {
|
||||
const propertiesString = additionalFields.queryParams as string;
|
||||
const properties = propertiesString.split(',').map((column) => column.trim());
|
||||
const paramsItems = getItemsCopy(items, properties);
|
||||
valuesArray = paramsItems.map((row) => properties.map((col) => row[col])) as string[][];
|
||||
}
|
||||
|
||||
const allQueries = [] as Array<{ query: string; values?: string[] }>;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const query = getNodeParam('query', i) as string;
|
||||
const values = valuesArray[i];
|
||||
const queryFormat = { query, values };
|
||||
allQueries.push(queryFormat);
|
||||
}
|
||||
|
||||
const mode = overrideMode ? overrideMode : ((additionalFields.mode ?? 'multiple') as string);
|
||||
if (mode === 'multiple') {
|
||||
return (await db.multi(pgp.helpers.concat(allQueries))).flat(1);
|
||||
} else if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < allQueries.length; i++) {
|
||||
try {
|
||||
Array.prototype.push.apply(
|
||||
result,
|
||||
await t.any(allQueries[i].query, allQueries[i].values),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...items[i].json,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < allQueries.length; i++) {
|
||||
try {
|
||||
Array.prototype.push.apply(
|
||||
result,
|
||||
await t.any(allQueries[i].query, allQueries[i].values),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...items[i].json,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
export async function pgQueryV2(
|
||||
this: IExecuteFunctions,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail: boolean,
|
||||
options?: {
|
||||
overrideMode?: string;
|
||||
resolveExpression?: boolean;
|
||||
},
|
||||
): Promise<IDataObject[]> {
|
||||
const additionalFields = this.getNodeParameter('additionalFields', 0);
|
||||
|
||||
let valuesArray = [] as string[][];
|
||||
if (additionalFields.queryParams) {
|
||||
const propertiesString = additionalFields.queryParams as string;
|
||||
const properties = propertiesString.split(',').map((column) => column.trim());
|
||||
const paramsItems = getItemsCopy(items, properties);
|
||||
valuesArray = paramsItems.map((row) => properties.map((col) => row[col])) as string[][];
|
||||
}
|
||||
|
||||
type QueryWithValues = { query: string; values?: string[] };
|
||||
const allQueries = new Array<QueryWithValues>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let query = this.getNodeParameter('query', i) as string;
|
||||
|
||||
if (options?.resolveExpression) {
|
||||
for (const resolvable of getResolvables(query)) {
|
||||
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
|
||||
}
|
||||
}
|
||||
|
||||
const values = valuesArray[i];
|
||||
const queryFormat = { query, values };
|
||||
allQueries.push(queryFormat);
|
||||
}
|
||||
|
||||
const mode = options?.overrideMode
|
||||
? options.overrideMode
|
||||
: ((additionalFields.mode ?? 'multiple') as string);
|
||||
if (mode === 'multiple') {
|
||||
return (await db.multi(pgp.helpers.concat(allQueries)))
|
||||
.map((result, i) => {
|
||||
return this.helpers.constructExecutionMetaData(wrapData(result as IDataObject[]), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
} else if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < allQueries.length; i++) {
|
||||
try {
|
||||
const transactionResult = await t.any(allQueries[i].query, allQueries[i].values);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(transactionResult as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
json: { ...items[i].json },
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
pairedItem: { item: i },
|
||||
} as INodeExecutionData);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < allQueries.length; i++) {
|
||||
try {
|
||||
const transactionResult = await t.any(allQueries[i].query, allQueries[i].values);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(transactionResult as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
json: { ...items[i].json },
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
pairedItem: { item: i },
|
||||
} as INodeExecutionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given items into the database.
|
||||
*
|
||||
* @param {Function} getNodeParam The getter for the Node's parameters
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param {PgpDatabase} db The pgPromise database connection
|
||||
* @param {INodeExecutionData[]} items The items to be inserted
|
||||
*/
|
||||
export async function pgInsert(
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
getNodeParam: Function,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail: boolean,
|
||||
overrideMode?: string,
|
||||
): Promise<IDataObject[]> {
|
||||
const table = getNodeParam('table', 0) as string;
|
||||
const schema = getNodeParam('schema', 0) as string;
|
||||
const columnString = getNodeParam('columns', 0) as string;
|
||||
const guardedColumns: { [key: string]: string } = {};
|
||||
|
||||
const columns = columnString
|
||||
.split(',')
|
||||
.map((column) => column.trim().split(':'))
|
||||
.map(([name, cast], i) => {
|
||||
guardedColumns[`column${i}`] = name;
|
||||
return { name, cast, prop: `column${i}` };
|
||||
});
|
||||
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
|
||||
const cs = new pgp.helpers.ColumnSet(columns, { table: { table, schema } });
|
||||
|
||||
const additionalFields = getNodeParam('additionalFields', 0) as IDataObject;
|
||||
const mode = overrideMode ? overrideMode : ((additionalFields.mode ?? 'multiple') as string);
|
||||
|
||||
const returning = generateReturning(pgp, getNodeParam('returnFields', 0) as string);
|
||||
if (mode === 'multiple') {
|
||||
const query =
|
||||
pgp.helpers.insert(getItemsCopy(items, columnNames, guardedColumns), cs) + returning;
|
||||
return await db.any(query);
|
||||
} else if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
result.push(await t.one(pgp.helpers.insert(itemCopy, cs) + returning));
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...itemCopy,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
const insertResult = await t.oneOrNone(pgp.helpers.insert(itemCopy, cs) + returning);
|
||||
if (insertResult !== null) {
|
||||
result.push(insertResult as IDataObject);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!continueOnFail) {
|
||||
throw err;
|
||||
}
|
||||
result.push({
|
||||
...itemCopy,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given items into the database.
|
||||
*
|
||||
* @param {Function} getNodeParam The getter for the Node's parameters
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param {PgpDatabase} db`` The pgPromise database connection
|
||||
* @param {INodeExecutionData[]} items The items to be inserted
|
||||
*/
|
||||
export async function pgInsertV2(
|
||||
this: IExecuteFunctions,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail: boolean,
|
||||
overrideMode?: string,
|
||||
): Promise<IDataObject[]> {
|
||||
const table = this.getNodeParameter('table', 0) as string;
|
||||
const schema = this.getNodeParameter('schema', 0) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const guardedColumns: { [key: string]: string } = {};
|
||||
|
||||
const columns = columnString
|
||||
.split(',')
|
||||
.map((column) => column.trim().split(':'))
|
||||
.map(([name, cast], i) => {
|
||||
guardedColumns[`column${i}`] = name;
|
||||
return { name, cast, prop: `column${i}` };
|
||||
});
|
||||
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
|
||||
const cs = new pgp.helpers.ColumnSet(columns, { table: { table, schema } });
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', 0);
|
||||
const mode = overrideMode ? overrideMode : ((additionalFields.mode ?? 'multiple') as string);
|
||||
|
||||
const returning = generateReturning(pgp, this.getNodeParameter('returnFields', 0) as string);
|
||||
if (mode === 'multiple') {
|
||||
const query =
|
||||
pgp.helpers.insert(getItemsCopy(items, columnNames, guardedColumns), cs) + returning;
|
||||
const queryResult = await db.any(query);
|
||||
return queryResult
|
||||
.map((result, i) => {
|
||||
return this.helpers.constructExecutionMetaData(wrapData(result as IDataObject[]), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
} else if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
const insertResult = await t.one(pgp.helpers.insert(itemCopy, cs) + returning);
|
||||
result.push(
|
||||
...this.helpers.constructExecutionMetaData(wrapData(insertResult as IDataObject[]), {
|
||||
itemData: { item: i },
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
json: { ...itemCopy },
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
pairedItem: { item: i },
|
||||
} as INodeExecutionData);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
const insertResult = await t.oneOrNone(pgp.helpers.insert(itemCopy, cs) + returning);
|
||||
if (insertResult !== null) {
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(insertResult as IDataObject[]),
|
||||
{
|
||||
itemData: { item: i },
|
||||
},
|
||||
);
|
||||
result.push(...executionData);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!continueOnFail) {
|
||||
throw err;
|
||||
}
|
||||
result.push({
|
||||
json: { ...itemCopy },
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
pairedItem: { item: i },
|
||||
} as INodeExecutionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given items in the database.
|
||||
*
|
||||
* @param {Function} getNodeParam The getter for the Node's parameters
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param {PgpDatabase} db The pgPromise database connection
|
||||
* @param {INodeExecutionData[]} items The items to be updated
|
||||
*/
|
||||
export async function pgUpdate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
getNodeParam: Function,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail = false,
|
||||
): Promise<IDataObject[]> {
|
||||
const table = getNodeParam('table', 0) as string;
|
||||
const schema = getNodeParam('schema', 0) as string;
|
||||
const updateKey = getNodeParam('updateKey', 0) as string;
|
||||
const columnString = getNodeParam('columns', 0) as string;
|
||||
const guardedColumns: { [key: string]: string } = {};
|
||||
|
||||
const columns: Array<{ name: string; cast: string; prop: string }> = columnString
|
||||
.split(',')
|
||||
.map((column) => column.trim().split(':'))
|
||||
.map(([name, cast], i) => {
|
||||
guardedColumns[`column${i}`] = name;
|
||||
return { name, cast, prop: `column${i}` };
|
||||
});
|
||||
|
||||
const updateKeys = updateKey.split(',').map((key, i) => {
|
||||
const [name, cast] = key.trim().split(':');
|
||||
const targetCol = columns.find((column) => column.name === name);
|
||||
const updateColumn = { name, cast, prop: targetCol ? targetCol.prop : `updateColumn${i}` };
|
||||
if (!targetCol) {
|
||||
guardedColumns[updateColumn.prop] = name;
|
||||
columns.unshift(updateColumn);
|
||||
} else if (!targetCol.cast) {
|
||||
targetCol.cast = updateColumn.cast || targetCol.cast;
|
||||
}
|
||||
return updateColumn;
|
||||
});
|
||||
|
||||
const additionalFields = getNodeParam('additionalFields', 0) as IDataObject;
|
||||
const mode = additionalFields.mode ?? ('multiple' as string);
|
||||
|
||||
const cs = new pgp.helpers.ColumnSet(columns, { table: { table, schema } });
|
||||
|
||||
// Prepare the data to update and copy it to be returned
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
const updateItems = getItemsCopy(items, columnNames, guardedColumns);
|
||||
|
||||
const returning = generateReturning(pgp, getNodeParam('returnFields', 0) as string);
|
||||
if (mode === 'multiple') {
|
||||
const query =
|
||||
(pgp.helpers.update(updateItems, cs) as string) +
|
||||
' WHERE ' +
|
||||
updateKeys
|
||||
.map((entry) => {
|
||||
const key = pgp.as.name(entry.name);
|
||||
return 'v.' + key + ' = t.' + key;
|
||||
})
|
||||
.join(' AND ') +
|
||||
returning;
|
||||
return await db.any(query);
|
||||
} else {
|
||||
const where =
|
||||
' WHERE ' +
|
||||
updateKeys
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
.map((entry) => pgp.as.name(entry.name) + ' = ${' + entry.prop + '}')
|
||||
.join(' AND ');
|
||||
if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
Array.prototype.push.apply(
|
||||
result,
|
||||
await t.any(
|
||||
(pgp.helpers.update(itemCopy, cs) as string) +
|
||||
pgp.as.format(where, itemCopy) +
|
||||
returning,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...itemCopy,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
Array.prototype.push.apply(
|
||||
result,
|
||||
await t.any(
|
||||
(pgp.helpers.update(itemCopy, cs) as string) +
|
||||
pgp.as.format(where, itemCopy) +
|
||||
returning,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...itemCopy,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given items in the database.
|
||||
*
|
||||
* @param {Function} getNodeParam The getter for the Node's parameters
|
||||
* @param {pgPromise.IMain<{}, pg.IClient>} pgp The pgPromise instance
|
||||
* @param {PgpDatabase} db The pgPromise database connection
|
||||
* @param {INodeExecutionData[]} items The items to be updated
|
||||
*/
|
||||
export async function pgUpdateV2(
|
||||
this: IExecuteFunctions,
|
||||
pgp: pgPromise.IMain<{}, pg.IClient>,
|
||||
db: PgpDatabase,
|
||||
items: INodeExecutionData[],
|
||||
continueOnFail = false,
|
||||
): Promise<IDataObject[]> {
|
||||
const table = this.getNodeParameter('table', 0) as string;
|
||||
const schema = this.getNodeParameter('schema', 0) as string;
|
||||
const updateKey = this.getNodeParameter('updateKey', 0) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const guardedColumns: { [key: string]: string } = {};
|
||||
|
||||
const columns: Array<{ name: string; cast: string; prop: string }> = columnString
|
||||
.split(',')
|
||||
.map((column) => column.trim().split(':'))
|
||||
.map(([name, cast], i) => {
|
||||
guardedColumns[`column${i}`] = name;
|
||||
return { name, cast, prop: `column${i}` };
|
||||
});
|
||||
|
||||
const updateKeys = updateKey.split(',').map((key, i) => {
|
||||
const [name, cast] = key.trim().split(':');
|
||||
const targetCol = columns.find((column) => column.name === name);
|
||||
const updateColumn = { name, cast, prop: targetCol ? targetCol.prop : `updateColumn${i}` };
|
||||
if (!targetCol) {
|
||||
guardedColumns[updateColumn.prop] = name;
|
||||
columns.unshift(updateColumn);
|
||||
} else if (!targetCol.cast) {
|
||||
targetCol.cast = updateColumn.cast || targetCol.cast;
|
||||
}
|
||||
return updateColumn;
|
||||
});
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', 0);
|
||||
const mode = additionalFields.mode ?? ('multiple' as string);
|
||||
|
||||
const cs = new pgp.helpers.ColumnSet(columns, { table: { table, schema } });
|
||||
|
||||
// Prepare the data to update and copy it to be returned
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
const updateItems = getItemsCopy(items, columnNames, guardedColumns);
|
||||
|
||||
const returning = generateReturning(pgp, this.getNodeParameter('returnFields', 0) as string);
|
||||
if (mode === 'multiple') {
|
||||
const query =
|
||||
(pgp.helpers.update(updateItems, cs) as string) +
|
||||
' WHERE ' +
|
||||
updateKeys
|
||||
.map((entry) => {
|
||||
const key = pgp.as.name(entry.name);
|
||||
return 'v.' + key + ' = t.' + key;
|
||||
})
|
||||
.join(' AND ') +
|
||||
returning;
|
||||
const updateResult = await db.any(query);
|
||||
return updateResult;
|
||||
} else {
|
||||
const where =
|
||||
' WHERE ' +
|
||||
updateKeys
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
.map((entry) => pgp.as.name(entry.name) + ' = ${' + entry.prop + '}')
|
||||
.join(' AND ');
|
||||
if (mode === 'transaction') {
|
||||
return await db.tx(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
const transactionResult = await t.any(
|
||||
(pgp.helpers.update(itemCopy, cs) as string) +
|
||||
pgp.as.format(where, itemCopy) +
|
||||
returning,
|
||||
);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(transactionResult as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
...itemCopy,
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} else if (mode === 'independently') {
|
||||
return await db.task(async (t) => {
|
||||
const result: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const itemCopy = getItemCopy(items[i], columnNames, guardedColumns);
|
||||
try {
|
||||
const independentResult = await t.any(
|
||||
(pgp.helpers.update(itemCopy, cs) as string) +
|
||||
pgp.as.format(where, itemCopy) +
|
||||
returning,
|
||||
);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(independentResult as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
if (!continueOnFail) throw err;
|
||||
result.push({
|
||||
json: { ...items[i].json },
|
||||
code: (err as JsonObject).code,
|
||||
message: (err as JsonObject).message,
|
||||
pairedItem: { item: i },
|
||||
} as INodeExecutionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new ApplicationError('multiple, independently or transaction are valid options', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
import { credentialTest, listSearch, loadOptions, resourceMapping } from './methods';
|
||||
|
||||
export class PostgresV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = { credentialTest, listSearch, loadOptions, resourceMapping };
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { INodeProperties, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
export const operatorOptions: INodePropertyOptions[] = [
|
||||
{
|
||||
name: 'Equal',
|
||||
value: 'equal',
|
||||
},
|
||||
{
|
||||
name: 'Not Equal',
|
||||
value: '!=',
|
||||
},
|
||||
{
|
||||
name: 'Like',
|
||||
value: 'LIKE',
|
||||
},
|
||||
{
|
||||
name: 'Greater Than',
|
||||
value: '>',
|
||||
},
|
||||
{
|
||||
name: 'Less Than',
|
||||
value: '<',
|
||||
},
|
||||
{
|
||||
name: 'Greater Than Or Equal',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
name: 'Less Than Or Equal',
|
||||
value: '<=',
|
||||
},
|
||||
{
|
||||
name: 'Is Null',
|
||||
value: 'IS NULL',
|
||||
},
|
||||
{
|
||||
name: 'Is Not Null',
|
||||
value: 'IS NOT NULL',
|
||||
},
|
||||
];
|
||||
|
||||
export const optionsCollection: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Cascade',
|
||||
name: 'cascade',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to drop all objects that depend on the table, such as views and sequences',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['deleteTable'],
|
||||
},
|
||||
hide: {
|
||||
'/deleteCommand': ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Connection Timeout',
|
||||
name: 'connectionTimeout',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
description: 'Number of seconds reserved for connecting to the database',
|
||||
},
|
||||
{
|
||||
displayName: 'Delay Closing Idle Connection',
|
||||
name: 'delayClosingIdleConnection',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Number of seconds to wait before idle connection would be eligible for closing',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Query Batching',
|
||||
name: 'queryBatching',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Single Query',
|
||||
value: 'single',
|
||||
description: 'A single query for all incoming items',
|
||||
},
|
||||
{
|
||||
name: 'Independent',
|
||||
value: 'independently',
|
||||
description: 'Execute one query per incoming item of the run',
|
||||
},
|
||||
{
|
||||
name: 'Transaction',
|
||||
value: 'transaction',
|
||||
description:
|
||||
'Execute all queries in a transaction, if a failure occurs, all changes are rolled back',
|
||||
},
|
||||
],
|
||||
default: 'single',
|
||||
description: 'The way queries should be sent to the database',
|
||||
},
|
||||
{
|
||||
displayName: 'Query Parameters',
|
||||
name: 'queryReplacement',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of the values you want to use as query parameters. <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…',
|
||||
placeholder: 'e.g. value1,value2,value3',
|
||||
displayOptions: {
|
||||
show: { '/operation': ['executeQuery'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
displayName: 'Treat query parameters in single quotes as text',
|
||||
name: 'treatQueryParametersInSingleQuotesAsText',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether to treat query parameters enclosed in single quotes as text e.g. '$1'",
|
||||
displayOptions: {
|
||||
show: { queryReplacement: [{ _cnd: { exists: true } }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Output Columns',
|
||||
name: 'outputColumns',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsMultiOptions',
|
||||
loadOptionsDependsOn: ['table.value'],
|
||||
},
|
||||
default: [],
|
||||
displayOptions: {
|
||||
show: { '/operation': ['select', 'insert', 'update', 'upsert'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Large-Format Numbers As',
|
||||
name: 'largeNumbersOutput',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Numbers',
|
||||
value: 'numbers',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description:
|
||||
'Use this if you expect numbers longer than 16 digits (otherwise numbers may be incorrect)',
|
||||
},
|
||||
],
|
||||
hint: 'Applies to NUMERIC and BIGINT columns only',
|
||||
default: 'text',
|
||||
},
|
||||
{
|
||||
displayName: 'Skip on Conflict',
|
||||
name: 'skipOnConflict',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to skip the row and do not throw error if a unique constraint or exclusion constraint is violated',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['insert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Replace Empty Strings with NULL',
|
||||
name: 'replaceEmptyStrings',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to replace empty strings with NULL in input, could be useful when data come from spreadsheet',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['insert', 'update', 'upsert', 'executeQuery'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const schemaRLC: INodeProperties = {
|
||||
displayName: 'Schema',
|
||||
name: 'schema',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: 'public' },
|
||||
required: true,
|
||||
placeholder: 'e.g. public',
|
||||
description: 'The schema that contains the table you want to work on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'schemaSearch',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const tableRLC: INodeProperties = {
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'The table you want to work on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tableSearch',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const whereFixedCollection: INodeProperties = {
|
||||
displayName: 'Select Rows',
|
||||
name: 'where',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Condition',
|
||||
default: {},
|
||||
description: 'If not set, all rows will be selected',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
default: '',
|
||||
placeholder: 'e.g. ID',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Operator',
|
||||
name: 'condition',
|
||||
type: 'options',
|
||||
description:
|
||||
"The operator to check the column against. When using 'LIKE' operator percent sign ( %) matches zero or more characters, underscore ( _ ) matches any single character.",
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: operatorOptions,
|
||||
default: 'equal',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
condition: ['IS NULL', 'IS NOT NULL'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sortFixedCollection: INodeProperties = {
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Sort Rule',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Direction',
|
||||
name: 'direction',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'ASC',
|
||||
value: 'ASC',
|
||||
},
|
||||
{
|
||||
name: 'DESC',
|
||||
value: 'DESC',
|
||||
},
|
||||
],
|
||||
default: 'ASC',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combineConditionsCollection: INodeProperties = {
|
||||
displayName: 'Combine Conditions',
|
||||
name: 'combineConditions',
|
||||
type: 'options',
|
||||
description:
|
||||
'How to combine the conditions defined in "Select Rows": AND requires all conditions to be true, OR requires at least one condition to be true',
|
||||
options: [
|
||||
{
|
||||
name: 'AND',
|
||||
value: 'AND',
|
||||
description: 'Only rows that meet all the conditions are selected',
|
||||
},
|
||||
{
|
||||
name: 'OR',
|
||||
value: 'OR',
|
||||
description: 'Rows that meet at least one condition are selected',
|
||||
},
|
||||
],
|
||||
default: 'AND',
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteTable from './deleteTable.operation';
|
||||
import * as executeQuery from './executeQuery.operation';
|
||||
import * as insert from './insert.operation';
|
||||
import * as select from './select.operation';
|
||||
import * as update from './update.operation';
|
||||
import * as upsert from './upsert.operation';
|
||||
import { schemaRLC, tableRLC } from '../common.descriptions';
|
||||
|
||||
export { deleteTable, executeQuery, insert, select, update, upsert };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteTable',
|
||||
description: 'Delete an entire table or rows in a table',
|
||||
action: 'Delete table or rows',
|
||||
},
|
||||
{
|
||||
name: 'Execute Query',
|
||||
value: 'executeQuery',
|
||||
description: 'Execute an SQL query',
|
||||
action: 'Execute a SQL query',
|
||||
},
|
||||
{
|
||||
name: 'Insert',
|
||||
value: 'insert',
|
||||
description: 'Insert rows in a table',
|
||||
action: 'Insert rows in a table',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-option-name-wrong-for-upsert
|
||||
name: 'Insert or Update',
|
||||
value: 'upsert',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-upsert
|
||||
description: 'Insert or update rows in a table',
|
||||
action: 'Insert or update rows in a table',
|
||||
},
|
||||
{
|
||||
name: 'Select',
|
||||
value: 'select',
|
||||
description: 'Select rows from a table',
|
||||
action: 'Select rows from a table',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update rows in a table',
|
||||
action: 'Update rows in a table',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
},
|
||||
},
|
||||
default: 'insert',
|
||||
},
|
||||
{ ...schemaRLC, displayOptions: { hide: { operation: ['executeQuery'] } } },
|
||||
{ ...tableRLC, displayOptions: { hide: { operation: ['executeQuery'] } } },
|
||||
...deleteTable.description,
|
||||
...executeQuery.description,
|
||||
...insert.description,
|
||||
...select.description,
|
||||
...update.description,
|
||||
...upsert.description,
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import { addWhereClauses, getWhereClauses } from '../../helpers/utils';
|
||||
import {
|
||||
combineConditionsCollection,
|
||||
optionsCollection,
|
||||
whereFixedCollection,
|
||||
} from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Command',
|
||||
name: 'deleteCommand',
|
||||
type: 'options',
|
||||
default: 'truncate',
|
||||
options: [
|
||||
{
|
||||
name: 'Truncate',
|
||||
value: 'truncate',
|
||||
description: "Only removes the table's data and preserves the table's structure",
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description:
|
||||
"Delete the rows that match the 'Select Rows' conditions below. If no selection is made, all rows in the table are deleted.",
|
||||
},
|
||||
{
|
||||
name: 'Drop',
|
||||
value: 'drop',
|
||||
description: "Deletes the table's data and also the table's structure permanently",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Restart Sequences',
|
||||
name: 'restartSequences',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to reset identity (auto-increment) columns to their initial values',
|
||||
displayOptions: {
|
||||
show: {
|
||||
deleteCommand: ['truncate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...whereFixedCollection,
|
||||
displayOptions: {
|
||||
show: {
|
||||
deleteCommand: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineConditionsCollection,
|
||||
displayOptions: {
|
||||
show: {
|
||||
deleteCommand: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['deleteTable'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
_db?: PgpDatabase,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const schema = this.getNodeParameter('schema', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const deleteCommand = this.getNodeParameter('deleteCommand', i) as string;
|
||||
|
||||
let query = '';
|
||||
let values: QueryValues = [schema, table];
|
||||
|
||||
if (deleteCommand === 'drop') {
|
||||
const cascade = options.cascade ? ' CASCADE' : '';
|
||||
query = `DROP TABLE IF EXISTS $1:name.$2:name${cascade}`;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'truncate') {
|
||||
const identity = this.getNodeParameter('restartSequences', i, false)
|
||||
? ' RESTART IDENTITY'
|
||||
: '';
|
||||
const cascade = options.cascade ? ' CASCADE' : '';
|
||||
query = `TRUNCATE TABLE $1:name.$2:name${identity}${cascade}`;
|
||||
}
|
||||
|
||||
if (deleteCommand === 'delete') {
|
||||
const whereClauses = getWhereClauses(this, i);
|
||||
|
||||
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string;
|
||||
|
||||
[query, values] = addWhereClauses(
|
||||
this.getNode(),
|
||||
i,
|
||||
'DELETE FROM $1:name.$2:name',
|
||||
whereClauses,
|
||||
values,
|
||||
combineConditions,
|
||||
);
|
||||
}
|
||||
|
||||
if (query === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Invalid delete command, only drop, delete and truncate are supported ',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
const queryWithValues = { query, values };
|
||||
|
||||
queries.push(queryWithValues);
|
||||
}
|
||||
|
||||
return await runQueries(queries, nodeOptions);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
evaluateExpression,
|
||||
isJSON,
|
||||
replaceEmptyStringsByNulls,
|
||||
stringToArray,
|
||||
} from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. SELECT id, name FROM product WHERE quantity > $1 AND price <= $2',
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
description:
|
||||
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
sqlDialect: 'PostgreSQL',
|
||||
},
|
||||
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
_db?: PgpDatabase,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const queries: QueryWithValues[] = replaceEmptyStringsByNulls(
|
||||
items,
|
||||
nodeOptions.replaceEmptyStrings as boolean,
|
||||
).map((_, index) => {
|
||||
let query = this.getNodeParameter('query', index) as string;
|
||||
|
||||
for (const resolvable of getResolvables(query)) {
|
||||
query = query.replace(resolvable, this.evaluateExpression(resolvable, index) as string);
|
||||
}
|
||||
|
||||
let values: Array<IDataObject | string> = [];
|
||||
|
||||
let queryReplacement = this.getNodeParameter('options.queryReplacement', index, '');
|
||||
|
||||
if (typeof queryReplacement === 'number') {
|
||||
queryReplacement = String(queryReplacement);
|
||||
}
|
||||
|
||||
if (typeof queryReplacement === 'string') {
|
||||
const node = this.getNode();
|
||||
|
||||
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);
|
||||
if (resolvables.length) {
|
||||
for (const resolvable of resolvables) {
|
||||
const evaluatedExpression = evaluateExpression(
|
||||
this.evaluateExpression(`${resolvable}`, index),
|
||||
);
|
||||
const evaluatedValues = isJSON(evaluatedExpression)
|
||||
? [evaluatedExpression]
|
||||
: stringToArray(evaluatedExpression);
|
||||
|
||||
if (evaluatedValues.length) values.push(...evaluatedValues);
|
||||
}
|
||||
} else {
|
||||
values.push(...stringToArray(rawValues));
|
||||
}
|
||||
} else {
|
||||
const rawValues = rawReplacements
|
||||
.replace(/^=+/, '')
|
||||
.split(',')
|
||||
.filter((entry) => entry)
|
||||
.map((entry) => entry.trim());
|
||||
|
||||
for (const rawValue of rawValues) {
|
||||
const resolvables = getResolvables(rawValue);
|
||||
|
||||
if (resolvables.length) {
|
||||
for (const resolvable of resolvables) {
|
||||
values.push(this.evaluateExpression(`${resolvable}`, index) as IDataObject);
|
||||
}
|
||||
} else {
|
||||
values.push(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(queryReplacement)) {
|
||||
values = queryReplacement as IDataObject[];
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Query Parameters must be a string of comma-separated values or an array of values',
|
||||
{ itemIndex: index },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!queryReplacement || nodeOptions.treatQueryParametersInSingleQuotesAsText) {
|
||||
let nextValueIndex = values.length + 1;
|
||||
const literals = query.match(/'\$[0-9]+'/g) ?? [];
|
||||
for (const literal of literals) {
|
||||
query = query.replace(literal, `$${nextValueIndex}`);
|
||||
values.push(literal.replace(/'/g, ''));
|
||||
nextValueIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return { query, values, options: { partial: true } };
|
||||
});
|
||||
|
||||
return await runQueries(queries, nodeOptions);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpClient,
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
addReturning,
|
||||
checkItemAgainstSchema,
|
||||
configureTableSchemaUpdater,
|
||||
getTableSchema,
|
||||
prepareItem,
|
||||
convertArraysToPostgresFormat,
|
||||
replaceEmptyStringsByNulls,
|
||||
hasJsonDataTypeInSchema,
|
||||
convertValuesToJsonWithPgp,
|
||||
runQueriesAndHandleErrors,
|
||||
} from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Manually',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: 'autoMapInputData',
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['autoMapInputData'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'operation'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['insert'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
db: PgpDatabase,
|
||||
pgp: PgpClient,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
items = replaceEmptyStringsByNulls(items, nodeOptions.replaceEmptyStrings as boolean);
|
||||
const nodeVersion = nodeOptions.nodeVersion as number;
|
||||
|
||||
let schema = this.getNodeParameter('schema', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
let table = this.getNodeParameter('table', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const updateTableSchema = configureTableSchemaUpdater(schema, table);
|
||||
|
||||
let tableSchema = await getTableSchema(db, schema, table);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
const errorItemsMap = new Map<number, INodeExecutionData>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
schema = this.getNodeParameter('schema', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
let onConflict = '';
|
||||
if (options.skipOnConflict) {
|
||||
onConflict = ' ON CONFLICT DO NOTHING';
|
||||
}
|
||||
|
||||
let query = `INSERT INTO $1:name.$2:name($3:name) VALUES($3:csv)${onConflict}`;
|
||||
let values: QueryValues = [schema, table];
|
||||
|
||||
const dataMode =
|
||||
nodeVersion < 2.2
|
||||
? (this.getNodeParameter('dataMode', i) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', i) as string);
|
||||
|
||||
let item: IDataObject = {};
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
item = items[i].json;
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const valuesToSend =
|
||||
nodeVersion < 2.2
|
||||
? ((this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[])
|
||||
: ((this.getNodeParameter('columns.values', i, []) as IDataObject)
|
||||
.values as IDataObject[]);
|
||||
|
||||
item =
|
||||
nodeVersion < 2.2
|
||||
? prepareItem(valuesToSend)
|
||||
: hasJsonDataTypeInSchema(tableSchema)
|
||||
? convertValuesToJsonWithPgp(
|
||||
pgp,
|
||||
tableSchema,
|
||||
(this.getNodeParameter('columns', i) as IDataObject)?.value as IDataObject,
|
||||
)
|
||||
: (this.getNodeParameter('columns.value', i) as IDataObject);
|
||||
}
|
||||
|
||||
tableSchema = await updateTableSchema(db, tableSchema, schema, table);
|
||||
|
||||
if (nodeVersion >= 2.4) {
|
||||
item = convertArraysToPostgresFormat(item, tableSchema, this.getNode(), i);
|
||||
}
|
||||
|
||||
values.push(checkItemAgainstSchema(this.getNode(), item, tableSchema, i));
|
||||
|
||||
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
|
||||
|
||||
if (nodeVersion >= 2.6 && Object.keys(item).length === 0) {
|
||||
query = 'INSERT INTO $1:name.$2:name DEFAULT VALUES';
|
||||
}
|
||||
|
||||
[query, values] = addReturning(query, outputColumns, values);
|
||||
|
||||
queries.push({ query, values });
|
||||
} catch (e) {
|
||||
if (this.continueOnFail()) {
|
||||
const error = e instanceof Error ? e : String(e);
|
||||
errorItemsMap.set(i, { json: { error }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return await runQueriesAndHandleErrors(runQueries, queries, nodeOptions, errorItemsMap);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
tryToParseNumber,
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
SortRule,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
addSortRules,
|
||||
addWhereClauses,
|
||||
getWhereClauses,
|
||||
replaceEmptyStringsByNulls,
|
||||
} from '../../helpers/utils';
|
||||
import {
|
||||
combineConditionsCollection,
|
||||
optionsCollection,
|
||||
sortFixedCollection,
|
||||
whereFixedCollection,
|
||||
} from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['event'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
default: 50,
|
||||
description: 'Max number of results to return',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
whereFixedCollection,
|
||||
combineConditionsCollection,
|
||||
sortFixedCollection,
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['select'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
_db?: PgpDatabase,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
items = replaceEmptyStringsByNulls(items, nodeOptions.replaceEmptyStrings as boolean);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const schema = this.getNodeParameter('schema', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
let values: QueryValues = [schema, table];
|
||||
|
||||
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
|
||||
|
||||
let query = '';
|
||||
|
||||
if (outputColumns.includes('*')) {
|
||||
query = 'SELECT * FROM $1:name.$2:name';
|
||||
} else {
|
||||
values.push(outputColumns);
|
||||
query = `SELECT $${values.length}:name FROM $1:name.$2:name`;
|
||||
}
|
||||
|
||||
const whereClauses = getWhereClauses(this, i);
|
||||
|
||||
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string;
|
||||
|
||||
[query, values] = addWhereClauses(
|
||||
this.getNode(),
|
||||
i,
|
||||
query,
|
||||
whereClauses,
|
||||
values,
|
||||
combineConditions,
|
||||
);
|
||||
|
||||
const sortRules =
|
||||
((this.getNodeParameter('sort', i, []) as IDataObject).values as SortRule[]) || [];
|
||||
|
||||
[query, values] = addSortRules(query, sortRules, values);
|
||||
|
||||
const returnAll = this.getNodeParameter('returnAll', i, false);
|
||||
if (!returnAll) {
|
||||
const limitRaw = this.getNodeParameter('limit', i, 50);
|
||||
const limit = tryToParseNumber(limitRaw);
|
||||
values.push(limit);
|
||||
query += ` LIMIT $${values.length}`;
|
||||
}
|
||||
|
||||
const queryWithValues = { query, values };
|
||||
queries.push(queryWithValues);
|
||||
}
|
||||
|
||||
return await runQueries(queries, nodeOptions);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
addReturning,
|
||||
checkItemAgainstSchema,
|
||||
configureTableSchemaUpdater,
|
||||
doesRowExist,
|
||||
getTableSchema,
|
||||
prepareItem,
|
||||
convertArraysToPostgresFormat,
|
||||
replaceEmptyStringsByNulls,
|
||||
runQueriesAndHandleErrors,
|
||||
} from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Manually',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: 'autoMapInputData',
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['autoMapInputData'],
|
||||
'@version': [2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to Match On',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'The column to compare when finding the rows to update. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>.',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: '',
|
||||
hint: 'The column to use when matching rows in Postgres to the input items of this node. Usually an ID.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Rows with a value in the specified "Column to Match On" that corresponds to the value in this field will be updated',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'operation'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['update'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
db: PgpDatabase,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
items = replaceEmptyStringsByNulls(items, nodeOptions.replaceEmptyStrings as boolean);
|
||||
const nodeVersion = nodeOptions.nodeVersion as number;
|
||||
|
||||
let schema = this.getNodeParameter('schema', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
let table = this.getNodeParameter('table', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const updateTableSchema = configureTableSchemaUpdater(schema, table);
|
||||
|
||||
let tableSchema = await getTableSchema(db, schema, table);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
const errorItemsMap = new Map<number, INodeExecutionData>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
schema = this.getNodeParameter('schema', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 2.2
|
||||
? [this.getNodeParameter('columnToMatchOn', i) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', i) as string[]);
|
||||
|
||||
const dataMode =
|
||||
nodeVersion < 2.2
|
||||
? (this.getNodeParameter('dataMode', i) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', i) as string);
|
||||
|
||||
let item: IDataObject = {};
|
||||
let valueToMatchOn: string | IDataObject = '';
|
||||
if (nodeVersion < 2.2) {
|
||||
valueToMatchOn = this.getNodeParameter('valueToMatchOn', i) as string;
|
||||
}
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
item = items[i].json;
|
||||
if (nodeVersion < 2.2) {
|
||||
valueToMatchOn = item[columnsToMatchOn[0]] as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const valuesToSend =
|
||||
nodeVersion < 2.2
|
||||
? ((this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[])
|
||||
: ((this.getNodeParameter('columns.values', i, []) as IDataObject)
|
||||
.values as IDataObject[]);
|
||||
|
||||
if (nodeVersion < 2.2) {
|
||||
item = prepareItem(valuesToSend);
|
||||
item[columnsToMatchOn[0]] = this.getNodeParameter('valueToMatchOn', i) as string;
|
||||
} else {
|
||||
item = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
}
|
||||
}
|
||||
|
||||
const matchValues: string[] = [];
|
||||
if (nodeVersion < 2.2) {
|
||||
if (!item[columnsToMatchOn[0]] && dataMode === 'autoMapInputData') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Column to match on not found in input item. Add a column to match on or set the 'Data Mode' to 'Define Below' to define the value to match on.",
|
||||
);
|
||||
}
|
||||
matchValues.push(valueToMatchOn);
|
||||
matchValues.push(columnsToMatchOn[0]);
|
||||
} else {
|
||||
columnsToMatchOn.forEach((column) => {
|
||||
matchValues.push(column);
|
||||
matchValues.push(item[column] as string);
|
||||
});
|
||||
const rowExists = await doesRowExist(db, schema, table, matchValues);
|
||||
if (!rowExists) {
|
||||
const descriptionValues: string[] = [];
|
||||
matchValues.forEach((_, index) => {
|
||||
if (index % 2 === 0) {
|
||||
descriptionValues.push(`${matchValues[index]}=${matchValues[index + 1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The row you are trying to update doesn't exist",
|
||||
{
|
||||
description: `No rows matching the provided values (${descriptionValues.join(
|
||||
', ',
|
||||
)}) were found in the table "${table}".`,
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tableSchema = await updateTableSchema(db, tableSchema, schema, table);
|
||||
|
||||
if (nodeVersion >= 2.4) {
|
||||
item = convertArraysToPostgresFormat(item, tableSchema, this.getNode(), i);
|
||||
}
|
||||
|
||||
item = checkItemAgainstSchema(this.getNode(), item, tableSchema, i);
|
||||
|
||||
let values: QueryValues = [schema, table];
|
||||
|
||||
let valuesLength = values.length + 1;
|
||||
|
||||
let condition = '';
|
||||
if (nodeVersion < 2.2) {
|
||||
condition = `$${valuesLength}:name = $${valuesLength + 1}`;
|
||||
valuesLength = valuesLength + 2;
|
||||
values.push(columnsToMatchOn[0], valueToMatchOn);
|
||||
} else {
|
||||
const conditions: string[] = [];
|
||||
for (const column of columnsToMatchOn) {
|
||||
conditions.push(`$${valuesLength}:name = $${valuesLength + 1}`);
|
||||
valuesLength = valuesLength + 2;
|
||||
values.push(column, item[column] as string);
|
||||
}
|
||||
condition = conditions.join(' AND ');
|
||||
}
|
||||
|
||||
const updateColumns = Object.keys(item).filter(
|
||||
(column) => !columnsToMatchOn.includes(column),
|
||||
);
|
||||
|
||||
if (!Object.keys(updateColumns).length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Add values to update to the input item or set the 'Data Mode' to 'Define Below' to define the values to update.",
|
||||
);
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
|
||||
for (const column of updateColumns) {
|
||||
updates.push(`$${valuesLength}:name = $${valuesLength + 1}`);
|
||||
valuesLength = valuesLength + 2;
|
||||
values.push(column, item[column] as string);
|
||||
}
|
||||
|
||||
let query = `UPDATE $1:name.$2:name SET ${updates.join(', ')} WHERE ${condition}`;
|
||||
|
||||
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
|
||||
|
||||
[query, values] = addReturning(query, outputColumns, values);
|
||||
|
||||
queries.push({ query, values });
|
||||
} catch (e) {
|
||||
if (this.continueOnFail()) {
|
||||
const error = e instanceof Error ? e : String(e);
|
||||
errorItemsMap.set(i, { json: { error }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return await runQueriesAndHandleErrors(runQueries, queries, nodeOptions, errorItemsMap);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import type {
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
addReturning,
|
||||
checkItemAgainstSchema,
|
||||
getTableSchema,
|
||||
prepareItem,
|
||||
replaceEmptyStringsByNulls,
|
||||
configureTableSchemaUpdater,
|
||||
convertArraysToPostgresFormat,
|
||||
runQueriesAndHandleErrors,
|
||||
} from '../../helpers/utils';
|
||||
import { optionsCollection } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties names exactly match the table column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Manually',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column manually',
|
||||
},
|
||||
],
|
||||
default: 'autoMapInputData',
|
||||
description:
|
||||
'Whether to map node input properties and the table data automatically or manually',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `
|
||||
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use an 'Edit Fields' node before this node to change the field names.
|
||||
`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['autoMapInputData'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Unique Column',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'The column to compare when finding the rows to update. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>.',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row(s) to update. Doesn't get changed. Has to be unique.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Unique Column',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Rows with a value in the specified "Column to Match On" that corresponds to the value in this field will be updated. New rows will be created for non-matching items.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'valuesToSend',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Value',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [2, 2.1],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/" target="_blank">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
|
||||
loadOptionsDependsOn: ['schema.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'operation'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'upsert',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
optionsCollection,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['database'],
|
||||
operation: ['upsert'],
|
||||
},
|
||||
hide: {
|
||||
table: [''],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
runQueries: QueriesRunner,
|
||||
items: INodeExecutionData[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
db: PgpDatabase,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
items = replaceEmptyStringsByNulls(items, nodeOptions.replaceEmptyStrings as boolean);
|
||||
const nodeVersion = nodeOptions.nodeVersion as number;
|
||||
|
||||
let schema = this.getNodeParameter('schema', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
let table = this.getNodeParameter('table', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const updateTableSchema = configureTableSchemaUpdater(schema, table);
|
||||
|
||||
let tableSchema = await getTableSchema(db, schema, table);
|
||||
|
||||
const queries: QueryWithValues[] = [];
|
||||
const errorItemsMap = new Map<number, INodeExecutionData>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
schema = this.getNodeParameter('schema', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
table = this.getNodeParameter('table', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 2.2
|
||||
? [this.getNodeParameter('columnToMatchOn', i) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', i) as string[]);
|
||||
|
||||
const dataMode =
|
||||
nodeVersion < 2.2
|
||||
? (this.getNodeParameter('dataMode', i) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', i) as string);
|
||||
|
||||
let item: IDataObject = {};
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
item = items[i].json;
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const valuesToSend =
|
||||
nodeVersion < 2.2
|
||||
? ((this.getNodeParameter('valuesToSend', i, []) as IDataObject)
|
||||
.values as IDataObject[])
|
||||
: ((this.getNodeParameter('columns.values', i, []) as IDataObject)
|
||||
.values as IDataObject[]);
|
||||
|
||||
if (nodeVersion < 2.2) {
|
||||
item = prepareItem(valuesToSend);
|
||||
item[columnsToMatchOn[0]] = this.getNodeParameter('valueToMatchOn', i) as string;
|
||||
} else {
|
||||
item = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item[columnsToMatchOn[0]]) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Column to match on not found in input item. Add a column to match on or set the 'Data Mode' to 'Define Below' to define the value to match on.",
|
||||
);
|
||||
}
|
||||
|
||||
if (item[columnsToMatchOn[0]] && Object.keys(item).length === 1) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Add values to update or insert to the input item or set the 'Data Mode' to 'Define Below' to define the values to insert or update.",
|
||||
);
|
||||
}
|
||||
|
||||
tableSchema = await updateTableSchema(db, tableSchema, schema, table);
|
||||
|
||||
if (nodeVersion >= 2.4) {
|
||||
item = convertArraysToPostgresFormat(item, tableSchema, this.getNode(), i);
|
||||
}
|
||||
|
||||
item = checkItemAgainstSchema(this.getNode(), item, tableSchema, i);
|
||||
|
||||
let values: QueryValues = [schema, table];
|
||||
|
||||
let valuesLength = values.length + 1;
|
||||
const conflictColumns: string[] = [];
|
||||
columnsToMatchOn.forEach((column) => {
|
||||
conflictColumns.push(`$${valuesLength}:name`);
|
||||
valuesLength = valuesLength + 1;
|
||||
values.push(column);
|
||||
});
|
||||
const onConflict = ` ON CONFLICT (${conflictColumns.join(',')})`;
|
||||
|
||||
const insertQuery = `INSERT INTO $1:name.$2:name($${valuesLength}:name) VALUES($${valuesLength}:csv)${onConflict}`;
|
||||
valuesLength = valuesLength + 1;
|
||||
values.push(item);
|
||||
|
||||
const updateColumns = Object.keys(item).filter(
|
||||
(column) => !columnsToMatchOn.includes(column),
|
||||
);
|
||||
const updates: string[] = [];
|
||||
|
||||
for (const column of updateColumns) {
|
||||
updates.push(`$${valuesLength}:name = $${valuesLength + 1}`);
|
||||
valuesLength = valuesLength + 2;
|
||||
values.push(column, item[column] as string);
|
||||
}
|
||||
|
||||
const updateQuery =
|
||||
updates?.length > 0 ? ` DO UPDATE SET ${updates.join(', ')}` : ' DO NOTHING ';
|
||||
let query = `${insertQuery}${updateQuery}`;
|
||||
|
||||
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
|
||||
|
||||
[query, values] = addReturning(query, outputColumns, values);
|
||||
|
||||
queries.push({ query, values });
|
||||
} catch (e) {
|
||||
if (this.continueOnFail()) {
|
||||
const error = e instanceof Error ? e : String(e);
|
||||
errorItemsMap.set(i, { json: { error }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return await runQueriesAndHandleErrors(runQueries, queries, nodeOptions, errorItemsMap);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AllEntities, Entity } from 'n8n-workflow';
|
||||
|
||||
type PostgresMap = {
|
||||
database: 'deleteTable' | 'executeQuery' | 'insert' | 'select' | 'update' | 'upsert';
|
||||
};
|
||||
|
||||
export type PostgresType = AllEntities<PostgresMap>;
|
||||
|
||||
export type PostgresDatabaseType = Entity<PostgresMap, 'database'>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
import type { PostgresType } from './node.type';
|
||||
import { addExecutionHints } from '../../../../utils/utilities';
|
||||
import { configurePostgres } from '../../transport';
|
||||
import type { PostgresNodeCredentials, PostgresNodeOptions } from '../helpers/interfaces';
|
||||
import { configureQueryRunner } from '../helpers/utils';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<PostgresType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const options = this.getNodeParameter('options', 0, {}) as PostgresNodeOptions;
|
||||
const node = this.getNode();
|
||||
options.nodeVersion = node.typeVersion;
|
||||
options.operation = operation;
|
||||
|
||||
const { db, pgp } = await configurePostgres.call(this, credentials, options);
|
||||
|
||||
const runQueries = configureQueryRunner.call(
|
||||
this,
|
||||
this.getNode(),
|
||||
this.continueOnFail(),
|
||||
pgp,
|
||||
db,
|
||||
);
|
||||
|
||||
const postgresNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as PostgresType;
|
||||
|
||||
switch (postgresNodeData.resource) {
|
||||
case 'database':
|
||||
returnData = await database[postgresNodeData.operation].execute.call(
|
||||
this,
|
||||
runQueries,
|
||||
items,
|
||||
options,
|
||||
db,
|
||||
pgp,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
addExecutionHints(this, node, items, operation, node.executeOnce);
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as database from './database/Database.resource';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Postgres',
|
||||
name: 'postgres',
|
||||
icon: 'file:postgres.svg',
|
||||
group: ['input'],
|
||||
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6],
|
||||
subtitle: '={{ $parameter["operation"] }}',
|
||||
description: 'Get, add and update data in Postgres',
|
||||
defaults: {
|
||||
name: 'Postgres',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
credentials: [
|
||||
{
|
||||
name: 'postgres',
|
||||
required: true,
|
||||
testedBy: 'postgresConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'hidden',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Database',
|
||||
value: 'database',
|
||||
},
|
||||
],
|
||||
default: 'database',
|
||||
},
|
||||
...database.description,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { IDataObject, INodeExecutionData, SSHCredentials } from 'n8n-workflow';
|
||||
import type pgPromise from 'pg-promise';
|
||||
import { type IFormattingOptions } from 'pg-promise';
|
||||
import type pg from 'pg-promise/typescript/pg-subset';
|
||||
|
||||
export type QueryMode = 'single' | 'transaction' | 'independently';
|
||||
|
||||
export type QueryValue = string | number | IDataObject | string[];
|
||||
export type QueryValues = QueryValue[];
|
||||
export type QueryWithValues = { query: string; values?: QueryValues; options?: IFormattingOptions };
|
||||
|
||||
export type WhereClause = { column: string; condition: string; value: string | number };
|
||||
export type SortRule = { column: string; direction: string };
|
||||
export type ColumnInfo = {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
udt_name?: string;
|
||||
column_default?: string | null;
|
||||
is_generated?: 'ALWAYS' | 'NEVER';
|
||||
identity_generation?: 'ALWAYS' | 'NEVER' | 'BY DEFAULT';
|
||||
};
|
||||
export type EnumInfo = {
|
||||
typname: string;
|
||||
enumlabel: string;
|
||||
};
|
||||
|
||||
export type PgpClient = pgPromise.IMain<{}, pg.IClient>;
|
||||
export type PgpDatabase = pgPromise.IDatabase<{}, pg.IClient>;
|
||||
export type PgpConnectionParameters = pg.IConnectionParameters<pg.IClient>;
|
||||
export type PgpConnection = pgPromise.IConnected<{}, pg.IClient>;
|
||||
export type ConnectionsData = { db: PgpDatabase; pgp: PgpClient };
|
||||
|
||||
export type QueriesRunner = (
|
||||
queries: QueryWithValues[],
|
||||
options: IDataObject,
|
||||
) => Promise<INodeExecutionData[]>;
|
||||
|
||||
export type PostgresNodeOptions = {
|
||||
nodeVersion?: number;
|
||||
operation?: string;
|
||||
cascade?: boolean;
|
||||
connectionTimeout?: number;
|
||||
delayClosingIdleConnection?: number;
|
||||
queryBatching?: QueryMode;
|
||||
queryReplacement?: string;
|
||||
outputColumns?: string[];
|
||||
largeNumbersOutput?: 'numbers' | 'text';
|
||||
skipOnConflict?: boolean;
|
||||
replaceEmptyStrings?: boolean;
|
||||
treatQueryParametersInSingleQuotesAsText?: boolean;
|
||||
};
|
||||
|
||||
export type PostgresNodeCredentials = {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
maxConnections: number;
|
||||
allowUnauthorizedCerts?: boolean;
|
||||
ssl?: 'disable' | 'allow' | 'require' | 'verify' | 'verify-full';
|
||||
} & (
|
||||
| { sshTunnel: false }
|
||||
| ({
|
||||
sshTunnel: true;
|
||||
} & SSHCredentials)
|
||||
);
|
||||
@@ -0,0 +1,678 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodePropertyOptions,
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, deepCopy, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
ColumnInfo,
|
||||
EnumInfo,
|
||||
PgpClient,
|
||||
PgpDatabase,
|
||||
PostgresNodeOptions,
|
||||
QueriesRunner,
|
||||
QueryMode,
|
||||
QueryValues,
|
||||
QueryWithValues,
|
||||
SortRule,
|
||||
WhereClause,
|
||||
} from './interfaces';
|
||||
import { generatePairedItemData } from '../../../../utils/utilities';
|
||||
import { operatorOptions } from '../actions/common.descriptions';
|
||||
|
||||
export function isJSON(str: string) {
|
||||
try {
|
||||
JSON.parse(str.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateExpression(expression: NodeParameterValueType) {
|
||||
if (expression === undefined) {
|
||||
return '';
|
||||
} else if (expression === null) {
|
||||
return 'null';
|
||||
} else {
|
||||
return typeof expression === 'object' ? JSON.stringify(expression) : expression.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export function stringToArray(str: NodeParameterValueType | undefined) {
|
||||
if (str === undefined) return [];
|
||||
return String(str)
|
||||
.split(',')
|
||||
.filter((entry) => entry)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
export function wrapData(data: IDataObject | IDataObject[]): INodeExecutionData[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [{ json: data }];
|
||||
}
|
||||
return data.map((item) => ({
|
||||
json: item,
|
||||
}));
|
||||
}
|
||||
|
||||
export function prepareErrorItem(error: IDataObject | NodeOperationError | Error, index: number) {
|
||||
return {
|
||||
json: { error: { ...error } },
|
||||
pairedItem: { item: index },
|
||||
} as INodeExecutionData;
|
||||
}
|
||||
|
||||
export function parsePostgresError(
|
||||
node: INode,
|
||||
error: any,
|
||||
queries: QueryWithValues[],
|
||||
itemIndex?: number,
|
||||
) {
|
||||
if (error.message.includes('syntax error at or near') && queries.length) {
|
||||
try {
|
||||
const snippet = error.message.match(/syntax error at or near "(.*)"/)[1] as string;
|
||||
const failedQureryIndex = queries.findIndex((query) => query.query.includes(snippet));
|
||||
|
||||
if (failedQureryIndex !== -1) {
|
||||
if (!itemIndex) {
|
||||
itemIndex = failedQureryIndex;
|
||||
}
|
||||
const failedQuery = queries[failedQureryIndex].query;
|
||||
const lines = failedQuery.split('\n');
|
||||
const lineIndex = lines.findIndex((line) => line.includes(snippet));
|
||||
const errorMessage = `Syntax error at line ${lineIndex + 1} near "${snippet}"`;
|
||||
error.message = errorMessage;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let message = error.message;
|
||||
const errorDescription = error.description ? error.description : error.detail || error.hint;
|
||||
let description = errorDescription;
|
||||
|
||||
if (!description && queries[itemIndex || 0]?.query) {
|
||||
description = `Failed query: ${queries[itemIndex || 0].query}`;
|
||||
}
|
||||
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
message = 'Connection refused';
|
||||
try {
|
||||
description = error.message.split('ECONNREFUSED ')[1].trim();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (error.message.includes('ENOTFOUND')) {
|
||||
message = 'Host not found';
|
||||
try {
|
||||
description = error.message.split('ENOTFOUND ')[1].trim();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (error.message.includes('ETIMEDOUT')) {
|
||||
message = 'Connection timed out';
|
||||
try {
|
||||
description = error.message.split('ETIMEDOUT ')[1].trim();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return new NodeOperationError(node, error as Error, {
|
||||
message,
|
||||
description,
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
export function addWhereClauses(
|
||||
_node: INode,
|
||||
_itemIndex: number,
|
||||
query: string,
|
||||
clauses: WhereClause[],
|
||||
replacements: QueryValues,
|
||||
combineConditions: string,
|
||||
): [string, QueryValues] {
|
||||
if (clauses.length === 0) return [query, replacements];
|
||||
|
||||
let combineWith = 'AND';
|
||||
|
||||
if (combineConditions === 'OR') {
|
||||
combineWith = 'OR';
|
||||
}
|
||||
|
||||
let replacementIndex = replacements.length + 1;
|
||||
|
||||
let whereQuery = ' WHERE';
|
||||
const values: QueryValues = [];
|
||||
|
||||
clauses.forEach((clause, index) => {
|
||||
if (clause.condition === 'equal') {
|
||||
clause.condition = '=';
|
||||
}
|
||||
if (['>', '<', '>=', '<='].includes(clause.condition)) {
|
||||
const numericValue = Number(clause.value);
|
||||
if (String(clause.value).trim() !== '' && !Number.isNaN(numericValue)) {
|
||||
clause.value = numericValue;
|
||||
}
|
||||
}
|
||||
const columnReplacement = `$${replacementIndex}:name`;
|
||||
values.push(clause.column);
|
||||
replacementIndex = replacementIndex + 1;
|
||||
|
||||
let valueReplacement = '';
|
||||
if (clause.condition !== 'IS NULL' && clause.condition !== 'IS NOT NULL') {
|
||||
valueReplacement = ` $${replacementIndex}`;
|
||||
values.push(clause.value);
|
||||
replacementIndex = replacementIndex + 1;
|
||||
}
|
||||
|
||||
const operator = index === clauses.length - 1 ? '' : ` ${combineWith}`;
|
||||
|
||||
whereQuery += ` ${columnReplacement} ${clause.condition}${valueReplacement}${operator}`;
|
||||
});
|
||||
|
||||
return [`${query}${whereQuery}`, replacements.concat(...values)];
|
||||
}
|
||||
|
||||
export function addSortRules(
|
||||
query: string,
|
||||
rules: SortRule[],
|
||||
replacements: QueryValues,
|
||||
): [string, QueryValues] {
|
||||
if (rules.length === 0) return [query, replacements];
|
||||
|
||||
let replacementIndex = replacements.length + 1;
|
||||
|
||||
let orderByQuery = ' ORDER BY';
|
||||
const values: string[] = [];
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
const columnReplacement = `$${replacementIndex}:name`;
|
||||
values.push(rule.column);
|
||||
replacementIndex = replacementIndex + 1;
|
||||
|
||||
const endWith = index === rules.length - 1 ? '' : ',';
|
||||
|
||||
const sortDirection = rule.direction === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
orderByQuery += ` ${columnReplacement} ${sortDirection}${endWith}`;
|
||||
});
|
||||
|
||||
return [`${query}${orderByQuery}`, replacements.concat(...values)];
|
||||
}
|
||||
|
||||
export function addReturning(
|
||||
query: string,
|
||||
outputColumns: string[],
|
||||
replacements: QueryValues,
|
||||
): [string, QueryValues] {
|
||||
if (outputColumns.includes('*')) return [`${query} RETURNING *`, replacements];
|
||||
|
||||
const replacementIndex = replacements.length + 1;
|
||||
|
||||
return [`${query} RETURNING $${replacementIndex}:name`, [...replacements, outputColumns]];
|
||||
}
|
||||
|
||||
const isSelectQuery = (query: string) => {
|
||||
return query
|
||||
.replace(/\/\*.*?\*\//g, '') // remove multiline comments
|
||||
.replace(/\n/g, '')
|
||||
.split(';')
|
||||
.filter((statement) => statement && !statement.startsWith('--')) // remove comments and empty statements
|
||||
.every((statement) => statement.trim().toLowerCase().startsWith('select'));
|
||||
};
|
||||
|
||||
export function configureQueryRunner(
|
||||
this: IExecuteFunctions,
|
||||
node: INode,
|
||||
continueOnFail: boolean,
|
||||
pgp: PgpClient,
|
||||
db: PgpDatabase,
|
||||
) {
|
||||
return async (queries: QueryWithValues[], options: IDataObject) => {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
const emptyReturnData: INodeExecutionData[] =
|
||||
options.operation === 'select' ? [] : [{ json: { success: true } }];
|
||||
|
||||
const queryBatching = (options.queryBatching as QueryMode) || 'single';
|
||||
|
||||
if (queryBatching === 'single') {
|
||||
try {
|
||||
returnData = (await db.multi(pgp.helpers.concat(queries)))
|
||||
.map((result, i) => {
|
||||
return this.helpers.constructExecutionMetaData(wrapData(result as IDataObject[]), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
|
||||
if (!returnData.length) {
|
||||
const pairedItem = generatePairedItemData(queries.length);
|
||||
|
||||
if ((options?.nodeVersion as number) < 2.3) {
|
||||
if (emptyReturnData.length) {
|
||||
emptyReturnData[0].pairedItem = pairedItem;
|
||||
}
|
||||
returnData = emptyReturnData;
|
||||
} else {
|
||||
returnData = queries.every((query) => isSelectQuery(query.query))
|
||||
? []
|
||||
: [{ json: { success: true }, pairedItem }];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const error = parsePostgresError(node, err, queries);
|
||||
if (!continueOnFail) throw error;
|
||||
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
message: error.message,
|
||||
error: { ...error },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (queryBatching === 'transaction') {
|
||||
returnData = await db.tx(async (transaction) => {
|
||||
const result: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
try {
|
||||
const query = queries[i].query;
|
||||
const values = queries[i].values;
|
||||
|
||||
let transactionResults;
|
||||
if ((options?.nodeVersion as number) < 2.3) {
|
||||
transactionResults = await transaction.any(query, values);
|
||||
} else {
|
||||
transactionResults = (await transaction.multi(query, values)).flat();
|
||||
}
|
||||
|
||||
if (!transactionResults.length) {
|
||||
if ((options?.nodeVersion as number) < 2.3) {
|
||||
transactionResults = emptyReturnData;
|
||||
} else {
|
||||
transactionResults = isSelectQuery(query) ? [] : [{ success: true }];
|
||||
}
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(transactionResults),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
const error = parsePostgresError(node, err, queries, i);
|
||||
if (!continueOnFail) throw error;
|
||||
result.push(prepareErrorItem(error, i));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
if (queryBatching === 'independently') {
|
||||
returnData = await db.task(async (task) => {
|
||||
const result: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
try {
|
||||
const query = queries[i].query;
|
||||
const values = queries[i].values;
|
||||
|
||||
let transactionResults;
|
||||
if ((options?.nodeVersion as number) < 2.3) {
|
||||
transactionResults = await task.any(query, values);
|
||||
} else {
|
||||
transactionResults = (await task.multi(query, values)).flat();
|
||||
}
|
||||
|
||||
if (!transactionResults.length) {
|
||||
if ((options?.nodeVersion as number) < 2.3) {
|
||||
transactionResults = emptyReturnData;
|
||||
} else {
|
||||
transactionResults = isSelectQuery(query) ? [] : [{ success: true }];
|
||||
}
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(transactionResults),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
result.push(...executionData);
|
||||
} catch (err) {
|
||||
const error = parsePostgresError(node, err, queries, i);
|
||||
if (!continueOnFail) throw error;
|
||||
result.push(prepareErrorItem(error, i));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceEmptyStringsByNulls(
|
||||
items: INodeExecutionData[],
|
||||
replace?: boolean,
|
||||
): INodeExecutionData[] {
|
||||
if (!replace) return items;
|
||||
|
||||
const returnData: INodeExecutionData[] = items.map((item) => {
|
||||
const newItem = { ...item };
|
||||
const keys = Object.keys(newItem.json);
|
||||
|
||||
for (const key of keys) {
|
||||
if (newItem.json[key] === '') {
|
||||
newItem.json[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function prepareItem(values: IDataObject[]) {
|
||||
const item = values.reduce((acc, { column, value }) => {
|
||||
acc[column as string] = value;
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function hasJsonDataTypeInSchema(schema: ColumnInfo[]) {
|
||||
return schema.some(({ data_type }) => data_type === 'json');
|
||||
}
|
||||
|
||||
export function convertValuesToJsonWithPgp(
|
||||
pgp: PgpClient,
|
||||
schema: ColumnInfo[],
|
||||
values: IDataObject,
|
||||
) {
|
||||
schema
|
||||
.filter(
|
||||
({ data_type, column_name }) =>
|
||||
data_type === 'json' && values[column_name] !== null && values[column_name] !== undefined,
|
||||
)
|
||||
.forEach(({ column_name }) => {
|
||||
values[column_name] = pgp.as.json(values[column_name], true);
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export async function columnFeatureSupport(
|
||||
db: PgpDatabase,
|
||||
): Promise<{ identity_generation: boolean; is_generated: boolean }> {
|
||||
const result = await db.any(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns WHERE table_name = 'columns' AND table_schema = 'information_schema' AND column_name = 'is_generated'
|
||||
) as is_generated,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns WHERE table_name = 'columns' AND table_schema = 'information_schema' AND column_name = 'identity_generation'
|
||||
) as identity_generation;`,
|
||||
);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getTableSchema(
|
||||
db: PgpDatabase,
|
||||
schema: string,
|
||||
table: string,
|
||||
options?: { getColumnsForResourceMapper?: boolean },
|
||||
): Promise<ColumnInfo[]> {
|
||||
const select = ['column_name', 'data_type', 'is_nullable', 'udt_name', 'column_default'];
|
||||
|
||||
if (options?.getColumnsForResourceMapper) {
|
||||
// Check if columns exist before querying (identity_generation was added in v10, is_generated in v12)
|
||||
const supported = await columnFeatureSupport(db);
|
||||
|
||||
if (supported.identity_generation) {
|
||||
select.push('identity_generation');
|
||||
}
|
||||
|
||||
if (supported.is_generated) {
|
||||
select.push('is_generated');
|
||||
}
|
||||
}
|
||||
|
||||
const selectString = select.join(', ');
|
||||
const columns = await db.any(
|
||||
`SELECT ${selectString} FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2`,
|
||||
[schema, table],
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export async function uniqueColumns(db: PgpDatabase, table: string, schema = 'public') {
|
||||
// Using the modified query from https://wiki.postgresql.org/wiki/Retrieve_primary_key_columns
|
||||
// `quote_ident` - properly quote and escape an identifier
|
||||
// `::regclass` - cast a string to a regclass (internal type for object names)
|
||||
const unique = await db.any(
|
||||
`
|
||||
SELECT DISTINCT a.attname
|
||||
FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = (quote_ident($1) || '.' || quote_ident($2))::regclass
|
||||
AND (i.indisprimary OR i.indisunique);
|
||||
`,
|
||||
[schema, table],
|
||||
);
|
||||
return unique as IDataObject[];
|
||||
}
|
||||
|
||||
export async function getEnums(db: PgpDatabase): Promise<Map<string, string[]>> {
|
||||
const enums = await db.any<EnumInfo>(
|
||||
'SELECT pg_type.typname, pg_enum.enumlabel FROM pg_type JOIN pg_enum ON pg_enum.enumtypid = pg_type.oid;',
|
||||
);
|
||||
|
||||
return enums.reduce((map, { typname, enumlabel }) => {
|
||||
const existingValues = map.get(typname) ?? [];
|
||||
map.set(typname, [...existingValues, enumlabel]);
|
||||
return map;
|
||||
}, new Map<string, string[]>());
|
||||
}
|
||||
|
||||
export function getEnumValues(
|
||||
enumInfo: Map<string, string[]>,
|
||||
enumName: string,
|
||||
): INodePropertyOptions[] {
|
||||
const values = enumInfo.get(enumName) ?? [];
|
||||
return values.map((value) => ({ name: value, value }));
|
||||
}
|
||||
|
||||
export async function doesRowExist(
|
||||
db: PgpDatabase,
|
||||
schema: string,
|
||||
table: string,
|
||||
values: string[],
|
||||
): Promise<boolean> {
|
||||
const where = [];
|
||||
for (let i = 3; i < 3 + values.length; i += 2) {
|
||||
where.push(`$${i}:name=$${i + 1}`);
|
||||
}
|
||||
const exists = await db.any(
|
||||
`SELECT EXISTS(SELECT 1 FROM $1:name.$2:name WHERE ${where.join(' AND ')})`,
|
||||
[schema, table, ...values],
|
||||
);
|
||||
return exists[0].exists;
|
||||
}
|
||||
|
||||
export function checkItemAgainstSchema(
|
||||
node: INode,
|
||||
item: IDataObject,
|
||||
columnsInfo: ColumnInfo[],
|
||||
index: number,
|
||||
) {
|
||||
if (columnsInfo.length === 0) return item;
|
||||
const schema = columnsInfo.reduce((acc, { column_name, data_type, is_nullable }) => {
|
||||
acc[column_name] = { type: data_type.toUpperCase(), nullable: is_nullable === 'YES' };
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
for (const key of Object.keys(item)) {
|
||||
if (schema[key] === undefined) {
|
||||
throw new NodeOperationError(node, `Column '${key}' does not exist in selected table`, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
if (item[key] === null && !(schema[key] as IDataObject)?.nullable) {
|
||||
throw new NodeOperationError(node, `Column '${key}' is not nullable`, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export const configureTableSchemaUpdater = (initialSchema: string, initialTable: string) => {
|
||||
let currentSchema = initialSchema;
|
||||
let currentTable = initialTable;
|
||||
return async (db: PgpDatabase, tableSchema: ColumnInfo[], schema: string, table: string) => {
|
||||
if (currentSchema !== schema || currentTable !== table) {
|
||||
currentSchema = schema;
|
||||
currentTable = table;
|
||||
tableSchema = await getTableSchema(db, schema, table);
|
||||
}
|
||||
return tableSchema;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* If postgress column type is array we need to convert it to fornmat that postgres understands, original object data would be modified
|
||||
* @param data the object with keys representing column names and values
|
||||
* @param schema table schema
|
||||
* @param node INode
|
||||
* @param itemIndex the index of the current item
|
||||
* @returns a new data object with the arrays converted to postgres format
|
||||
*/
|
||||
export const convertArraysToPostgresFormat = (
|
||||
data: IDataObject,
|
||||
schema: ColumnInfo[],
|
||||
node: INode,
|
||||
itemIndex = 0,
|
||||
) => {
|
||||
const newData = deepCopy(data);
|
||||
for (const columnInfo of schema) {
|
||||
// in case column type is array we need to convert it to fornmat that postgres understands
|
||||
if (columnInfo.data_type.toUpperCase() === 'ARRAY') {
|
||||
let columnValue = newData[columnInfo.column_name];
|
||||
|
||||
if (typeof columnValue === 'string') {
|
||||
columnValue = jsonParse(columnValue);
|
||||
}
|
||||
|
||||
if (Array.isArray(columnValue)) {
|
||||
const arrayEntries = columnValue.map((entry) => {
|
||||
if (typeof entry === 'number') {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (typeof entry === 'boolean') {
|
||||
entry = String(entry);
|
||||
}
|
||||
|
||||
if (typeof entry === 'object') {
|
||||
entry = JSON.stringify(entry);
|
||||
}
|
||||
|
||||
if (typeof entry === 'string') {
|
||||
return `"${entry.replace(/"/g, '\\"')}"`; //escape double quotes
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
// wrap in {} instead of [] as postgres does and join with ,
|
||||
newData[columnInfo.column_name] = `{${arrayEntries.join(',')}}`;
|
||||
} else {
|
||||
if (columnInfo.is_nullable === 'NO') {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Column '${columnInfo.column_name}' has to be an array`,
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// operations use 'equal' instead of '=' because of the way expressions are handled
|
||||
// manually add '=' to allow entering it instead of 'equal'
|
||||
const conditionSet = new Set(operatorOptions.map((option) => option.value)).add('=');
|
||||
|
||||
export const isWhereClause = (clause: unknown): clause is WhereClause => {
|
||||
if (typeof clause !== 'object' || clause === null) return false;
|
||||
if (!('column' in clause)) return false;
|
||||
if (
|
||||
!('condition' in clause) ||
|
||||
typeof clause.condition !== 'string' ||
|
||||
!conditionSet.has(clause.condition)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getWhereClauses = (ctx: IExecuteFunctions, itemIndex: number): WhereClause[] => {
|
||||
const whereClauses = ctx.getNodeParameter('where', itemIndex, []) as IDataObject;
|
||||
const whereClausesValues = whereClauses.values as unknown[];
|
||||
if (!Array.isArray(whereClausesValues)) {
|
||||
return [];
|
||||
}
|
||||
const someInvalid = whereClausesValues.some((clause) => !isWhereClause(clause));
|
||||
if (someInvalid) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'Invalid where clause', {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
return whereClausesValues as WhereClause[];
|
||||
};
|
||||
|
||||
export const runQueriesAndHandleErrors = async (
|
||||
runQueries: QueriesRunner,
|
||||
queries: QueryWithValues[],
|
||||
nodeOptions: PostgresNodeOptions,
|
||||
errorItemsMap: Map<number, INodeExecutionData>,
|
||||
) => {
|
||||
// if we have any errors and we are not running the queries independently
|
||||
// (i.e. `transaction` or `single` mode), we don't want to execute any
|
||||
// queries that didn't error, since the operation should be atomic
|
||||
if (errorItemsMap.size > 0 && nodeOptions.queryBatching !== 'independently') {
|
||||
return Array.from(errorItemsMap.values());
|
||||
}
|
||||
|
||||
const returnData = await runQueries(queries, nodeOptions);
|
||||
|
||||
const total = returnData.length + errorItemsMap.size;
|
||||
const result = new Array<INodeExecutionData>(total);
|
||||
let returnDataIndex = 0;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const errorItem = errorItemsMap.get(i);
|
||||
if (errorItem) {
|
||||
result[i] = errorItem;
|
||||
} else if (returnDataIndex < returnData.length) {
|
||||
result[i] = returnData[returnDataIndex++];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
INodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { configurePostgres } from '../../transport';
|
||||
import type { PgpConnection, PostgresNodeCredentials } from '../helpers/interfaces';
|
||||
|
||||
export async function postgresConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as PostgresNodeCredentials;
|
||||
|
||||
let connection: PgpConnection | undefined;
|
||||
|
||||
try {
|
||||
const { db } = await configurePostgres.call(this, credentials, {});
|
||||
|
||||
connection = await db.connect();
|
||||
} catch (error) {
|
||||
let message = error.message as string;
|
||||
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
message = 'Connection refused';
|
||||
}
|
||||
|
||||
if (error.message.includes('ENOTFOUND')) {
|
||||
message = 'Host not found, please check your host name';
|
||||
}
|
||||
|
||||
if (error.message.includes('ETIMEDOUT')) {
|
||||
message = 'Connection timed out';
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'Error',
|
||||
message,
|
||||
};
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.done();
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as credentialTest from './credentialTest';
|
||||
export * as listSearch from './listSearch';
|
||||
export * as loadOptions from './loadOptions';
|
||||
export * as resourceMapping from './resourceMapping';
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import { configurePostgres } from '../../transport';
|
||||
import type { PostgresNodeCredentials } from '../helpers/interfaces';
|
||||
|
||||
export async function schemaSearch(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const options = { nodeVersion: this.getNode().typeVersion };
|
||||
|
||||
const { db } = await configurePostgres.call(this, credentials, options);
|
||||
|
||||
const response = await db.any('SELECT schema_name FROM information_schema.schemata');
|
||||
|
||||
return {
|
||||
results: response.map((schema) => ({
|
||||
name: schema.schema_name as string,
|
||||
value: schema.schema_name as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export async function tableSearch(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const options = { nodeVersion: this.getNode().typeVersion };
|
||||
|
||||
const { db } = await configurePostgres.call(this, credentials, options);
|
||||
|
||||
const schema = this.getNodeParameter('schema', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const response = await db.any(
|
||||
'SELECT table_name FROM information_schema.tables WHERE table_schema=$1',
|
||||
[schema],
|
||||
);
|
||||
|
||||
return {
|
||||
results: response.map((table) => ({
|
||||
name: table.table_name as string,
|
||||
value: table.table_name as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
import { configurePostgres } from '../../transport';
|
||||
import type { PostgresNodeCredentials } from '../helpers/interfaces';
|
||||
import { getTableSchema } from '../helpers/utils';
|
||||
|
||||
export async function getColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
const options = { nodeVersion: this.getNode().typeVersion };
|
||||
|
||||
const { db } = await configurePostgres.call(this, credentials, options);
|
||||
|
||||
const schema = this.getNodeParameter('schema', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = this.getNodeParameter('table', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columns = await getTableSchema(db, schema, table);
|
||||
|
||||
return columns.map((column) => ({
|
||||
name: column.column_name,
|
||||
value: column.column_name,
|
||||
description: `Type: ${column.data_type.toUpperCase()}, Nullable: ${column.is_nullable}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getColumnsMultiOptions(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getColumns.call(this);
|
||||
const returnAll = { name: '*', value: '*', description: 'All columns' };
|
||||
return [returnAll, ...returnData];
|
||||
}
|
||||
|
||||
export async function getColumnsWithoutColumnToMatchOn(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn') as string;
|
||||
const returnData = await getColumns.call(this);
|
||||
return returnData.filter((column) => column.value !== columnToMatchOn);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ILoadOptionsFunctions, ResourceMapperFields, FieldType } from 'n8n-workflow';
|
||||
|
||||
import { configurePostgres } from '../../transport';
|
||||
import type { PostgresNodeCredentials } from '../helpers/interfaces';
|
||||
import { getEnumValues, getEnums, getTableSchema, uniqueColumns } from '../helpers/utils';
|
||||
|
||||
const postgresTypeToFieldType = new Map(
|
||||
Object.entries({
|
||||
text: 'string',
|
||||
varchar: 'string',
|
||||
'character varying': 'string',
|
||||
character: 'string',
|
||||
char: 'string',
|
||||
integer: 'number',
|
||||
smallint: 'number',
|
||||
bigint: 'number',
|
||||
decimal: 'number',
|
||||
numeric: 'number',
|
||||
real: 'number',
|
||||
'double precision': 'number',
|
||||
smallserial: 'number',
|
||||
serial: 'number',
|
||||
bigserial: 'number',
|
||||
// eslint-disable-next-line id-denylist
|
||||
boolean: 'boolean',
|
||||
timestamp: 'dateTime',
|
||||
date: 'dateTime',
|
||||
timestampz: 'dateTime',
|
||||
'timestamp without time zone': 'dateTime',
|
||||
'timestamp with time zone': 'dateTime',
|
||||
time: 'time',
|
||||
'time without time zone': 'time',
|
||||
'time with time zone': 'time',
|
||||
json: 'object',
|
||||
jsonb: 'object',
|
||||
enum: 'options',
|
||||
ARRAY: 'array',
|
||||
|
||||
// PostgreSQL extensions
|
||||
citext: 'string',
|
||||
uuid: 'string',
|
||||
geometry: 'string',
|
||||
geography: 'string',
|
||||
inet: 'string',
|
||||
cidr: 'string',
|
||||
macaddr: 'string',
|
||||
macaddr8: 'string',
|
||||
int4range: 'string',
|
||||
int8range: 'string',
|
||||
numrange: 'string',
|
||||
tsrange: 'string',
|
||||
tstzrange: 'string',
|
||||
daterange: 'string',
|
||||
tsvector: 'string',
|
||||
tsquery: 'string',
|
||||
hstore: 'object',
|
||||
ltree: 'string',
|
||||
} as const),
|
||||
);
|
||||
|
||||
function mapPostgresType(
|
||||
postgresType: string,
|
||||
userDefinedType?: string,
|
||||
enumInfo?: Map<string, string[]>,
|
||||
): FieldType {
|
||||
if (postgresType === 'USER-DEFINED' && userDefinedType) {
|
||||
if (enumInfo?.has(userDefinedType)) {
|
||||
return 'options';
|
||||
}
|
||||
return postgresTypeToFieldType.get(userDefinedType) ?? 'string';
|
||||
}
|
||||
|
||||
return postgresTypeToFieldType.get(postgresType) ?? 'string';
|
||||
}
|
||||
|
||||
export async function getMappingColumns(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
|
||||
|
||||
const { db } = await configurePostgres.call(this, credentials);
|
||||
|
||||
const schema = this.getNodeParameter('schema', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = this.getNodeParameter('table', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const operation = this.getNodeParameter('operation', 0, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const columns = await getTableSchema(db, schema, table, { getColumnsForResourceMapper: true });
|
||||
const unique = operation === 'upsert' ? await uniqueColumns(db, table, schema) : [];
|
||||
const enumInfo = await getEnums(db);
|
||||
const fields = columns.map((col) => {
|
||||
const canBeUsedToMatch =
|
||||
operation === 'upsert' ? unique.some((u) => u.attname === col.column_name) : true;
|
||||
const type = mapPostgresType(col.data_type, col.udt_name, enumInfo);
|
||||
const options =
|
||||
type === 'options' ? getEnumValues(enumInfo, col.udt_name as string) : undefined;
|
||||
const hasDefault = Boolean(col.column_default);
|
||||
const isGenerated =
|
||||
col.is_generated === 'ALWAYS' ||
|
||||
['ALWAYS', 'BY DEFAULT'].includes(col.identity_generation ?? '');
|
||||
const nullable = col.is_nullable === 'YES';
|
||||
return {
|
||||
id: col.column_name,
|
||||
displayName: col.column_name,
|
||||
required: !nullable && !hasDefault && !isGenerated,
|
||||
defaultMatch: (col.column_name === 'id' && canBeUsedToMatch) || false,
|
||||
display: true,
|
||||
type,
|
||||
canBeUsedToMatch,
|
||||
options,
|
||||
};
|
||||
});
|
||||
return { fields };
|
||||
}
|
||||
Reference in New Issue
Block a user