feat(Postgres Node): Overhaul node

This commit is contained in:
Michael Kret
2023-04-03 18:18:01 +03:00
committed by GitHub
parent df2ea0f5ec
commit 07dc0e4b40
30 changed files with 4114 additions and 404 deletions

View File

@@ -0,0 +1,335 @@
import type { INodeProperties } from 'n8n-workflow';
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: 'Query Batching',
name: 'queryBatching',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Single Query',
value: 'single',
description: 'A single query for all incoming items',
},
{
name: 'Independently',
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">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-wrong-for-dynamic-multi-options
displayName: 'Output Columns',
name: 'outputColumns',
type: 'multiOptions',
description:
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code-examples/expressions/">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',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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: [
{
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',
},
],
default: 'equal',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
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',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">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',
};

View File

@@ -0,0 +1,74 @@
import type { INodeProperties } from 'n8n-workflow';
import { schemaRLC, tableRLC } from '../common.descriptions';
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';
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,
];

View File

@@ -0,0 +1,159 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type {
PgpDatabase,
QueriesRunner,
QueryValues,
QueryWithValues,
WhereClause,
} from '../../helpers/interfaces';
import { addWhereClauses } 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: IDataObject,
_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 =
((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || [];
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 runQueries(queries, items, nodeOptions);
}

View File

@@ -0,0 +1,84 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type { PgpDatabase, QueriesRunner, QueryWithValues } from '../../helpers/interfaces';
import { replaceEmptyStringsByNulls } 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',
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: {
rows: 3,
},
hint: 'Prefer using query parameters over n8n expressions to avoid SQL injection attacks',
},
{
displayName: `
To use query parameters in your SQL query, reference them as $1, $2, $3, etc in the corresponding order. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/#use-query-parameters">More info</a>.
`,
name: 'notice',
type: 'notice',
default: '',
},
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: IDataObject,
_db?: PgpDatabase,
): Promise<INodeExecutionData[]> {
items = replaceEmptyStringsByNulls(items, nodeOptions.replaceEmptyStrings as boolean);
const queries: QueryWithValues[] = [];
for (let i = 0; i < items.length; i++) {
const query = this.getNodeParameter('query', i) as string;
let values: IDataObject[] = [];
let queryReplacement = this.getNodeParameter('options.queryReplacement', i, '');
if (typeof queryReplacement === 'string') {
queryReplacement = queryReplacement.split(',').map((entry) => entry.trim());
}
if (Array.isArray(queryReplacement)) {
values = queryReplacement as IDataObject[];
} else {
throw new NodeOperationError(
this.getNode(),
'Query Replacement must be a string of comma-separated values, or an array of values',
{ itemIndex: i },
);
}
queries.push({ query, values });
}
return runQueries(queries, items, nodeOptions);
}

View File

@@ -0,0 +1,172 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type {
PgpDatabase,
QueriesRunner,
QueryValues,
QueryWithValues,
} from '../../helpers/interfaces';
import {
addReturning,
checkItemAgainstSchema,
getTableSchema,
prepareItem,
replaceEmptyStringsByNulls,
} 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',
},
{
displayName: `
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use a 'Set' node before this node to change the field names.
`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
dataMode: ['autoMapInputData'],
},
},
},
{
displayName: 'Values to Send',
name: 'valuesToSend',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Value',
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['defineBelow'],
},
},
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',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getColumns',
loadOptionsDependsOn: ['schema.value', 'table.value'],
},
default: [],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
},
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: IDataObject,
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;
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 = this.getNodeParameter('dataMode', i) as string;
let item: IDataObject = {};
if (dataMode === 'autoMapInputData') {
item = items[i].json;
}
if (dataMode === 'defineBelow') {
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
.values as IDataObject[];
item = prepareItem(valuesToSend);
}
const tableSchema = await getTableSchema(db, schema, table);
values.push(checkItemAgainstSchema(this.getNode(), item, tableSchema, i));
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
[query, values] = addReturning(query, outputColumns, values);
queries.push({ query, values });
}
return runQueries(queries, items, nodeOptions);
}

View File

@@ -0,0 +1,134 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type {
PgpDatabase,
QueriesRunner,
QueryValues,
QueryWithValues,
SortRule,
WhereClause,
} from '../../helpers/interfaces';
import { addSortRules, addWhereClauses, 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: IDataObject,
_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 =
((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || [];
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 limit = this.getNodeParameter('limit', i, 50);
query += ` LIMIT ${limit}`;
}
const queryWithValues = { query, values };
queries.push(queryWithValues);
}
return runQueries(queries, items, nodeOptions);
}

View File

@@ -0,0 +1,216 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type {
PgpDatabase,
QueriesRunner,
QueryValues,
QueryWithValues,
} from '../../helpers/interfaces';
import {
addReturning,
checkItemAgainstSchema,
getTableSchema,
prepareItem,
replaceEmptyStringsByNulls,
} 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',
},
{
displayName: `
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use a 'Set' node before this node to change the field names.
`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
dataMode: ['autoMapInputData'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column to Match On',
name: 'columnToMatchOn',
type: 'options',
required: true,
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-examples/expressions/">expression</a>.',
typeOptions: {
loadOptionsMethod: 'getColumns',
loadOptionsDependsOn: ['schema.value', 'table.value'],
},
default: '',
hint: 'The column that identifies the row(s) to modify',
},
{
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'],
},
},
},
{
displayName: 'Values to Send',
name: 'valuesToSend',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Value',
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['defineBelow'],
},
},
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',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
loadOptionsDependsOn: ['schema.value', 'table.value'],
},
default: [],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
},
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: IDataObject,
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;
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', i) as string;
const dataMode = this.getNodeParameter('dataMode', i) as string;
let item: IDataObject = {};
let valueToMatchOn: string | IDataObject = '';
if (dataMode === 'autoMapInputData') {
item = items[i].json;
valueToMatchOn = item[columnToMatchOn] as string;
}
if (dataMode === 'defineBelow') {
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
.values as IDataObject[];
item = prepareItem(valuesToSend);
valueToMatchOn = this.getNodeParameter('valueToMatchOn', i) as string;
}
const tableSchema = await getTableSchema(db, schema, table);
item = checkItemAgainstSchema(this.getNode(), item, tableSchema, i);
let values: QueryValues = [schema, table];
let valuesLength = values.length + 1;
const condition = `$${valuesLength}:name = $${valuesLength + 1}`;
valuesLength = valuesLength + 2;
values.push(columnToMatchOn, valueToMatchOn);
const updateColumns = Object.keys(item).filter((column) => column !== columnToMatchOn);
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 });
}
return runQueries(queries, items, nodeOptions);
}

View File

@@ -0,0 +1,217 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import type {
PgpDatabase,
QueriesRunner,
QueryValues,
QueryWithValues,
} from '../../helpers/interfaces';
import {
addReturning,
checkItemAgainstSchema,
getTableSchema,
prepareItem,
replaceEmptyStringsByNulls,
} 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',
},
{
displayName: `
In this mode, make sure incoming data fields are named the same as the columns in your table. If needed, use a 'Set' node before this node to change the field names.
`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
dataMode: ['autoMapInputData'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column to Match On',
name: 'columnToMatchOn',
type: 'options',
required: true,
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-examples/expressions/">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.",
},
{
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. New rows will be created for non-matching items.',
displayOptions: {
show: {
dataMode: ['defineBelow'],
},
},
},
{
displayName: 'Values to Send',
name: 'valuesToSend',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Value',
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['defineBelow'],
},
},
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',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getColumnsWithoutColumnToMatchOn',
loadOptionsDependsOn: ['schema.value', 'table.value'],
},
default: [],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
},
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: IDataObject,
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;
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', i) as string;
const dataMode = this.getNodeParameter('dataMode', i) as string;
let item: IDataObject = {};
if (dataMode === 'autoMapInputData') {
item = items[i].json;
}
if (dataMode === 'defineBelow') {
const valuesToSend = (this.getNodeParameter('valuesToSend', i, []) as IDataObject)
.values as IDataObject[];
item = prepareItem(valuesToSend);
item[columnToMatchOn] = this.getNodeParameter('valueToMatchOn', i) as string;
}
const tableSchema = await getTableSchema(db, schema, table);
item = checkItemAgainstSchema(this.getNode(), item, tableSchema, i);
let values: QueryValues = [schema, table];
let valuesLength = values.length + 1;
const onConflict = ` ON CONFLICT ($${valuesLength}:name) DO UPDATE `;
valuesLength = valuesLength + 1;
values.push(columnToMatchOn);
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) => column !== columnToMatchOn);
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 = `${insertQuery} SET ${updates.join(', ')}`;
const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[];
[query, values] = addReturning(query, outputColumns, values);
queries.push({ query, values });
}
return runQueries(queries, items, nodeOptions);
}

View File

@@ -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'>;

View File

@@ -0,0 +1,67 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { PostgresType } from './node.type';
import * as database from './database/Database.resource';
import { Connections } from '../transport';
import { configureQueryRunner } from '../helpers/utils';
import type { ConnectionsData } from '../helpers/interfaces';
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('postgres');
const options = this.getNodeParameter('options', 0, {});
const { db, pgp, sshClient } = (await Connections.getInstance(
credentials,
options,
true,
)) as ConnectionsData;
const runQueries = configureQueryRunner(
this.getNode(),
this.helpers.constructExecutionMetaData,
this.continueOnFail(),
pgp,
db,
);
const postgresNodeData = {
resource,
operation,
} as PostgresType;
try {
switch (postgresNodeData.resource) {
case 'database':
returnData = await database[postgresNodeData.operation].execute.call(
this,
runQueries,
items,
options,
db,
);
break;
default:
throw new NodeOperationError(
this.getNode(),
`The operation "${operation}" is not supported!`,
);
}
} catch (error) {
throw error;
} finally {
if (sshClient) {
sshClient.end();
}
pgp.end();
}
return this.prepareOutputData(returnData);
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import 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,
subtitle: '={{ $parameter["operation"] }}',
description: 'Get, add and update data in Postgres',
defaults: {
name: 'Postgres',
},
inputs: ['main'],
outputs: ['main'],
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,
],
};