diff --git a/lib/client.spec.ts b/lib/client.spec.ts index efc396bd7a..df166d50a9 100644 --- a/lib/client.spec.ts +++ b/lib/client.spec.ts @@ -6,6 +6,17 @@ import { AbortError } from './errors'; import { defineScript } from './lua-script'; import { spy } from 'sinon'; +const SQUARE_SCRIPT = defineScript({ + NUMBER_OF_KEYS: 0, + SCRIPT: 'return ARGV[1] * ARGV[1];', + transformArguments(number: number): Array { + return [number.toString()]; + }, + transformReply(reply: number): number { + return reply; + } +}); + describe('Client', () => { describe('authentication', () => { itWithClient(TestRedisServers.PASSWORD, 'Client should be authenticated', async client => { @@ -26,7 +37,6 @@ describe('Client', () => { await assert.rejects( client.connect(), { - message: isRedisVersionGreaterThan([6]) ? 'WRONGPASS invalid username-password pair or user is disabled.' : 'ERR invalid password' @@ -40,17 +50,8 @@ 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; - } - } - } + scripts: { + square: SQUARE_SCRIPT }, legacyMode: true }); @@ -164,55 +165,9 @@ describe('Client', () => { ['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'] - ); + it('client.{script} should return a promise', async () => { + assert.equal(await client.square(2), 4); }); }); @@ -296,51 +251,29 @@ describe('Client', () => { it('with script', async () => { const client = RedisClient.create({ scripts: { - add: defineScript({ - NUMBER_OF_KEYS: 0, - SCRIPT: 'return ARGV[1] + 1;', - transformArguments(number: number): Array { - assert.equal(number, 1); - return [number.toString()]; - }, - transformReply(reply: number): number { - assert.equal(reply, 2); - return reply; - } - }) + square: SQUARE_SCRIPT } }); - + await client.connect(); try { assert.deepEqual( await client.multi() - .add(1) + .square(2) .exec(), - [2] + [4] ); } finally { await client.disconnect(); } }); }); - + it('scripts', async () => { const client = RedisClient.create({ scripts: { - add: defineScript({ - NUMBER_OF_KEYS: 0, - SCRIPT: 'return ARGV[1] + 1;', - transformArguments(number: number): Array { - assert.equal(number, 1); - return [number.toString()]; - }, - transformReply(reply: number): number { - assert.equal(reply, 2); - return reply; - } - }) + square: SQUARE_SCRIPT } }); @@ -348,8 +281,8 @@ describe('Client', () => { try { assert.equal( - await client.add(1), - 2 + await client.square(2), + 4 ); } finally { await client.disconnect(); @@ -514,7 +447,7 @@ describe('Client', () => { await subscriber.pUnsubscribe(); await publisher.publish('channel', 'message'); - + assert.ok(channelListener1.calledOnce); assert.ok(channelListener2.calledTwice); assert.ok(patternListener.calledThrice); diff --git a/lib/client.ts b/lib/client.ts index fb857bf4ff..54856a7fbd 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -9,6 +9,7 @@ import { RedisLuaScript, RedisLuaScripts } from './lua-script'; import { ScanOptions, ZMember } from './commands/generic-transformers'; import { ScanCommandOptions } from './commands/SCAN'; import { HScanTuple } from './commands/HSCAN'; +import { extendWithDefaultCommands, extendWithModulesAndScripts, transformCommandArguments } from './commander'; export interface RedisClientOptions { socket?: RedisSocketOptions; @@ -47,18 +48,54 @@ export interface ClientCommandOptions extends QueueCommandOptions { } export default class RedisClient extends EventEmitter { - static create(options?: RedisClientOptions): RedisClientType { - return new RedisClient(options); - } - static commandOptions(options: ClientCommandOptions): CommandOptions { return commandOptions(options); } + static async commandsExecutor( + this: RedisClient, + command: RedisCommand, + args: Array + ): Promise> { + const { args: redisArgs, options } = transformCommandArguments(command, args); + + const reply = command.transformReply( + await this.#sendCommand(redisArgs, options), + redisArgs.preserve + ); + + return reply; + } + + static async #scriptsExecutor( + this: RedisClient, + script: RedisLuaScript, + args: Array + ): Promise { + const { args: redisArgs, options } = transformCommandArguments(script, args); + + const reply = script.transformReply( + await this.executeScript(script, redisArgs, options), + redisArgs.preserve + ); + + return reply; + } + + static create(options?: RedisClientOptions): RedisClientType { + return new (extendWithModulesAndScripts({ + BaseClass: RedisClient, + modules: options?.modules, + modulesCommandsExecutor: RedisClient.commandsExecutor, + scripts: options?.scripts, + scriptsExecutor: RedisClient.#scriptsExecutor + }))(options); + } + readonly #options?: RedisClientOptions; readonly #socket: RedisSocket; readonly #queue: RedisCommandsQueue; - readonly #Multi: typeof RedisMultiCommand & { new(): RedisMultiCommandType }; + readonly #Multi: new (...args: ConstructorParameters) => RedisMultiCommandType; readonly #v4: Record = {}; #selectedDB = 0; @@ -83,9 +120,7 @@ export default class RedisClient } { - const executor = async (commands: Array): Promise> => { - const promise = Promise.all( - commands.map(({encodedCommand}) => { - return this.#queue.addEncodedCommand(encodedCommand); - }) - ); - - this.#tick(); - - return await promise; - }; - - const options = this.#options; - return class extends RedisMultiCommand { - constructor() { - super(executor, options); - } - }; - } - - #initiateModules(): void { - if (!this.#options?.modules) return; - - for (const [moduleName, commands] of Object.entries(this.#options.modules)) { - const module: { - [P in keyof typeof commands]: RedisCommandSignature<(typeof commands)[P]>; - } = {}; - - for (const [commandName, command] of Object.entries(commands)) { - module[commandName] = (...args) => this.executeCommand(command, args); - } - - (this as any)[moduleName] = module; - } - } - - #initiateScripts(): void { - if (!this.#options?.scripts) return; - - for (const [name, script] of Object.entries(this.#options.scripts)) { - (this as any)[name] = async function (...args: Parameters): Promise> { - let options; - if (isCommandOptions(args[0])) { - options = args[0]; - args = args.slice(1); - } - - const transformedArguments = script.transformArguments(...args); - return script.transformReply( - await this.executeScript( - script, - transformedArguments, - options - ), - transformedArguments.preserve - ); - }; - } - } - - async executeScript(script: S, args: Array, options?: ClientCommandOptions): Promise> { - try { - return await this.#sendCommand([ - 'EVALSHA', - script.SHA, - script.NUMBER_OF_KEYS.toString(), - ...args - ], options); - } catch (err: any) { - if (!err?.message?.startsWith?.('NOSCRIPT')) { - throw err; - } - - return await this.#sendCommand([ - 'EVAL', - script.SCRIPT, - script.NUMBER_OF_KEYS.toString(), - ...args - ], options); - } - } - #legacyMode(): void { if (!this.#options?.legacyMode) return; - (this as any).#v4.sendCommand = this.sendCommand.bind(this); + (this as any).#v4.sendCommand = this.#sendCommand.bind(this); (this as any).sendCommand = (...args: Array): void => { - 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) + const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] as Function : undefined, + actualArgs = !callback ? args : args.slice(0, -1); + this.#sendCommand(actualArgs.flat() as Array) .then((reply: unknown) => { if (!callback) return; @@ -244,7 +195,7 @@ export default class RedisClient): void => { + #defineLegacyCommand(name: string): void { + (this as any).#v4[name] = (this as any)[name].bind(this); + (this as any)[name] = (...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 { - return RedisClient.create(this.#options); + return new (Object.getPrototypeOf(this).constructor)(this.#options); } async connect(): Promise { @@ -354,13 +283,14 @@ export default class RedisClient(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 }); @@ -374,25 +304,45 @@ export default class RedisClient): Promise { - let options; - if (isCommandOptions(args[0])) { - options = args[0]; - args = args.slice(1); - } + async executeScript(script: RedisLuaScript, args: Array, options?: ClientCommandOptions): Promise> { + try { + return await this.#sendCommand([ + 'EVALSHA', + script.SHA, + script.NUMBER_OF_KEYS.toString(), + ...args + ], options); + } catch (err: any) { + if (!err?.message?.startsWith?.('NOSCRIPT')) { + throw err; + } - const transformedArguments = command.transformArguments(...args); - return command.transformReply( - await this.#sendCommand( - transformedArguments, - options - ), - transformedArguments.preserve + return await this.#sendCommand([ + 'EVAL', + script.SCRIPT, + script.NUMBER_OF_KEYS.toString(), + ...args + ], options); + } + } + + async #multiExecutor(commands: Array): Promise> { + const promise = Promise.all( + commands.map(({encodedCommand}) => { + return this.#queue.addEncodedCommand(encodedCommand); + }) ); + + this.#tick(); + + return await promise; } multi(): RedisMultiCommandType { - return new this.#Multi(); + return new this.#Multi( + this.#multiExecutor.bind(this), + this.#options + ); } async* scanIterator(options?: ScanCommandOptions): AsyncIterable { @@ -470,8 +420,4 @@ export default class RedisClient): Promise { - return this.executeCommand(command, args); - }; -} +extendWithDefaultCommands(RedisClient, RedisClient.commandsExecutor); diff --git a/lib/cluster.ts b/lib/cluster.ts index bb89cc59c5..15fce848bf 100644 --- a/lib/cluster.ts +++ b/lib/cluster.ts @@ -1,10 +1,10 @@ -import COMMANDS from './commands'; import { RedisCommand, RedisModules } from './commands'; -import { ClientCommandOptions, RedisClientType, RedisCommandSignature, WithPlugins } from './client'; +import { ClientCommandOptions, RedisClientType, WithPlugins } from './client'; import { RedisSocketOptions } from './socket'; import RedisClusterSlots, { ClusterNode } from './cluster-slots'; import { RedisLuaScript, RedisLuaScripts } from './lua-script'; -import { commandOptions, CommandOptions, isCommandOptions } from './command-options'; +import { commandOptions, CommandOptions } from './command-options'; +import { extendWithModulesAndScripts, extendWithDefaultCommands, transformCommandArguments } from './commander'; export interface RedisClusterOptions { rootNodes: Array; @@ -28,8 +28,54 @@ export default class RedisCluster(options: RedisClusterOptions): RedisClusterType { - return new RedisCluster(options); + static async commandsExecutor( + this: RedisCluster, + command: RedisCommand, + args: Array + ): Promise> { + const { args: redisArgs, options } = transformCommandArguments(command, args); + + const reply = command.transformReply( + await this.sendCommand( + RedisCluster.#extractFirstKey(command, args, redisArgs), + command.IS_READ_ONLY, + redisArgs, + options + ), + redisArgs.preserve + ); + + return reply; + } + + static async #scriptsExecutor( + this: RedisCluster, + script: RedisLuaScript, + args: Array + ): Promise { + const { args: redisArgs, options } = transformCommandArguments(script, args); + + const reply = script.transformReply( + await this.executeScript( + script, + args, + redisArgs, + options + ), + redisArgs.preserve + ); + + return reply; + } + + static create(options?: RedisClusterOptions): RedisClusterType { + return new (extendWithModulesAndScripts({ + BaseClass: RedisCluster, + modules: options?.modules, + modulesCommandsExecutor: RedisCluster.commandsExecutor, + scripts: options?.scripts, + scriptsExecutor: RedisCluster.#scriptsExecutor + }))(options); } static commandOptions(options: ClientCommandOptions): CommandOptions { @@ -42,49 +88,6 @@ export default class RedisCluster) { this.#options = options; this.#slots = new RedisClusterSlots(options); - this.#initiateModules(); - this.#initiateScripts(); - } - - #initiateModules(): void { - if (!this.#options.modules) return; - - for (const [moduleName, commands] of Object.entries(this.#options.modules)) { - const module: { - [P in keyof typeof commands]: RedisCommandSignature<(typeof commands)[P]>; - } = {}; - - for (const [commandName, command] of Object.entries(commands)) { - module[commandName] = (...args) => this.executeCommand(command, args); - } - - (this as any)[moduleName] = module; - } - } - - #initiateScripts(): void { - if (!this.#options.scripts) return; - - for (const [name, script] of Object.entries(this.#options.scripts)) { - (this as any)[name] = async function (...args: Parameters): Promise> { - let options; - if (isCommandOptions(args[0])) { - options = args[0]; - args = args.slice(1); - } - - const transformedArguments = script.transformArguments(...args); - return script.transformReply( - await this.executeScript( - script, - args, - transformedArguments, - options - ), - transformedArguments.preserve - ); - }; - } } async connect(): Promise { @@ -114,32 +117,13 @@ 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 - ), - redisArgs.preserve - ); - } - - async executeScript( - script: S, + async executeScript( + script: RedisLuaScript, originalArgs: Array, redisArgs: Array, options?: ClientCommandOptions, redirections = 0 - ): Promise> { + ): Promise> { const client = this.#slots.getClient( RedisCluster.#extractFirstKey(script, originalArgs, redisArgs), script.IS_READ_ONLY @@ -199,8 +183,5 @@ export default class RedisCluster) { - return this.executeCommand(command, args); - }; -} +extendWithDefaultCommands(RedisCluster, RedisCluster.commandsExecutor); + diff --git a/lib/commander.ts b/lib/commander.ts new file mode 100644 index 0000000000..c896c45b75 --- /dev/null +++ b/lib/commander.ts @@ -0,0 +1,88 @@ + +import COMMANDS, { RedisCommand, RedisModules, TransformArgumentsReply } from './commands'; +import { RedisLuaScript, RedisLuaScripts } from './lua-script'; +import { CommandOptions, isCommandOptions } from './command-options'; + +type Instantiable = new(...args: Array) => T; + +type CommandExecutor = (this: InstanceType, command: RedisCommand, args: Array) => unknown; + +export function extendWithDefaultCommands(BaseClass: T, executor: CommandExecutor): void { + for (const [name, command] of Object.entries(COMMANDS)) { + BaseClass.prototype[name] = function (...args: Array): unknown { + return executor.call(this, command, args); + }; + } +} + +interface ExtendWithModulesAndScriptsConfig< + T extends Instantiable, + M extends RedisModules, + S extends RedisLuaScripts +> { + BaseClass: T; + modules: M | undefined; + modulesCommandsExecutor: CommandExecutor; + scripts: S | undefined; + scriptsExecutor(this: InstanceType, script: RedisLuaScript, args: Array): unknown; +} + +export function extendWithModulesAndScripts< + T extends Instantiable, + M extends RedisModules, + S extends RedisLuaScripts, +>(config: ExtendWithModulesAndScriptsConfig): T { + let Commander: T | undefined, + modulesBaseObject: Record; + + if (config.modules) { + modulesBaseObject = Object.create(null); + Commander = class extends config.BaseClass { + constructor(...args: Array) { + super(...args); + modulesBaseObject.self = this; + } + }; + + for (const [moduleName, module] of Object.entries(config.modules)) { + Commander.prototype[moduleName] = Object.create(modulesBaseObject); + + for (const [commandName, command] of Object.entries(module)) { + Commander.prototype[moduleName][commandName] = function (...args: Array): unknown { + return config.modulesCommandsExecutor.call(this.self, command, args); + }; + } + } + } + + if (config.scripts) { + Commander ??= class extends config.BaseClass {}; + + for (const [name, script] of Object.entries(config.scripts)) { + Commander.prototype[name] = function (...args: Array): unknown { + return config.scriptsExecutor.call(this, script, args); + }; + } + } + + return (Commander ?? config.BaseClass) as any; +} + +export function transformCommandArguments( + command: RedisCommand, + args: Array +): { + args: TransformArgumentsReply; + options: CommandOptions | undefined; +} { + let options; + if (isCommandOptions(args[0])) { + options = args[0]; + args = args.slice(1); + } + + return { + args: command.transformArguments(...args), + options + }; +} diff --git a/lib/multi-command.ts b/lib/multi-command.ts index afb7d4d987..8bbeaff948 100644 --- a/lib/multi-command.ts +++ b/lib/multi-command.ts @@ -1,8 +1,9 @@ import COMMANDS, { TransformArgumentsReply } 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'; +import { extendWithModulesAndScripts, extendWithDefaultCommands } from './commander'; type RedisMultiCommandSignature = (...args: Parameters) => RedisMultiCommandType; @@ -31,10 +32,62 @@ export interface MultiQueuedCommand { export type RedisMultiExecutor = (queue: Array, chainId?: symbol) => Promise>; export default class RedisMultiCommand { - static create(executor: RedisMultiExecutor, clientOptions?: RedisClientOptions): RedisMultiCommandType { - return new RedisMultiCommand(executor, clientOptions); + static commandsExecutor(this: RedisMultiCommand, command: RedisCommand, args: Array): RedisMultiCommand { + return this.addCommand( + command.transformArguments(...args), + command.transformReply + ); } + static #scriptsExecutor( + this: RedisMultiCommand, + script: RedisLuaScript, + args: Array + ): RedisMultiCommand { + const transformedArguments: TransformArgumentsReply = []; + if (this.#scriptsInUse.has(script.SHA)) { + transformedArguments.push( + 'EVALSHA', + script.SHA + ); + } else { + this.#scriptsInUse.add(script.SHA); + transformedArguments.push( + 'EVAL', + script.SCRIPT + ); + } + + transformedArguments.push(script.NUMBER_OF_KEYS.toString()); + + const scriptArguments = script.transformArguments(...args); + transformedArguments.push(...scriptArguments); + transformedArguments.preserve = scriptArguments.preserve; + + return this.addCommand( + transformedArguments, + script.transformReply + ); + } + + static extend( + clientOptions?: RedisClientOptions + ): new (...args: ConstructorParameters) => RedisMultiCommandType { + return extendWithModulesAndScripts({ + BaseClass: RedisMultiCommand, + modules: clientOptions?.modules, + modulesCommandsExecutor: RedisMultiCommand.commandsExecutor, + scripts: clientOptions?.scripts, + scriptsExecutor: RedisMultiCommand.#scriptsExecutor + }); + } + + static create( + executor: RedisMultiExecutor, + clientOptions?: RedisClientOptions + ): RedisMultiCommandType { + return new this(executor, clientOptions); + } readonly #executor: RedisMultiExecutor; readonly #clientOptions: RedisClientOptions | undefined; @@ -56,65 +109,20 @@ export default class RedisMultiCommand) { this.#executor = executor; this.#clientOptions = clientOptions; - this.#initiateModules(); - this.#initiateScripts(); this.#legacyMode(); } - #initiateModules(): void { - if (!this.#clientOptions?.modules) return; - - for (const [moduleName, commands] of Object.entries(this.#clientOptions.modules)) { - const module: { - [P in keyof typeof commands]: RedisMultiCommandSignature<(typeof commands)[P], M, S> - } = {}; - - for (const [commandName, command] of Object.entries(commands)) { - module[commandName] = (...args) => this.executeCommand(command, args); - } - - (this as any)[moduleName] = module; - } - } - - #initiateScripts(): void { - if (!this.#clientOptions?.scripts) return; - - for (const [name, script] of Object.entries(this.#clientOptions.scripts)) { - (this as any)[name] = function (...args: Array) { - const transformedArgs: TransformArgumentsReply = []; - if (this.#scriptsInUse.has(name)) { - transformedArgs.push( - 'EVALSHA', - script.SHA - ); - } else { - this.#scriptsInUse.add(name); - transformedArgs.push( - 'EVAL', - script.SCRIPT - ); - } - - transformedArgs.push(script.NUMBER_OF_KEYS.toString()); - - const scriptArgs = script.transformArguments(...args); - transformedArgs.push(...scriptArgs); - transformedArgs.preserve = scriptArgs.preserve; - - return this.addCommand( - transformedArgs, - script.transformReply - ); - }; - } - } - #legacyMode(): void { if (!this.#clientOptions?.legacyMode) return; + this.#v4.addCommand = this.addCommand.bind(this); + (this as any).addCommand = (...args: Array): this => { + this.#queue.push({ + encodedCommand: RedisCommandsQueue.encodeCommand(args.flat() as Array) + }); + return this; + } this.#v4.exec = this.exec.bind(this); - (this as any).exec = (callback?: (err: Error | null, replies?: Array) => unknown): void => { this.#v4.exec() .then((reply: Array) => { @@ -135,55 +143,21 @@ export default class RedisMultiCommand): 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; - } + #defineLegacyCommand(name: string): void { + (this as any).#v4[name] = (this as any)[name].bind(this.#v4); + (this as any)[name] = (...args: Array): void => (this as any).addCommand(name, args); } - addCommand(args: TransformArgumentsReply, transformReply?: RedisCommand['transformReply']): RedisMultiCommandType { + addCommand(args: TransformArgumentsReply, transformReply?: RedisCommand['transformReply']): this { this.#queue.push({ encodedCommand: RedisCommandsQueue.encodeCommand(args), preservedArguments: args.preserve, transformReply }); - return this; - } - - executeCommand(command: RedisCommand, args: Array): RedisMultiCommandType { - return this.addCommand( - command.transformArguments(...args), - command.transformReply - ); + return this; } async exec(execAsPipeline = false): Promise> { @@ -217,8 +191,4 @@ export default class RedisMultiCommand): RedisMultiCommand { - return this.executeCommand(command, args); - }; -} +extendWithDefaultCommands(RedisMultiCommand, RedisMultiCommand.commandsExecutor);