diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index d520f970b0..6e494c1210 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -853,6 +853,74 @@ describe('Client', () => { assert.deepEqual(map, results); }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClient('scan iterators respect type mapping', async client => { + await Promise.all([ + client.mSet(['scan:1', '', 'scan:2', '']), + client.hSet('hash', { + field: 'value' + }), + client.sAdd('set', ['member']), + client.zAdd('zset', { + value: 'member', + score: 1.5 + }) + ]); + + const mappedClient = client.withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: Buffer, + [RESP_TYPES.DOUBLE]: String + }); + + const keys = new Set(); + for await (const page of mappedClient.scanIterator({ MATCH: 'scan:*' })) { + for (const key of page) { + assert.ok(Buffer.isBuffer(key)); + keys.add(key.toString()); + } + } + assert.deepEqual(keys, new Set(['scan:1', 'scan:2'])); + + const entries = new Map(); + for await (const page of mappedClient.hScanIterator('hash')) { + for (const { field, value } of page) { + assert.ok(Buffer.isBuffer(field)); + assert.ok(Buffer.isBuffer(value)); + entries.set(field.toString(), value.toString()); + } + } + assert.deepEqual(entries, new Map([['field', 'value']])); + + const fields = new Set(); + for await (const page of mappedClient.hScanNoValuesIterator('hash')) { + for (const field of page) { + assert.ok(Buffer.isBuffer(field)); + fields.add(field.toString()); + } + } + assert.deepEqual(fields, new Set(['field'])); + + const setMembers = new Set(); + for await (const page of mappedClient.sScanIterator('set')) { + for (const member of page) { + assert.ok(Buffer.isBuffer(member)); + setMembers.add(member.toString()); + } + } + assert.deepEqual(setMembers, new Set(['member'])); + + const sortedSetMembers = new Map(); + for await (const page of mappedClient.zScanIterator('zset')) { + for (const { value, score } of page) { + assert.ok(Buffer.isBuffer(value)); + sortedSetMembers.set(value.toString(), score); + } + } + assert.deepEqual(sortedSetMembers, new Map([['member', '1.5']])); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [7, 4] + }); + describe('PubSub', () => { testUtils.testWithClient('should be able to publish and subscribe to messages', async publisher => { function assertStringListener(message: string, channel: string) { diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index c20c75830e..fe5dd86714 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -255,10 +255,21 @@ export type RedisClientType< RedisClientExtensions ); -type ProxyClient = RedisClient; +type ProxyClient = RedisClient; type NamespaceProxyClient = { _self: ProxyClient }; +type RedisClientMultiCommandConstructor = new ( + ...args: ConstructorParameters +) => RedisClientMultiCommand; + +type ConfiguredRedisClientClass = { + new (options?: RedisClientOptions): ProxyClient; + prototype: ProxyClient & { + Multi?: RedisClientMultiCommandConstructor; + }; +}; + interface ScanIteratorOptions { cursor?: RedisArgument; } @@ -320,7 +331,10 @@ export default class RedisClient< } } - static #SingleEntryCache = new SingleEntryCache() + static #SingleEntryCache = new SingleEntryCache< + CommanderConfig | undefined, + ConfiguredRedisClientClass + >() static factory< M extends RedisModules = {}, @@ -340,9 +354,9 @@ export default class RedisClient< createFunctionCommand: RedisClient.#createFunctionCommand, createScriptCommand: RedisClient.#createScriptCommand, config - }); + }) as ConfiguredRedisClientClass; - Client.prototype.Multi = RedisClientMultiCommand.extend(config); + Client.prototype.Multi = RedisClientMultiCommand.extend(config) as RedisClientMultiCommandConstructor; RedisClient.#SingleEntryCache.set(config, Client); } @@ -350,8 +364,11 @@ export default class RedisClient< return ( options?: Omit, keyof Exclude> ) => { + const ClientCtor = Client as unknown as new ( + options?: Omit, keyof Exclude> + ) => RedisClientType; // returning a "proxy" to prevent the namespaces._self to leak between "proxies" - return Object.create(new Client(options)) as RedisClientType; + return Object.create(new ClientCtor(options)) as RedisClientType; }; } @@ -598,11 +615,11 @@ export default class RedisClient< const cscConfig = this.#options.clientSideCache; this.#clientSideCache = new BasicClientSideCache(cscConfig); } - this.#queue.addPushHandler((push: Array): boolean => { - if (push[0].toString() !== 'invalidate') return false; + this.#queue.addPushHandler((push: Array): boolean => { + if (String(push[0]) !== 'invalidate') return false; if (push[1] !== null) { - for (const key of push[1]) { + for (const key of push[1] as Array) { this.#clientSideCache?.invalidate(key) } } else { @@ -612,11 +629,11 @@ export default class RedisClient< return true }); } else if (options?.emitInvalidate) { - this.#queue.addPushHandler((push: Array): boolean => { - if (push[0].toString() !== 'invalidate') return false; + this.#queue.addPushHandler((push: Array): boolean => { + if (String(push[0]) !== 'invalidate') return false; if (push[1] !== null) { - for (const key of push[1]) { + for (const key of push[1] as Array) { this.emit('invalidate', key); } } else { @@ -1057,7 +1074,7 @@ export default class RedisClient< */ _ejectSocket(): RedisSocket { const socket = this._self.#socket; - // @ts-ignore + // @ts-expect-error temporarily clears the socket before reinserting one this._self.#socket = null; socket.removeAllListeners(); return socket; @@ -1524,7 +1541,7 @@ export default class RedisClient< MULTI() { type Multi = new (...args: ConstructorParameters) => RedisClientMultiCommandType; - return new ((this as any).Multi as Multi)( + return new ((this as unknown as { Multi: Multi }).Multi)( this._executeMulti.bind(this), this._executePipeline.bind(this), this._commandOptions?.typeMapping @@ -1542,7 +1559,7 @@ export default class RedisClient< const reply = await this.scan(cursor, options); cursor = reply.cursor; yield reply.keys; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async* hScanIterator( @@ -1555,7 +1572,7 @@ export default class RedisClient< const reply = await this.hScan(key, cursor, options); cursor = reply.cursor; yield reply.entries; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async* hScanValuesIterator( @@ -1568,7 +1585,7 @@ export default class RedisClient< const reply = await this.hScanNoValues(key, cursor, options); cursor = reply.cursor; yield reply.fields; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async* hScanNoValuesIterator( @@ -1581,7 +1598,7 @@ export default class RedisClient< const reply = await this.hScanNoValues(key, cursor, options); cursor = reply.cursor; yield reply.fields; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async* sScanIterator( @@ -1594,7 +1611,7 @@ export default class RedisClient< const reply = await this.sScan(key, cursor, options); cursor = reply.cursor; yield reply.members; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async* zScanIterator( @@ -1607,7 +1624,7 @@ export default class RedisClient< const reply = await this.zScan(key, cursor, options); cursor = reply.cursor; yield reply.members; - } while (cursor !== '0'); + } while (cursor.toString() !== '0'); } async MONITOR(callback: MonitorCallback) { diff --git a/packages/client/lib/commands/ZSCAN.spec.ts b/packages/client/lib/commands/ZSCAN.spec.ts index f8064aea41..bd4ee53e02 100644 --- a/packages/client/lib/commands/ZSCAN.spec.ts +++ b/packages/client/lib/commands/ZSCAN.spec.ts @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; +import { RESP_TYPES } from '../RESP/decoder'; import { parseArgs } from './generic-transformers'; import ZSCAN from './ZSCAN'; @@ -50,4 +51,23 @@ describe('ZSCAN', () => { } ); }, GLOBAL.SERVERS.OPEN); + + it('transformReply with type mapping', () => { + assert.deepEqual( + ZSCAN.transformReply( + ['0', ['member', '1.5']], + undefined, + { + [RESP_TYPES.DOUBLE]: String + } + ), + { + cursor: '0', + members: [{ + value: 'member', + score: '1.5' + }] + } + ); + }); }); diff --git a/packages/client/lib/commands/ZSCAN.ts b/packages/client/lib/commands/ZSCAN.ts index 051235033e..8a23eaa342 100644 --- a/packages/client/lib/commands/ZSCAN.ts +++ b/packages/client/lib/commands/ZSCAN.ts @@ -1,5 +1,5 @@ import { CommandParser } from '../client/parser'; -import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; +import { RedisArgument, ArrayReply, BlobStringReply, Command, TypeMapping } from '../RESP/types'; import { ScanCommonOptions, parseScanArguments } from './SCAN'; import { transformSortedSetReply } from './generic-transformers'; @@ -20,10 +20,14 @@ export default { parser.pushKey(key); parseScanArguments(parser, cursor, options); }, - transformReply([cursor, rawMembers]: [BlobStringReply, ArrayReply]) { + transformReply( + [cursor, rawMembers]: [BlobStringReply, ArrayReply], + preserve?: unknown, + typeMapping?: TypeMapping + ) { return { cursor, - members: transformSortedSetReply[2](rawMembers) + members: transformSortedSetReply[2](rawMembers, preserve, typeMapping) }; } } as const satisfies Command;