From c5bde41692047e88a2050ddca0ca4a9b0eae8130 Mon Sep 17 00:00:00 2001 From: leibale Date: Wed, 7 Jul 2021 18:02:22 -0400 Subject: [PATCH] better modules support, fix some bugs in legacy mode, add some tests --- lib/client.spec.ts | 75 ++++++++++++++++++++++++++--- lib/client.ts | 108 ++++++++++++++++++++++++++---------------- lib/cluster.ts | 40 +++++++++++++--- lib/commands-queue.ts | 2 +- lib/commands/index.ts | 2 +- lib/multi-command.ts | 108 +++++++++++++++++++++++++++++------------- 6 files changed, 244 insertions(+), 91 deletions(-) diff --git a/lib/client.spec.ts b/lib/client.spec.ts index 031b9c36f9..964801b2c4 100644 --- a/lib/client.spec.ts +++ b/lib/client.spec.ts @@ -37,6 +37,18 @@ describe('Client', () => { describe('legacyMode', () => { const client = RedisClient.create({ socket: TEST_REDIS_SERVERS[TestRedisServers.OPEN], + modules: { + testModule: { + echo: { + transformArguments(message: string): Array { + return ['ECHO', message]; + }, + transformReply(reply: string): string { + return reply; + } + } + } + }, legacyMode: true }); @@ -116,7 +128,7 @@ describe('Client', () => { }); }); - it('client.multi.exec should call the callback', done => { + it('client.multi.ping.exec should call the callback', done => { (client as any).multi() .ping() .exec((err?: Error, reply?: string) => { @@ -133,19 +145,70 @@ describe('Client', () => { }); }); - it('client.multi.exec should work without callback', async () => { + it('client.multi.ping.exec should work without callback', async () => { (client as any).multi() .ping() .exec(); await client.v4.ping(); // make sure the first command was replied }); - it('client.v4.exec should return a promise', async () => { + it('client.multi.ping.v4.ping.v4.exec should return a promise', async () => { assert.deepEqual( - await ((client as any).multi().v4 + await ((client as any).multi() .ping() - .exec()), - ['PONG'] + .v4.ping() + .v4.exec()), + ['PONG', 'PONG'] + ); + }); + + it('client.testModule.echo should call the callback', done => { + (client as any).testModule.echo('message', (err?: Error, reply?: string) => { + if (err) { + return done(err); + } + + try { + assert.deepEqual(reply, 'message'); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.v4.testModule.echo should return a promise', async () => { + assert.equal( + await (client as any).v4.testModule.echo('message'), + 'message' + ); + }); + + it('client.multi.testModule.echo.v4.testModule.echo.exec should call the callback', done => { + (client as any).multi() + .testModule.echo('message') + .v4.testModule.echo('message') + .exec((err?: Error, replies?: Array) => { + if (err) { + return done(err); + } + + try { + assert.deepEqual(replies, ['message', 'message']); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.multi.testModule.echo.v4.testModule.echo.v4.exec should return a promise', async () => { + assert.deepEqual( + await ((client as any).multi() + .testModule.echo('message') + .v4.testModule.echo('message') + .v4.exec()), + ['message', 'message'] ); }); }); diff --git a/lib/client.ts b/lib/client.ts index a0300f9b2b..cdb54299f8 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -27,7 +27,9 @@ type WithCommands = { }; type WithModules = { - [P in keyof M[number]]: RedisCommandSignature; + [P in keyof M]: { + [C in keyof M[P]]: RedisCommandSignature; + }; }; type WithScripts = { @@ -45,18 +47,6 @@ export interface ClientCommandOptions extends QueueCommandOptions { } export default class RedisClient extends EventEmitter { - static defineCommand(on: any, name: string, command: RedisCommand): void { - on[name] = async function (...args: Array): Promise { - const options = isCommandOptions(args[0]) && args[0]; - return command.transformReply( - await this.sendCommand( - command.transformArguments(...(options ? args.slice(1) : args)), - options - ) - ); - }; - } - static create(options?: RedisClientOptions): RedisClientType { return new RedisClient(options); } @@ -69,7 +59,7 @@ export default class RedisClient }; - readonly #v4: Record = {}; + readonly #v4: Record = {}; #selectedDB = 0; get options(): RedisClientOptions | null | undefined { @@ -80,7 +70,7 @@ export default class RedisClient { + get v4(): Record { if (!this.#options?.legacyMode) { throw new Error('the client is not in "legacy mode"'); } @@ -101,18 +91,19 @@ export default class RedisClient => { - const promises = []; + const v4Commands = this.#options?.legacyMode ? this.#v4 : this, + promises = []; if (this.#selectedDB !== 0) { - promises.push((this as any).select(RedisClient.commandOptions({ asap: true }), this.#selectedDB)); + promises.push(v4Commands.select(RedisClient.commandOptions({ asap: true }), this.#selectedDB)); } if (this.#options?.readonly) { - promises.push((this as any).readonly(RedisClient.commandOptions({ asap: true }))); + promises.push(v4Commands.readonly(RedisClient.commandOptions({ asap: true }))); } if (this.#options?.socket?.password) { - promises.push((this as any).auth(RedisClient.commandOptions({ asap: true }), this.#options?.socket)); + promises.push(v4Commands.auth(RedisClient.commandOptions({ asap: true }), this.#options.socket)); } const resubscribePromise = this.#queue.resubscribe(); @@ -170,11 +161,16 @@ export default class RedisClient; + } = {}; + + for (const [commandName, command] of Object.entries(commands)) { + module[commandName] = (...args) => this.executeCommand(command, args); } + + (this as any)[moduleName] = module; } } @@ -196,7 +192,7 @@ export default class RedisClient(script: S, args: Array, options?: ClientCommandOptions): Promise> { try { - return await this.sendCommand([ + return await this.#sendCommand([ 'EVALSHA', script.SHA, script.NUMBER_OF_KEYS.toString(), @@ -207,7 +203,7 @@ export default class RedisClient): void => { - const options = isCommandOptions(args[0]) && args.shift(), - callback = typeof args[args.length - 1] === 'function' && (args.pop() as Function); - - this.#v4.sendCommand(args.flat(), options) + const options = isCommandOptions(args[0]) ? args[0] : undefined, + callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] as Function : undefined, + actualArgs = !options && !callback ? args : args.slice(options ? 1 : 0, callback ? -1 : Infinity); + this.#sendCommand(actualArgs.flat() as Array, options) .then((reply: unknown) => { if (!callback) return; @@ -260,9 +255,10 @@ export default class RedisClient): void { - this.sendCommand(name, ...args); + #defineLegacyCommand(name: string, moduleName?: string): void { + const handler = (...args: Array): void => { + (this as any).sendCommand(name, ...args); }; + + if (moduleName) { + (this as any).#v4[moduleName][name] = (this as any)[moduleName][name]; + (this as any)[moduleName][name] = handler; + } else { + (this as any).#v4[name] = (this as any)[name].bind(this); + (this as any)[name] = handler; + } } duplicate(): RedisClientType { @@ -297,7 +300,7 @@ export default class RedisClient(args: Array, options?: ClientCommandOptions): Promise { + sendCommand(args: Array, options?: ClientCommandOptions): Promise { + return this.#sendCommand(args, options); + } + + async #sendCommand(args: Array, options?: ClientCommandOptions): Promise { if (options?.duplicateConnection) { const duplicate = this.duplicate(); await duplicate.connect(); try { - return await duplicate.sendCommand(args, { + return await duplicate.#sendCommand(args, { ...options, duplicateConnection: false }); @@ -359,6 +366,21 @@ export default class RedisClient): Promise { + let options; + if (isCommandOptions(args[0])) { + options = args[0]; + args = args.slice(1); + } + + return command.transformReply( + await this.#sendCommand( + command.transformArguments(...args), + options + ) + ); + } + multi(): RedisMultiCommandType { return new this.#Multi(); } @@ -430,5 +452,7 @@ export default class RedisClient): Promise { + return this.executeCommand(command, args); + }; } diff --git a/lib/cluster.ts b/lib/cluster.ts index bf43bb36a6..61d36ef6f6 100644 --- a/lib/cluster.ts +++ b/lib/cluster.ts @@ -1,6 +1,6 @@ import COMMANDS from './commands'; import { RedisCommand, RedisModules } from './commands'; -import { ClientCommandOptions, RedisClientType, WithPlugins } from './client'; +import { ClientCommandOptions, RedisClientType, RedisCommandSignature, WithPlugins } from './client'; import { RedisSocketOptions } from './socket'; import RedisClusterSlots, { ClusterNode } from './cluster-slots'; import { RedisLuaScript, RedisLuaScripts } from './lua-script'; @@ -18,12 +18,12 @@ export type RedisClusterType WithPlugins & RedisCluster; export default class RedisCluster { - static defineCommand(on: any, name: string, command: RedisCommand): void { + static defineCommand(on: any, name: string, command: RedisCommand, cluster?: RedisCluster): void { on[name] = async function (...args: Array): Promise { const options = isCommandOptions(args[0]) && args[0], redisArgs = command.transformArguments(...(options ? args.slice(1) : args)); return command.transformReply( - await this.sendCommand( + await (cluster ?? this).sendCommand( RedisCluster.#extractFirstKey(command, args, redisArgs), command.IS_READ_ONLY, redisArgs, @@ -64,10 +64,16 @@ export default class RedisCluster; + } = {}; + + for (const [commandName, command] of Object.entries(commands)) { + module[commandName] = (...args) => this.executeCommand(command, args); } + + (this as any)[moduleName] = module; } } @@ -116,6 +122,24 @@ export default class RedisCluster): Promise<(typeof command)['transformReply']> { + let options; + if (isCommandOptions(args[0])) { + options = args[0]; + args = args.slice(1); + } + + const redisArgs = command.transformArguments(...args); + return command.transformReply( + await this.sendCommand( + RedisCluster.#extractFirstKey(command, args, redisArgs), + command.IS_READ_ONLY, + redisArgs, + options + ) + ); + } + async executeScript( script: S, originalArgs: Array, @@ -183,5 +207,7 @@ export default class RedisCluster) { + return this.executeCommand(command, args); + }; } diff --git a/lib/commands-queue.ts b/lib/commands-queue.ts index f81c7f3648..dbbe0c5f94 100644 --- a/lib/commands-queue.ts +++ b/lib/commands-queue.ts @@ -113,7 +113,7 @@ export default class RedisCommandsQueue { return; } } - + this.#shiftWaitingForReply().resolve(reply); }, returnError: (err: Error) => this.#shiftWaitingForReply().reject(err) diff --git a/lib/commands/index.ts b/lib/commands/index.ts index f812659c0f..d86753c39c 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -585,4 +585,4 @@ export interface RedisModule { [key: string]: RedisCommand; } -export type RedisModules = Array; +export type RedisModules = Record; diff --git a/lib/multi-command.ts b/lib/multi-command.ts index 1454cd22de..a639a21640 100644 --- a/lib/multi-command.ts +++ b/lib/multi-command.ts @@ -1,7 +1,7 @@ import COMMANDS from './commands'; import { RedisCommand, RedisModules, RedisReply } from './commands'; import RedisCommandsQueue from './commands-queue'; -import { RedisLuaScripts } from './lua-script'; +import { RedisLuaScript, RedisLuaScripts } from './lua-script'; import { RedisClientOptions } from './client'; type RedisMultiCommandSignature = (...args: Parameters) => RedisMultiCommandType; @@ -11,7 +11,9 @@ type WithCommands = { }; type WithModules = { - [P in keyof M[number]]: RedisMultiCommandSignature + [P in keyof M]: { + [C in keyof M[P]]: RedisMultiCommandSignature; + }; }; type WithScripts = { @@ -28,14 +30,6 @@ export interface MultiQueuedCommand { export type RedisMultiExecutor = (queue: Array, chainId?: symbol) => Promise>; export default class RedisMultiCommand { - static defineCommand(on: any, name: string, command: RedisCommand): void { - on[name] = function (...args: Parameters) { - // do not return `this.addCommand` directly cause in legacy mode it's binded to the legacy version - this.addCommand(command.transformArguments(...args), command.transformReply); - return this; - }; - } - static create(executor: RedisMultiExecutor, clientOptions?: RedisClientOptions): RedisMultiCommandType { return new RedisMultiCommand(executor, clientOptions); } @@ -48,9 +42,9 @@ export default class RedisMultiCommand(); - readonly #v4: Record = {}; + readonly #v4: Record = {}; - get v4(): Record { + get v4(): Record { if (!this.#clientOptions?.legacyMode) { throw new Error('client is not in "legacy mode"'); } @@ -69,10 +63,16 @@ export default class RedisMultiCommand + } = {}; + + for (const [commandName, command] of Object.entries(commands)) { + module[commandName] = (...args) => this.executeCommand(command, args); } + + (this as any)[moduleName] = module; } } @@ -107,16 +107,14 @@ export default class RedisMultiCommand | undefined { + #legacyMode(): void { if (!this.#clientOptions?.legacyMode) return; this.#v4.exec = this.exec.bind(this); - this.#v4.addCommand = this.addCommand.bind(this); - (this as any).exec = function (...args: Array): void { - const callback = typeof args[args.length - 1] === 'function' && args.pop() as Function; + (this as any).exec = (callback?: (err: Error | null, replies?: Array) => unknown): void => { this.#v4.exec() - .then((reply: unknown) => { + .then((reply: Array) => { if (!callback) return; callback(null, reply); @@ -131,15 +129,15 @@ export default class RedisMultiCommand) { - return this.addCommand([name, ...args.flat()]); + #defineLegacyCommand(name: string, moduleName?: string): void { + const handler = (...args: Array): RedisMultiCommandType => { + return this.addCommand([ + name, + ...args.flat() as Array + ]); }; + + if (moduleName) { + this.#v4[moduleName][name] = (this as any)[moduleName][name]; + (this as any)[moduleName][name] = handler; + } else { + this.#v4[name] = (this as any)[name].bind(this); + (this as any)[name] = handler; + } } - addCommand(args: Array, transformReply?: RedisCommand['transformReply']): this { + addCommand(args: Array, transformReply?: RedisCommand['transformReply']): RedisMultiCommandType { this.#queue.push({ encodedCommand: RedisCommandsQueue.encodeCommand(args), transformReply }); - return this; + return this; + } + + executeCommand(command: RedisCommand, args: Array): RedisMultiCommandType { + return this.addCommand( + command.transformArguments(...args), + command.transformReply + ); + } + + executeScript(name: string, script: RedisLuaScript, args: Array): RedisMultiCommandType { + const evalArgs = []; + if (this.#scriptsInUse.has(name)) { + evalArgs.push( + 'EVALSHA', + script.SHA + ); + } else { + this.#scriptsInUse.add(name); + evalArgs.push( + 'EVAL', + script.SCRIPT + ); + } + + return this.addCommand( + [ + ...evalArgs, + script.NUMBER_OF_KEYS.toString(), + ...script.transformArguments(...args) + ], + script.transformReply + ); } async exec(execAsPipeline = false): Promise> { @@ -201,5 +239,7 @@ export default class RedisMultiCommand): RedisMultiCommand { + return this.executeCommand(command, args); + }; }