diff --git a/TODO.md b/TODO.md index 5871eee2bf..aa1893f5fc 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,8 @@ * [`return_buffers`](https://github.com/NodeRedis/node-redis#options-object-properties) (? supported in v3, but have performance drawbacks) * ~~Support options in a command function (`.get`, `.set`, ...)~~ * Key prefixing (?) (partially supported in v3) +* Support for RESP3 +* client-side caching ## Client * ~~Blocking Commands~~ diff --git a/lib/client.spec.ts b/lib/client.spec.ts index 30daf03a86..1774c6ba22 100644 --- a/lib/client.spec.ts +++ b/lib/client.spec.ts @@ -33,20 +33,18 @@ describe('Client', () => { }); }); - describe('callbackify', () => { + describe('legacyMode', () => { const client = RedisClient.create({ socket: TEST_REDIS_SERVERS[TestRedisServers.OPEN], - callbackify: true + legacyMode: true }); before(() => client.connect()); - after(async () => { - await (client as any).flushAllAsync(); - await client.disconnect(); - }); + afterEach(() => client.modern.flushAll()); + after(() => client.disconnect()); - it('client.{command} should call the callback', done => { - (client as any).ping((err: Error, reply: string) => { + it('client.sendCommand should call the callback', done => { + (client as any).sendCommand('PING', (err?: Error, reply?: string) => { if (err) { return done(err); } @@ -60,17 +58,95 @@ describe('Client', () => { }); }); - it('client.{command} should work without callback', async () => { - (client as any).ping(); - await (client as any).pingAsync(); // make sure the first command was replied + it('client.sendCommand should work without callback', async () => { + (client as any).sendCommand('PING'); + await client.modern.ping(); // make sure the first command was replied }); - it('client.{command}Async should return a promise', async () => { + it('client.modern.sendCommand should return a promise', async () => { assert.equal( - await (client as any).pingAsync(), + await client.modern.sendCommand(['PING']), 'PONG' ); }); + + it('client.{command} should accept vardict arguments', done => { + (client as any).set('a', 'b', (err?: Error, reply?: string) => { + if (err) { + return done(err); + } + + try { + assert.equal(reply, 'OK'); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.{command} should accept arguments array', done => { + (client as any).set(['a', 'b'], (err?: Error, reply?: string) => { + if (err) { + return done(err); + } + + try { + assert.equal(reply, 'OK'); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.{command} should accept mix of strings and array of strings', done => { + (client as any).set(['a'], 'b', ['GET'], (err?: Error, reply?: string) => { + if (err) { + return done(err); + } + + try { + assert.equal(reply, null); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.multi.exec should call the callback', done => { + (client as any).multi() + .ping() + .exec((err?: Error, reply?: string) => { + if (err) { + return done(err); + } + + try { + assert.deepEqual(reply, ['PONG']); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('client.multi.exec should work without callback', async () => { + (client as any).multi() + .ping() + .exec(); + await client.modern.ping(); // make sure the first command was replied + }); + + it('client.modern.exec should return a promise', async () => { + assert.deepEqual( + await ((client as any).multi().modern + .ping() + .exec()), + ['PONG'] + ); + }); }); describe('events', () => { diff --git a/lib/client.ts b/lib/client.ts index 2df4cda01f..acf06560b0 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -13,7 +13,7 @@ export interface RedisClientOptions { scripts?: S; commandsQueueMaxLength?: number; readOnly?: boolean; - callbackify?: boolean; + legacyMode?: boolean; } export type RedisCommandSignature = @@ -50,24 +50,6 @@ export default class RedisClient) { - const hasCallback = typeof args[args.length - 1] === 'function', - callback = (hasCallback && args.pop()) as Function; - - const promise = originalFunction.apply(this, args); - if (hasCallback) { - promise - .then((reply: RedisReply) => callback(null, reply)) - .catch((err: Error) => callback(err)); - } else { - promise - .catch((err: Error) => this.emit('error', err)); - } - }; - } - static create(options?: RedisClientOptions): RedisClientType { return new RedisClient(options); } @@ -80,6 +62,7 @@ export default class RedisClient }; + readonly #modern: Record = {}; #selectedDB = 0; get options(): RedisClientOptions | null | undefined { @@ -90,6 +73,14 @@ export default class RedisClient { + if (!this.#options?.legacyMode) { + throw new Error('the client is not in "legacy mode"'); + } + + return this.#modern; + } + constructor(options?: RedisClientOptions) { super(); this.#options = options; @@ -98,7 +89,7 @@ export default class RedisClientclass extends RedisMultiCommand { constructor() { - super(executor, options?.modules, options?.scripts); + super(executor, options); } }; } @@ -203,22 +194,61 @@ export default class RedisClient): void => { + const options = isCommandOptions(args[0]) && args.shift(), + callback = typeof args[args.length - 1] === 'function' && (args.pop() as Function); + + this.#modern.sendCommand(args.flat(), options) + .then((reply: unknown) => { + if (!callback) return; + + // https://github.com/NodeRedis/node-redis#commands:~:text=minimal%20parsing + + callback(null, reply); + }) + .catch((err: Error) => { + if (!callback) { + this.emit('error', err); + return; + } + + callback(err); + }) + } for (const name of Object.keys(COMMANDS)) { - RedisClient.callbackifyCommand(this, name); - RedisClient.callbackifyCommand(this.#Multi.prototype, name); + this.#defineLegacyCommand(name); } - if (!this.#options?.modules) return; + // hard coded commands + this.#defineLegacyCommand('SELECT'); + this.#defineLegacyCommand('select'); - for (const m of this.#options.modules) { - for (const name of Object.keys(m)) { - RedisClient.callbackifyCommand(this, name); - RedisClient.callbackifyCommand(this.#Multi.prototype, name); + if (this.#options?.modules) { + for (const m of this.#options.modules) { + for (const name of Object.keys(m)) { + this.#defineLegacyCommand(name); + } } } + + if (this.#options?.scripts) { + for (const name of Object.keys(this.#options.scripts)) { + this.#defineLegacyCommand(name); + } + } + } + + #defineLegacyCommand(name: string): void { + this.#modern[name] = (this as any)[name]; + (this as any)[name] = function (...args: Array): void { + this.sendCommand(name, ...args); + }; } duplicate(): RedisClientType { diff --git a/lib/multi-command.ts b/lib/multi-command.ts index 045bd24fb5..bf18edfcdb 100644 --- a/lib/multi-command.ts +++ b/lib/multi-command.ts @@ -2,6 +2,7 @@ import COMMANDS from './commands/client'; import { RedisCommand, RedisModules, RedisReply } from './commands'; import RedisCommandsQueue from './commands-queue'; import { RedisLuaScript, RedisLuaScripts } from './lua-script'; +import { RedisClientOptions } from './client'; type RedisMultiCommandSignature = (...args: Parameters) => RedisMultiCommandType; @@ -29,71 +30,136 @@ export type RedisMultiExecutor = (queue: Array, chainId: Sym export default class RedisMultiCommand { static defineCommand(on: any, name: string, command: RedisCommand): void { on[name] = function (...args: Parameters) { - return this.addCommand(command.transformArguments(...args), command.transformReply); + // 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 defineLuaScript(on: any, name: string, script: RedisLuaScript): void { - on[name] = function (...args: Array) { - let evalArgs; - if (this.#scriptsInUse.has(name)) { - evalArgs = [ - 'EVALSHA', - script.SHA - ]; - } else { - this.#scriptsInUse.add(name); - evalArgs = [ - 'EVAL', - script.SCRIPT - ]; - } - - return this.addCommand( - [ - ...evalArgs, - script.NUMBER_OF_KEYS, - ...script.transformArguments(...args) - ], - script.transformReply - ); - }; - } - - static create(executor: RedisMultiExecutor, modules?: M, scripts?: S): RedisMultiCommandType { - return new RedisMultiCommand(executor, modules, scripts); + static create(executor: RedisMultiExecutor, clientOptions?: RedisClientOptions): RedisMultiCommandType { + return new RedisMultiCommand(executor, clientOptions); } readonly #executor: RedisMultiExecutor; + readonly #clientOptions: RedisClientOptions | undefined; + readonly #queue: Array = []; readonly #scriptsInUse = new Set(); - constructor(executor: RedisMultiExecutor, modules?: RedisModules, scripts?: RedisLuaScripts) { - this.#executor = executor; - this.#initiateModules(modules); - this.#initiateScripts(scripts); + readonly #modern: Record = {}; + + get modern(): Record { + if (!this.#clientOptions?.legacyMode) { + throw new Error('client is not in "legacy mode"'); + } + + return this.#modern; } - #initiateModules(modules?: RedisModules): void { - if (!modules) return; + constructor(executor: RedisMultiExecutor, clientOptions?: RedisClientOptions) { + this.#executor = executor; + this.#clientOptions = clientOptions; + this.#initiateModules(); + this.#initiateScripts(); + this.#legacyMode(); + } - for (const m of modules) { + #initiateModules(): void { + if (!this.#clientOptions?.modules) return; + + for (const m of this.#clientOptions.modules) { for (const [name, command] of Object.entries(m)) { RedisMultiCommand.defineCommand(this, name, command); } } } - #initiateScripts(scripts?: RedisLuaScripts): void { - if (!scripts) return; + #initiateScripts(): void { + if (!this.#clientOptions?.scripts) return; - for (const [name, script] of Object.entries(scripts)) { - RedisMultiCommand.defineLuaScript(this, name, script); + for (const [name, script] of Object.entries(this.#clientOptions.scripts)) { + (this as any)[name] = function (...args: Array) { + let evalArgs; + if (this.#scriptsInUse.has(name)) { + evalArgs = [ + 'EVALSHA', + script.SHA + ]; + } else { + this.#scriptsInUse.add(name); + evalArgs = [ + 'EVAL', + script.SCRIPT + ]; + } + + return this.addCommand( + [ + ...evalArgs, + script.NUMBER_OF_KEYS, + ...script.transformArguments(...args) + ], + script.transformReply + ); + }; } } + #legacyMode(): Record | undefined { + if (!this.#clientOptions?.legacyMode) return; + + this.#modern.exec = this.exec.bind(this); + this.#modern.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.#modern.exec() + .then((reply: unknown) => { + if (!callback) return; + + callback(null, reply); + }) + .catch((err: Error) => { + if (!callback) { + // this.emit('error', err); + return; + } + + callback(err); + }); + }; + + + for (const name of Object.keys(COMMANDS)) { + this.#defineLegacyCommand(name); + } + + if (this.#clientOptions.modules) { + for (const m of this.#clientOptions.modules) { + for (const name of Object.keys(m)) { + this.#defineLegacyCommand(name); + } + } + } + + if (this.#clientOptions.scripts) { + for (const name of Object.keys(this.#clientOptions.scripts)) { + this.#defineLegacyCommand(name); + } + } + } + + #defineLegacyCommand(name: string): void { + this.#modern[name] = (this as any)[name]; + + // TODO: https://github.com/NodeRedis/node-redis#commands:~:text=minimal%20parsing + (this as any)[name] = function (...args: Array) { + return this.addCommand([name, ...args.flat()]); + }; + } + addCommand(args: Array, transformReply?: RedisCommand['transformReply']): this { this.#queue.push({ encodedCommand: RedisCommandsQueue.encodeCommand(args),