From ab52aaf7e9846526122c52e975230aad1e239957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 17 Jan 2024 10:15:20 +0100 Subject: [PATCH] feat(Redis Node): Update node-redis (no-changelog) (#8269) Co-authored-by: Michael Kret --- packages/@n8n/nodes-langchain/package.json | 2 +- packages/nodes-base/nodes/Redis/Redis.node.ts | 484 +++++------------- .../nodes/Redis/RedisTrigger.node.ts | 47 +- .../nodes/Redis/test/Redis.node.test.ts | 158 ++++++ packages/nodes-base/nodes/Redis/utils.ts | 168 ++++++ packages/nodes-base/package.json | 3 +- pnpm-lock.yaml | 81 ++- 7 files changed, 517 insertions(+), 426 deletions(-) create mode 100644 packages/nodes-base/nodes/Redis/test/Redis.node.test.ts create mode 100644 packages/nodes-base/nodes/Redis/utils.ts diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 1dcdff0266..6dfa670242 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -148,7 +148,7 @@ "openai": "4.20.0", "pdf-parse": "1.1.1", "pg": "8.11.3", - "redis": "4.6.11", + "redis": "4.6.12", "sqlite3": "5.1.6", "temp": "0.9.4", "typeorm": "0.3.17", diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index 9d7a3fc65b..414176db94 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -1,19 +1,19 @@ -import util from 'util'; import type { IExecuteFunctions, - ICredentialDataDecryptedObject, - ICredentialsDecrypted, - ICredentialTestFunctions, - IDataObject, - INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; import set from 'lodash/set'; -import redis from 'redis'; + +import { + setupRedisClient, + redisConnectionTest, + convertInfoToObject, + getValue, + setValue, +} from './utils'; export class Redis implements INodeType { description: INodeTypeDescription = { @@ -100,6 +100,23 @@ export class Redis implements INodeType { default: 'info', }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Key', + name: 'key', + type: 'string', + displayOptions: { + show: { + operation: ['delete'], + }, + }, + default: '', + required: true, + description: 'Name of the key to delete from Redis', + }, + // ---------------------------------- // get // ---------------------------------- @@ -117,19 +134,6 @@ export class Redis implements INodeType { description: 'Name of the property to write received data to. Supports dot-notation. Example: "data.person[0].name".', }, - { - displayName: 'Key', - name: 'key', - type: 'string', - displayOptions: { - show: { - operation: ['delete'], - }, - }, - default: '', - required: true, - description: 'Name of the key to delete from Redis', - }, { displayName: 'Key', name: 'key', @@ -498,345 +502,135 @@ export class Redis implements INodeType { }; methods = { - credentialTest: { - async redisConnectionTest( - this: ICredentialTestFunctions, - credential: ICredentialsDecrypted, - ): Promise { - const credentials = credential.data as ICredentialDataDecryptedObject; - const redisOptions: redis.ClientOpts = { - host: credentials.host as string, - port: credentials.port as number, - db: credentials.database as number, - }; - - if (credentials.password) { - redisOptions.password = credentials.password as string; - } - try { - const client = redis.createClient(redisOptions); - - await new Promise((resolve, reject): any => { - client.on('connect', async () => { - client.ping('ping', (error, pong) => { - if (error) reject(error); - resolve(pong); - client.quit(); - }); - }); - client.on('error', async (err) => { - client.quit(); - reject(err); - }); - }); - } catch (error) { - return { - status: 'Error', - message: error.message, - }; - } - return { - status: 'OK', - message: 'Connection successful!', - }; - }, - }, + credentialTest: { redisConnectionTest }, }; - async execute(this: IExecuteFunctions): Promise { - // Parses the given value in a number if it is one else returns a string - function getParsedValue(value: string): string | number { - if (value.match(/^[\d\.]+$/) === null) { - // Is a string - return value; - } else { - // Is a number - return parseFloat(value); - } - } + async execute(this: IExecuteFunctions) { + // TODO: For array and object fields it should not have a "value" field it should + // have a parameter field for a path. Because it is not possible to set + // array, object via parameter directly (should maybe be possible?!?!) + // Should maybe have a parameter which is JSON. + const credentials = await this.getCredentials('redis'); - // Converts the Redis Info String into an object - function convertInfoToObject(stringData: string): IDataObject { - const returnData: IDataObject = {}; + const client = setupRedisClient(credentials); + await client.connect(); + await client.ping(); - let key: string, value: string; - for (const line of stringData.split('\n')) { - if (['#', ''].includes(line.charAt(0))) { - continue; - } - [key, value] = line.split(':'); - if (key === undefined || value === undefined) { - continue; - } - value = value.trim(); + const operation = this.getNodeParameter('operation', 0); + const returnItems: INodeExecutionData[] = []; - if (value.includes('=')) { - returnData[key] = {}; - let key2: string, value2: string; - for (const keyValuePair of value.split(',')) { - [key2, value2] = keyValuePair.split('='); - (returnData[key] as IDataObject)[key2] = getParsedValue(value2); - } - } else { - returnData[key] = getParsedValue(value); - } - } + try { + if (operation === 'info') { + const result = await client.info(); + returnItems.push({ json: convertInfoToObject(result) }); + } else if ( + ['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation) + ) { + const items = this.getInputData(); - return returnData; - } + let item: INodeExecutionData; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + item = { json: {} }; - async function getValue(client: redis.RedisClient, keyName: string, type?: string) { - if (type === undefined || type === 'automatic') { - // Request the type first - const clientType = util.promisify(client.type).bind(client); - type = await clientType(keyName); - } + if (operation === 'delete') { + const keyDelete = this.getNodeParameter('key', itemIndex) as string; - if (type === 'string') { - const clientGet = util.promisify(client.get).bind(client); - return clientGet(keyName); - } else if (type === 'hash') { - const clientHGetAll = util.promisify(client.hgetall).bind(client); - return clientHGetAll(keyName); - } else if (type === 'list') { - const clientLRange = util.promisify(client.lrange).bind(client); - return clientLRange(keyName, 0, -1); - } else if (type === 'sets') { - const clientSMembers = util.promisify(client.smembers).bind(client); - return clientSMembers(keyName); - } - } + await client.del(keyDelete); + returnItems.push(items[itemIndex]); + } else if (operation === 'get') { + const propertyName = this.getNodeParameter('propertyName', itemIndex) as string; + const keyGet = this.getNodeParameter('key', itemIndex) as string; + const keyType = this.getNodeParameter('keyType', itemIndex) as string; - const setValue = async ( - client: redis.RedisClient, - keyName: string, - value: string | number | object | string[] | number[], - expire: boolean, - ttl: number, - type?: string, - valueIsJSON?: boolean, - ) => { - if (type === undefined || type === 'automatic') { - // Request the type first - if (typeof value === 'string') { - type = 'string'; - } else if (Array.isArray(value)) { - type = 'list'; - } else if (typeof value === 'object') { - type = 'hash'; - } else { - throw new NodeOperationError( - this.getNode(), - 'Could not identify the type to set. Please set it manually!', - ); - } - } + const value = (await getValue(client, keyGet, keyType)) ?? null; - if (type === 'string') { - const clientSet = util.promisify(client.set).bind(client); - await clientSet(keyName, value.toString()); - } else if (type === 'hash') { - const clientHset = util.promisify(client.hset).bind(client); - if (valueIsJSON) { - let values: unknown; - if (typeof value === 'string') { + const options = this.getNodeParameter('options', itemIndex, {}); + + if (options.dotNotation === false) { + item.json[propertyName] = value; + } else { + set(item.json, propertyName, value); + } + + returnItems.push(item); + } else if (operation === 'keys') { + const keyPattern = this.getNodeParameter('keyPattern', itemIndex) as string; + const getValues = this.getNodeParameter('getValues', itemIndex, true) as boolean; + + const keys = await client.keys(keyPattern); + + if (!getValues) { + returnItems.push({ json: { keys } }); + continue; + } + + for (const keyName of keys) { + item.json[keyName] = await getValue(client, keyName); + } + returnItems.push(item); + } else if (operation === 'set') { + const keySet = this.getNodeParameter('key', itemIndex) as string; + const value = this.getNodeParameter('value', itemIndex) as string; + const keyType = this.getNodeParameter('keyType', itemIndex) as string; + const valueIsJSON = this.getNodeParameter('valueIsJSON', itemIndex, true) as boolean; + const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; + const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; + + await setValue.call(this, client, keySet, value, expire, ttl, keyType, valueIsJSON); + returnItems.push(items[itemIndex]); + } else if (operation === 'incr') { + const keyIncr = this.getNodeParameter('key', itemIndex) as string; + const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; + const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; + const incrementVal = await client.incr(keyIncr); + if (expire && ttl > 0) { + await client.expire(keyIncr, ttl); + } + returnItems.push({ json: { [keyIncr]: incrementVal } }); + } else if (operation === 'publish') { + const channel = this.getNodeParameter('channel', itemIndex) as string; + const messageData = this.getNodeParameter('messageData', itemIndex) as string; + await client.publish(channel, messageData); + returnItems.push(items[itemIndex]); + } else if (operation === 'push') { + const redisList = this.getNodeParameter('list', itemIndex) as string; + const messageData = this.getNodeParameter('messageData', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + await client[tail ? 'rPush' : 'lPush'](redisList, messageData); + returnItems.push(items[itemIndex]); + } else if (operation === 'pop') { + const redisList = this.getNodeParameter('list', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + const propertyName = this.getNodeParameter( + 'propertyName', + itemIndex, + 'propertyName', + ) as string; + + const value = await client[tail ? 'rPop' : 'lPop'](redisList); + + let outputValue; try { - values = JSON.parse(value); + outputValue = value && JSON.parse(value); } catch { - // This is how we originally worked and prevents a breaking change - values = value; + outputValue = value; } - } else { - values = value; - } - for (const key of Object.keys(values as object)) { - // @ts-ignore - await clientHset(keyName, key, (values as IDataObject)[key]!.toString()); - } - } else { - const values = value.toString().split(' '); - //@ts-ignore - await clientHset(keyName, values); - } - } else if (type === 'list') { - const clientLset = util.promisify(client.lset).bind(client); - for (let index = 0; index < (value as string[]).length; index++) { - await clientLset(keyName, index, (value as IDataObject)[index]!.toString()); - } - } else if (type === 'sets') { - const clientSadd = util.promisify(client.sadd).bind(client); - //@ts-ignore - await clientSadd(keyName, value); - } - - if (expire) { - const clientExpire = util.promisify(client.expire).bind(client); - await clientExpire(keyName, ttl); - } - return; - }; - - return new Promise(async (resolve, reject) => { - // TODO: For array and object fields it should not have a "value" field it should - // have a parameter field for a path. Because it is not possible to set - // array, object via parameter directly (should maybe be possible?!?!) - // Should maybe have a parameter which is JSON. - const credentials = await this.getCredentials('redis'); - - const redisOptions: redis.ClientOpts = { - host: credentials.host as string, - port: credentials.port as number, - db: credentials.database as number, - }; - - if (credentials.password) { - redisOptions.password = credentials.password as string; - } - - const client = redis.createClient(redisOptions); - - const operation = this.getNodeParameter('operation', 0); - - client.on('error', (err: Error) => { - client.quit(); - reject(err); - }); - - client.on('ready', async (_err: Error | null) => { - client.select(credentials.database as number); - try { - if (operation === 'info') { - const clientInfo = util.promisify(client.info).bind(client); - const result = await clientInfo(); - - resolve([[{ json: convertInfoToObject(result as string) }]]); - client.quit(); - } else if ( - ['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation) - ) { - const items = this.getInputData(); - const returnItems: INodeExecutionData[] = []; - - let item: INodeExecutionData; - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - item = { json: {} }; - - if (operation === 'delete') { - const keyDelete = this.getNodeParameter('key', itemIndex) as string; - - const clientDel = util.promisify(client.del).bind(client); - // @ts-ignore - await clientDel(keyDelete); - returnItems.push(items[itemIndex]); - } else if (operation === 'get') { - const propertyName = this.getNodeParameter('propertyName', itemIndex) as string; - const keyGet = this.getNodeParameter('key', itemIndex) as string; - const keyType = this.getNodeParameter('keyType', itemIndex) as string; - - const value = (await getValue(client, keyGet, keyType)) || null; - - const options = this.getNodeParameter('options', itemIndex, {}); - - if (options.dotNotation === false) { - item.json[propertyName] = value; - } else { - set(item.json, propertyName, value); - } - - returnItems.push(item); - } else if (operation === 'keys') { - const keyPattern = this.getNodeParameter('keyPattern', itemIndex) as string; - const getValues = this.getNodeParameter('getValues', itemIndex, true) as boolean; - - const clientKeys = util.promisify(client.keys).bind(client); - const keys = await clientKeys(keyPattern); - - if (!getValues) { - returnItems.push({ json: { keys } }); - continue; - } - - for (const keyName of keys) { - item.json[keyName] = await getValue(client, keyName); - } - returnItems.push(item); - } else if (operation === 'set') { - const keySet = this.getNodeParameter('key', itemIndex) as string; - const value = this.getNodeParameter('value', itemIndex) as string; - const keyType = this.getNodeParameter('keyType', itemIndex) as string; - const valueIsJSON = this.getNodeParameter( - 'valueIsJSON', - itemIndex, - true, - ) as boolean; - const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; - const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; - - await setValue(client, keySet, value, expire, ttl, keyType, valueIsJSON); - returnItems.push(items[itemIndex]); - } else if (operation === 'incr') { - const keyIncr = this.getNodeParameter('key', itemIndex) as string; - const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; - const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; - const clientIncr = util.promisify(client.incr).bind(client); - // @ts-ignore - const incrementVal = await clientIncr(keyIncr); - if (expire && ttl > 0) { - const clientExpire = util.promisify(client.expire).bind(client); - await clientExpire(keyIncr, ttl); - } - returnItems.push({ json: { [keyIncr]: incrementVal } }); - } else if (operation === 'publish') { - const channel = this.getNodeParameter('channel', itemIndex) as string; - const messageData = this.getNodeParameter('messageData', itemIndex) as string; - const clientPublish = util.promisify(client.publish).bind(client); - await clientPublish(channel, messageData); - returnItems.push(items[itemIndex]); - } else if (operation === 'push') { - const redisList = this.getNodeParameter('list', itemIndex) as string; - const messageData = this.getNodeParameter('messageData', itemIndex) as string; - const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; - const action = tail ? client.RPUSH : client.LPUSH; - const clientPush = util.promisify(action).bind(client); - // @ts-ignore: typescript not understanding generic function signatures - await clientPush(redisList, messageData); - returnItems.push(items[itemIndex]); - } else if (operation === 'pop') { - const redisList = this.getNodeParameter('list', itemIndex) as string; - const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; - const propertyName = this.getNodeParameter( - 'propertyName', - itemIndex, - 'propertyName', - ) as string; - - const action = tail ? client.rpop : client.lpop; - const clientPop = util.promisify(action).bind(client); - const value = await clientPop(redisList); - - let outputValue; - try { - outputValue = JSON.parse(value); - } catch { - outputValue = value; - } - const options = this.getNodeParameter('options', itemIndex, {}); - if (options.dotNotation === false) { - item.json[propertyName] = outputValue; - } else { - set(item.json, propertyName, outputValue); - } - returnItems.push(item); - } + const options = this.getNodeParameter('options', itemIndex, {}); + if (options.dotNotation === false) { + item.json[propertyName] = outputValue; + } else { + set(item.json, propertyName, outputValue); } - - client.quit(); - resolve([returnItems]); + returnItems.push(item); } - } catch (error) { - reject(error); } - }); - }); + } + } catch (error) { + throw error; + } finally { + await client.quit(); + } + + return [returnItems]; } } diff --git a/packages/nodes-base/nodes/Redis/RedisTrigger.node.ts b/packages/nodes-base/nodes/Redis/RedisTrigger.node.ts index 39622f0c0c..9616ba486a 100644 --- a/packages/nodes-base/nodes/Redis/RedisTrigger.node.ts +++ b/packages/nodes-base/nodes/Redis/RedisTrigger.node.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-loop-func */ import type { ITriggerFunctions, IDataObject, @@ -7,7 +8,7 @@ import type { } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import redis from 'redis'; +import { redisConnectionTest, setupRedisClient } from './utils'; export class RedisTrigger implements INodeType { description: INodeTypeDescription = { @@ -26,6 +27,7 @@ export class RedisTrigger implements INodeType { { name: 'redis', required: true, + testedBy: 'redisConnectionTest', }, ], properties: [ @@ -64,36 +66,29 @@ export class RedisTrigger implements INodeType { ], }; + methods = { + credentialTest: { redisConnectionTest }, + }; + async trigger(this: ITriggerFunctions): Promise { const credentials = await this.getCredentials('redis'); - const redisOptions: redis.ClientOpts = { - host: credentials.host as string, - port: credentials.port as number, - db: credentials.database as number, - }; - - if (credentials.password) { - redisOptions.password = credentials.password as string; - } - const channels = (this.getNodeParameter('channels') as string).split(','); - const options = this.getNodeParameter('options') as IDataObject; if (!channels) { throw new NodeOperationError(this.getNode(), 'Channels are mandatory!'); } - const client = redis.createClient(redisOptions); + const client = setupRedisClient(credentials); const manualTriggerFunction = async () => { - await new Promise((resolve, reject) => { - client.on('connect', () => { - for (const channel of channels) { - client.psubscribe(channel); - } - client.on('pmessage', (pattern: string, channel: string, message: string) => { + await client.connect(); + await client.ping(); + + try { + for (const channel of channels) { + await client.pSubscribe(channel, (message) => { if (options.jsonParseBody) { try { message = JSON.parse(message); @@ -102,19 +97,15 @@ export class RedisTrigger implements INodeType { if (options.onlyMessage) { this.emit([this.helpers.returnJsonArray({ message })]); - resolve(true); return; } this.emit([this.helpers.returnJsonArray({ channel, message })]); - resolve(true); }); - }); - - client.on('error', (error) => { - reject(error); - }); - }); + } + } catch (error) { + throw new NodeOperationError(this.getNode(), error); + } }; if (this.getMode() === 'trigger') { @@ -122,7 +113,7 @@ export class RedisTrigger implements INodeType { } async function closeFunction() { - client.quit(); + await client.quit(); } return { diff --git a/packages/nodes-base/nodes/Redis/test/Redis.node.test.ts b/packages/nodes-base/nodes/Redis/test/Redis.node.test.ts new file mode 100644 index 0000000000..892165169a --- /dev/null +++ b/packages/nodes-base/nodes/Redis/test/Redis.node.test.ts @@ -0,0 +1,158 @@ +import { mock } from 'jest-mock-extended'; +import type { RedisClientType } from '@redis/client'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { Redis } from '../Redis.node'; + +const mockClient = mock(); +jest.mock('redis', () => ({ + createClient: () => mockClient, +})); + +describe('Redis Node', () => { + const mockCredential = { + host: 'redis', + port: 1234, + database: 0, + password: 'random', + }; + + const node = new Redis(); + const thisArg = mock({}); + thisArg.getCredentials.calledWith('redis').mockResolvedValue(mockCredential); + + beforeEach(() => jest.clearAllMocks()); + + afterEach(() => { + expect(mockClient.connect).toHaveBeenCalled(); + expect(mockClient.ping).toHaveBeenCalled(); + expect(mockClient.quit).toHaveBeenCalled(); + }); + + it('info operation', async () => { + thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('info'); + mockClient.info.mockResolvedValue(` +# Server +redis_version:6.2.14 +redis_git_sha1:00000000 +redis_git_dirty:0 +redis_mode:standalone +arch_bits:64 +tcp_port:6379 +uptime_in_seconds:429905 +uptime_in_days:4 + +# Clients +connected_clients:1 +cluster_connections:0 +max_clients:10000 + +# Memory +used_memory:876648 + +# Replication +role:master +connected_slaves:0 +master_failover_state:no-failover + `); + + const output = await node.execute.call(thisArg); + + expect(mockClient.info).toHaveBeenCalled(); + expect(output[0][0].json).toEqual({ + redis_version: 6.2, + redis_git_sha1: 0, + redis_git_dirty: 0, + redis_mode: 'standalone', + arch_bits: 64, + tcp_port: 6379, + uptime_in_seconds: 429905, + uptime_in_days: 4, + connected_clients: 1, + cluster_connections: 0, + max_clients: 10000, + used_memory: 876648, + role: 'master', + connected_slaves: 0, + master_failover_state: 'no-failover', + }); + }); + + it('delete operation', async () => { + thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]); + thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('delete'); + thisArg.getNodeParameter.calledWith('key', 0).mockReturnValue('key1'); + mockClient.del.calledWith('key1').mockResolvedValue(1); + + const output = await node.execute.call(thisArg); + expect(mockClient.del).toHaveBeenCalledWith('key1'); + expect(output[0][0].json).toEqual({ x: 1 }); + }); + + describe('get operation', () => { + beforeEach(() => { + thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]); + thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('get'); + thisArg.getNodeParameter.calledWith('options', 0).mockReturnValue({ dotNotation: true }); + thisArg.getNodeParameter.calledWith('key', 0).mockReturnValue('key1'); + thisArg.getNodeParameter.calledWith('propertyName', 0).mockReturnValue('x.y'); + }); + + it('keyType = automatic', async () => { + thisArg.getNodeParameter.calledWith('keyType', 0).mockReturnValue('automatic'); + mockClient.type.calledWith('key1').mockResolvedValue('string'); + mockClient.get.calledWith('key1').mockResolvedValue('value'); + + const output = await node.execute.call(thisArg); + expect(mockClient.type).toHaveBeenCalledWith('key1'); + expect(mockClient.get).toHaveBeenCalledWith('key1'); + expect(output[0][0].json).toEqual({ x: { y: 'value' } }); + }); + + it('keyType = hash', async () => { + thisArg.getNodeParameter.calledWith('keyType', 0).mockReturnValue('hash'); + mockClient.hGetAll.calledWith('key1').mockResolvedValue({ + field1: '1', + field2: '2', + }); + + const output = await node.execute.call(thisArg); + expect(mockClient.hGetAll).toHaveBeenCalledWith('key1'); + expect(output[0][0].json).toEqual({ + x: { + y: { + field1: '1', + field2: '2', + }, + }, + }); + }); + }); + + describe('keys operation', () => { + beforeEach(() => { + thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]); + thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('keys'); + thisArg.getNodeParameter.calledWith('keyPattern', 0).mockReturnValue('key*'); + mockClient.keys.calledWith('key*').mockResolvedValue(['key1', 'key2']); + }); + + it('getValues = false', async () => { + thisArg.getNodeParameter.calledWith('getValues', 0).mockReturnValue(false); + + const output = await node.execute.call(thisArg); + expect(mockClient.keys).toHaveBeenCalledWith('key*'); + expect(output[0][0].json).toEqual({ keys: ['key1', 'key2'] }); + }); + + it('getValues = true', async () => { + thisArg.getNodeParameter.calledWith('getValues', 0).mockReturnValue(true); + mockClient.type.mockResolvedValue('string'); + mockClient.get.calledWith('key1').mockResolvedValue('value1'); + mockClient.get.calledWith('key2').mockResolvedValue('value2'); + + const output = await node.execute.call(thisArg); + expect(mockClient.keys).toHaveBeenCalledWith('key*'); + expect(output[0][0].json).toEqual({ key1: 'value1', key2: 'value2' }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Redis/utils.ts b/packages/nodes-base/nodes/Redis/utils.ts new file mode 100644 index 0000000000..6aca6b13bf --- /dev/null +++ b/packages/nodes-base/nodes/Redis/utils.ts @@ -0,0 +1,168 @@ +import type { + ICredentialDataDecryptedObject, + ICredentialTestFunctions, + ICredentialsDecrypted, + IDataObject, + IExecuteFunctions, + INodeCredentialTestResult, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { createClient } from 'redis'; +export type RedisClientType = ReturnType; + +export function setupRedisClient(credentials: ICredentialDataDecryptedObject): RedisClientType { + const redisOptions = { + socket: { + host: credentials.host as string, + port: credentials.port as number, + }, + database: credentials.database as number, + password: (credentials.password as string) || undefined, + }; + + return createClient(redisOptions); +} + +export async function redisConnectionTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, +): Promise { + const credentials = credential.data as ICredentialDataDecryptedObject; + + try { + const client = setupRedisClient(credentials); + await client.connect(); + await client.ping(); + return { + status: 'OK', + message: 'Connection successful!', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } +} + +/** Parses the given value in a number if it is one else returns a string */ +function getParsedValue(value: string): string | number { + if (value.match(/^[\d\.]+$/) === null) { + // Is a string + return value; + } else { + // Is a number + return parseFloat(value); + } +} + +/** Converts the Redis Info String into an object */ +export function convertInfoToObject(stringData: string): IDataObject { + const returnData: IDataObject = {}; + + let key: string, value: string; + for (const line of stringData.split('\n')) { + if (['#', ''].includes(line.charAt(0))) { + continue; + } + [key, value] = line.split(':'); + if (key === undefined || value === undefined) { + continue; + } + value = value.trim(); + + if (value.includes('=')) { + returnData[key] = {}; + let key2: string, value2: string; + for (const keyValuePair of value.split(',')) { + [key2, value2] = keyValuePair.split('='); + (returnData[key] as IDataObject)[key2] = getParsedValue(value2); + } + } else { + returnData[key] = getParsedValue(value); + } + } + + return returnData; +} + +export async function getValue(client: RedisClientType, keyName: string, type?: string) { + if (type === undefined || type === 'automatic') { + // Request the type first + type = await client.type(keyName); + } + + if (type === 'string') { + return client.get(keyName); + } else if (type === 'hash') { + return client.hGetAll(keyName); + } else if (type === 'list') { + return client.lRange(keyName, 0, -1); + } else if (type === 'sets') { + return client.sMembers(keyName); + } +} + +export async function setValue( + this: IExecuteFunctions, + client: RedisClientType, + keyName: string, + value: string | number | object | string[] | number[], + expire: boolean, + ttl: number, + type?: string, + valueIsJSON?: boolean, +) { + if (type === undefined || type === 'automatic') { + // Request the type first + if (typeof value === 'string') { + type = 'string'; + } else if (Array.isArray(value)) { + type = 'list'; + } else if (typeof value === 'object') { + type = 'hash'; + } else { + throw new NodeOperationError( + this.getNode(), + 'Could not identify the type to set. Please set it manually!', + ); + } + } + + if (type === 'string') { + await client.set(keyName, value.toString()); + } else if (type === 'hash') { + if (valueIsJSON) { + let values: unknown; + if (typeof value === 'string') { + try { + values = JSON.parse(value); + } catch { + // This is how we originally worked and prevents a breaking change + values = value; + } + } else { + values = value; + } + for (const key of Object.keys(values as object)) { + await client.hSet(keyName, key, (values as IDataObject)[key]!.toString()); + } + } else { + const values = value.toString().split(' '); + await client.hSet(keyName, values); + } + } else if (type === 'list') { + for (let index = 0; index < (value as string[]).length; index++) { + await client.lSet(keyName, index, (value as IDataObject)[index]!.toString()); + } + } else if (type === 'sets') { + //@ts-ignore + await client.sAdd(keyName, value); + } + + if (expire) { + await client.expire(keyName, ttl); + } + return; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index eb4fbbb36c..5770cb7731 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -816,7 +816,6 @@ "@types/node-ssh": "^7.0.1", "@types/nodemailer": "^6.4.0", "@types/promise-ftp": "^1.3.4", - "@types/redis": "^2.8.11", "@types/request-promise-native": "~1.0.15", "@types/rfc2047": "^2.0.1", "@types/showdown": "^1.9.4", @@ -877,7 +876,7 @@ "pretty-bytes": "5.6.0", "promise-ftp": "1.3.5", "pyodide": "0.23.4", - "redis": "3.1.2", + "redis": "4.6.12", "rfc2047": "4.0.1", "rhea": "1.0.24", "rss-parser": "3.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f418fe6ba0..9f12f9da06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,7 +231,7 @@ importers: version: 1.2.0 langchain: specifier: 0.0.198 - version: 0.0.198(@aws-sdk/client-bedrock-runtime@3.454.0)(@aws-sdk/credential-provider-node@3.451.0)(@getzep/zep-js@0.9.0)(@google-ai/generativelanguage@0.2.1)(@huggingface/inference@2.6.4)(@pinecone-database/pinecone@1.1.2)(@qdrant/js-client-rest@1.7.0)(@supabase/supabase-js@2.38.5)(@xata.io/client@0.25.3)(axios@1.6.2)(cohere-ai@6.2.2)(d3-dsv@2.0.0)(epub2@3.0.1)(html-to-text@9.0.5)(lodash@4.17.21)(mammoth@1.6.0)(pdf-parse@1.1.1)(pg@8.11.3)(redis@4.6.11)(typeorm@0.3.17) + version: 0.0.198(@aws-sdk/client-bedrock-runtime@3.454.0)(@aws-sdk/credential-provider-node@3.451.0)(@getzep/zep-js@0.9.0)(@google-ai/generativelanguage@0.2.1)(@huggingface/inference@2.6.4)(@pinecone-database/pinecone@1.1.2)(@qdrant/js-client-rest@1.7.0)(@supabase/supabase-js@2.38.5)(@xata.io/client@0.25.3)(axios@1.6.2)(cohere-ai@6.2.2)(d3-dsv@2.0.0)(epub2@3.0.1)(html-to-text@9.0.5)(lodash@4.17.21)(mammoth@1.6.0)(pdf-parse@1.1.1)(pg@8.11.3)(redis@4.6.12)(typeorm@0.3.17) lodash: specifier: 4.17.21 version: 4.17.21 @@ -257,8 +257,8 @@ importers: specifier: 8.11.3 version: 8.11.3 redis: - specifier: 4.6.11 - version: 4.6.11 + specifier: 4.6.12 + version: 4.6.12 sqlite3: specifier: 5.1.6 version: 5.1.6 @@ -267,7 +267,7 @@ importers: version: 0.9.4 typeorm: specifier: 0.3.17 - version: 0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.11)(sqlite3@5.1.6) + version: 0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.12)(sqlite3@5.1.6) zod: specifier: 3.22.4 version: 3.22.4 @@ -1363,8 +1363,8 @@ importers: specifier: 0.23.4 version: 0.23.4(patch_hash=kzcwsjcayy5m6iezu7r4tdimjq) redis: - specifier: 3.1.2 - version: 3.1.2 + specifier: 4.6.12 + version: 4.6.12 rfc2047: specifier: 4.0.1 version: 4.0.1 @@ -1465,9 +1465,6 @@ importers: '@types/promise-ftp': specifier: ^1.3.4 version: 1.3.4 - '@types/redis': - specifier: ^2.8.11 - version: 2.8.32 '@types/request-promise-native': specifier: ~1.0.15 version: 1.0.18 @@ -7158,16 +7155,16 @@ packages: '@babel/runtime': 7.22.6 dev: true - /@redis/bloom@1.2.0(@redis/client@1.5.12): + /@redis/bloom@1.2.0(@redis/client@1.5.13): resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: '@redis/client': ^1.0.0 dependencies: - '@redis/client': 1.5.12 + '@redis/client': 1.5.13 dev: false - /@redis/client@1.5.12: - resolution: {integrity: sha512-/ZjE18HRzMd80eXIIUIPcH81UoZpwulbo8FmbElrjPqH0QC0SeIKu1BOU49bO5trM5g895kAjhvalt5h77q+4A==} + /@redis/client@1.5.13: + resolution: {integrity: sha512-epkUM9D0Sdmt93/8Ozk43PNjLi36RZzG+d/T1Gdu5AI8jvghonTeLYV69WVWdilvFo+PYxbP0TZ0saMvr6nscQ==} engines: {node: '>=14'} dependencies: cluster-key-slot: 1.1.2 @@ -7175,36 +7172,36 @@ packages: yallist: 4.0.0 dev: false - /@redis/graph@1.1.1(@redis/client@1.5.12): + /@redis/graph@1.1.1(@redis/client@1.5.13): resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} peerDependencies: '@redis/client': ^1.0.0 dependencies: - '@redis/client': 1.5.12 + '@redis/client': 1.5.13 dev: false - /@redis/json@1.0.6(@redis/client@1.5.12): + /@redis/json@1.0.6(@redis/client@1.5.13): resolution: {integrity: sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==} peerDependencies: '@redis/client': ^1.0.0 dependencies: - '@redis/client': 1.5.12 + '@redis/client': 1.5.13 dev: false - /@redis/search@1.1.6(@redis/client@1.5.12): + /@redis/search@1.1.6(@redis/client@1.5.13): resolution: {integrity: sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==} peerDependencies: '@redis/client': ^1.0.0 dependencies: - '@redis/client': 1.5.12 + '@redis/client': 1.5.13 dev: false - /@redis/time-series@1.0.5(@redis/client@1.5.12): + /@redis/time-series@1.0.5(@redis/client@1.5.13): resolution: {integrity: sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==} peerDependencies: '@redis/client': ^1.0.0 dependencies: - '@redis/client': 1.5.12 + '@redis/client': 1.5.13 dev: false /@rollup/plugin-alias@5.1.0(rollup@3.29.4): @@ -10598,12 +10595,6 @@ packages: csstype: 3.1.1 dev: true - /@types/redis@2.8.32: - resolution: {integrity: sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==} - dependencies: - '@types/node': 18.16.16 - dev: true - /@types/replacestream@4.0.1: resolution: {integrity: sha512-3ecTmnzB90sgarVpIszCF1cX2cnxwqDovWb31jGrKfxAL0Knui1H7Reaz/zlT9zaE3u0un7L5cNy9fQPy0d2sg==} dev: true @@ -18941,7 +18932,7 @@ packages: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} dev: false - /langchain@0.0.198(@aws-sdk/client-bedrock-runtime@3.454.0)(@aws-sdk/credential-provider-node@3.451.0)(@getzep/zep-js@0.9.0)(@google-ai/generativelanguage@0.2.1)(@huggingface/inference@2.6.4)(@pinecone-database/pinecone@1.1.2)(@qdrant/js-client-rest@1.7.0)(@supabase/supabase-js@2.38.5)(@xata.io/client@0.25.3)(axios@1.6.2)(cohere-ai@6.2.2)(d3-dsv@2.0.0)(epub2@3.0.1)(html-to-text@9.0.5)(lodash@4.17.21)(mammoth@1.6.0)(pdf-parse@1.1.1)(pg@8.11.3)(redis@4.6.11)(typeorm@0.3.17): + /langchain@0.0.198(@aws-sdk/client-bedrock-runtime@3.454.0)(@aws-sdk/credential-provider-node@3.451.0)(@getzep/zep-js@0.9.0)(@google-ai/generativelanguage@0.2.1)(@huggingface/inference@2.6.4)(@pinecone-database/pinecone@1.1.2)(@qdrant/js-client-rest@1.7.0)(@supabase/supabase-js@2.38.5)(@xata.io/client@0.25.3)(axios@1.6.2)(cohere-ai@6.2.2)(d3-dsv@2.0.0)(epub2@3.0.1)(html-to-text@9.0.5)(lodash@4.17.21)(mammoth@1.6.0)(pdf-parse@1.1.1)(pg@8.11.3)(redis@4.6.12)(typeorm@0.3.17): resolution: {integrity: sha512-YC0O1g8r61InCWyF5NmiQjdghdq6LKcgMrDZtqLbgDxAe4RoSldonm+5oNXS3yjCISG0j3s5Cty+yB7klqvUpg==} engines: {node: '>=18'} peerDependencies: @@ -19279,8 +19270,8 @@ packages: p-retry: 4.6.2 pdf-parse: 1.1.1 pg: 8.11.3 - redis: 4.6.11 - typeorm: 0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.11)(sqlite3@5.1.6) + redis: 4.6.12 + typeorm: 0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.12)(sqlite3@5.1.6) uuid: 9.0.0 yaml: 2.3.4 zod: 3.22.4 @@ -23233,25 +23224,15 @@ packages: dependencies: redis-errors: 1.2.0 - /redis@3.1.2: - resolution: {integrity: sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==} - engines: {node: '>=10'} + /redis@4.6.12: + resolution: {integrity: sha512-41Xuuko6P4uH4VPe5nE3BqXHB7a9lkFL0J29AlxKaIfD6eWO8VO/5PDF9ad2oS+mswMsfFxaM5DlE3tnXT+P8Q==} dependencies: - denque: 1.5.1 - redis-commands: 1.7.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - dev: false - - /redis@4.6.11: - resolution: {integrity: sha512-kg1Lt4NZLYkAjPOj/WcyIGWfZfnyfKo1Wg9YKVSlzhFwxpFIl3LYI8BWy1Ab963LLDsTz2+OwdsesHKljB3WMQ==} - dependencies: - '@redis/bloom': 1.2.0(@redis/client@1.5.12) - '@redis/client': 1.5.12 - '@redis/graph': 1.1.1(@redis/client@1.5.12) - '@redis/json': 1.0.6(@redis/client@1.5.12) - '@redis/search': 1.1.6(@redis/client@1.5.12) - '@redis/time-series': 1.0.5(@redis/client@1.5.12) + '@redis/bloom': 1.2.0(@redis/client@1.5.13) + '@redis/client': 1.5.13 + '@redis/graph': 1.1.1(@redis/client@1.5.13) + '@redis/json': 1.0.6(@redis/client@1.5.13) + '@redis/search': 1.1.6(@redis/client@1.5.13) + '@redis/time-series': 1.0.5(@redis/client@1.5.13) dev: false /reflect-metadata@0.1.13: @@ -25740,7 +25721,7 @@ packages: - supports-color dev: false - /typeorm@0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.11)(sqlite3@5.1.6): + /typeorm@0.3.17(mssql@9.1.1)(pg@8.11.3)(redis@4.6.12)(sqlite3@5.1.6): resolution: {integrity: sha512-UDjUEwIQalO9tWw9O2A4GU+sT3oyoUXheHJy4ft+RFdnRdQctdQ34L9SqE2p7LdwzafHx1maxT+bqXON+Qnmig==} engines: {node: '>= 12.9.0'} hasBin: true @@ -25810,7 +25791,7 @@ packages: mkdirp: 2.1.3 mssql: 9.1.1 pg: 8.11.3 - redis: 4.6.11 + redis: 4.6.12 reflect-metadata: 0.1.13 sha.js: 2.4.11 sqlite3: 5.1.6