From e55e8c4fd7f8b0a7f398a07ed8038d925c2a0a4f Mon Sep 17 00:00:00 2001 From: "David G. Simmons" Date: Tue, 7 Jul 2020 09:24:21 -0400 Subject: [PATCH] Added Node for QuestDB --- .../credentials/Questdb.credentials.ts | 73 ++++ .../nodes-base/nodes/QuestDB/QuestDB.node.ts | 337 ++++++++++++++++++ packages/nodes-base/nodes/QuestDB/questdb.png | Bin 0 -> 11244 bytes 3 files changed, 410 insertions(+) create mode 100644 packages/nodes-base/credentials/Questdb.credentials.ts create mode 100644 packages/nodes-base/nodes/QuestDB/QuestDB.node.ts create mode 100644 packages/nodes-base/nodes/QuestDB/questdb.png diff --git a/packages/nodes-base/credentials/Questdb.credentials.ts b/packages/nodes-base/credentials/Questdb.credentials.ts new file mode 100644 index 0000000000..5cd60f960a --- /dev/null +++ b/packages/nodes-base/credentials/Questdb.credentials.ts @@ -0,0 +1,73 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class QuestDB implements ICredentialType { + name = 'questdb'; + displayName = 'QuestDB'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: 'localhost', + }, + { + displayName: 'Database', + name: 'database', + type: 'string' as NodePropertyTypes, + default: 'qdb', + }, + { + displayName: 'User', + name: 'user', + type: 'string' as NodePropertyTypes, + default: 'admin', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: 'quest', + }, + { + displayName: 'SSL', + name: 'ssl', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'disable', + value: 'disable', + }, + { + name: 'allow', + value: 'allow', + }, + { + name: 'require', + value: 'require', + }, + { + name: 'verify (not implemented)', + value: 'verify', + }, + { + name: 'verify-full (not implemented)', + value: 'verify-full', + } + ], + default: 'disable', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 8812, + }, + ]; +} diff --git a/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts new file mode 100644 index 0000000000..8e54e102f7 --- /dev/null +++ b/packages/nodes-base/nodes/QuestDB/QuestDB.node.ts @@ -0,0 +1,337 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import * as pgPromise from 'pg-promise'; + + +/** + * Returns of 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 + * @returns + */ +function getItemCopy(items: INodeExecutionData[], properties: string[]): IDataObject[] { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + return items.map((item) => { + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; + }); +} + + +export class QuestDB implements INodeType { + description: INodeTypeDescription = { + displayName: 'QuestDB', + name: 'questdb', + icon: 'file:questdb.png', + group: ['input'], + version: 1, + description: 'Gets, add and update data in QuestDB.', + defaults: { + name: 'QuestDB', + color: '#336791', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'questdb', + required: true, + } + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Execute Query', + value: 'executeQuery', + description: 'Executes a SQL query.', + }, + { + name: 'Insert', + value: 'insert', + description: 'Insert rows in database.', + }, + { + name: 'Update', + value: 'update', + description: 'Updates rows in database.', + }, + ], + default: 'insert', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // executeQuery + // ---------------------------------- + { + displayName: 'Query', + name: 'query', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + operation: [ + 'executeQuery' + ], + }, + }, + default: '', + placeholder: 'SELECT id, name FROM product WHERE id < 40', + required: true, + description: 'The SQL query to execute.', + }, + + + // ---------------------------------- + // 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: '', + placeholder: 'id,name,description', + description: 'Comma separated list of the properties which should used as columns for the new rows.', + }, + { + displayName: 'Return Fields', + name: 'returnFields', + type: 'string', + displayOptions: { + show: { + operation: [ + 'insert' + ], + }, + }, + default: '*', + description: 'Comma separated list of the fields that the operation will return', + }, + + + // ---------------------------------- + // update + // ---------------------------------- + { + 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, + description: 'Name of the property 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,description', + description: 'Comma separated list of the properties which should used as columns for rows to update.', + }, + + ] + }; + + + async execute(this: IExecuteFunctions): Promise { + + const credentials = this.getCredentials('questdb'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const pgp = pgPromise(); + + const config = { + host: credentials.host as string, + port: credentials.port as number, + database: credentials.database as string, + user: credentials.user as string, + password: credentials.password as string, + ssl: !['disable', undefined].includes(credentials.ssl as string | undefined), + sslmode: credentials.ssl as string || 'disable', + }; + + const db = pgp(config); + + let returnItems = []; + + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as string; + + if (operation === 'executeQuery') { + // ---------------------------------- + // executeQuery + // ---------------------------------- + + const queries: string[] = []; + for (let i = 0; i < items.length; i++) { + queries.push(this.getNodeParameter('query', i) as string); + } + + const queryResult = await db.any(pgp.helpers.concat(queries)); + + returnItems = this.helpers.returnJsonArray(queryResult as IDataObject[]); + + } else if (operation === 'insert') { + // ---------------------------------- + // insert + // ---------------------------------- + + const table = this.getNodeParameter('table', 0) as string; + const schema = this.getNodeParameter('schema', 0) as string; + let returnFields = (this.getNodeParameter('returnFields', 0) as string).split(',') as string[]; + const columnString = this.getNodeParameter('columns', 0) as string; + const columns = columnString.split(',').map(column => column.trim()); + + const cs = new pgp.helpers.ColumnSet(columns); + + const te = new pgp.helpers.TableName({ table, schema }); + + // Prepare the data to insert and copy it to be returned + const insertItems = getItemCopy(items, columns); + + // Generate the multi-row insert query and return the id of new row + returnFields = returnFields.map(value => value.trim()).filter(value => !!value); + const query = pgp.helpers.insert(insertItems, cs, te) + (returnFields.length ? ` RETURNING ${returnFields.join(',')}` : ''); + + // Executing the query to insert the data + const insertData = await db.manyOrNone(query); + + // Add the id to the data + for (let i = 0; i < insertData.length; i++) { + returnItems.push({ + json: { + ...insertData[i], + ...insertItems[i], + } + }); + } + + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const table = this.getNodeParameter('table', 0) as string; + const updateKey = this.getNodeParameter('updateKey', 0) as string; + const columnString = this.getNodeParameter('columns', 0) as string; + + const columns = columnString.split(',').map(column => column.trim()); + + // Make sure that the updateKey does also get queried + if (!columns.includes(updateKey)) { + columns.unshift(updateKey); + } + + // Prepare the data to update and copy it to be returned + const updateItems = getItemCopy(items, columns); + + // Generate the multi-row update query + const query = pgp.helpers.update(updateItems, columns, table) + ' WHERE v.' + updateKey + ' = t.' + updateKey; + + // Executing the query to update the data + await db.none(query); + + returnItems = this.helpers.returnJsonArray(updateItems as IDataObject[]); + + } else { + await pgp.end(); + throw new Error(`The operation "${operation}" is not supported!`); + } + + // Close the connection + await pgp.end(); + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/QuestDB/questdb.png b/packages/nodes-base/nodes/QuestDB/questdb.png new file mode 100644 index 0000000000000000000000000000000000000000..90febff64f5d4facdd03c641e2cb6ffd0d2dd373 GIT binary patch literal 11244 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 literal 0 HcmV?d00001