From a10b27614493f4119a22488f110140069df4b56c Mon Sep 17 00:00:00 2001 From: leibale Date: Mon, 19 Jul 2021 16:39:09 -0400 Subject: [PATCH] implement some GEO commands, improve scan generic transformer, expose RPUSHX --- lib/client.ts | 28 ++- lib/cluster.ts | 16 +- lib/commands/GEOADD.spec.ts | 95 ++++++++++ lib/commands/GEOADD.ts | 49 +++++ lib/commands/GEODIST.spec.ts | 26 +++ lib/commands/GEODIST.ts | 24 +++ lib/commands/GEOHASH.spec.ts | 35 ++++ lib/commands/GEOHASH.ts | 19 ++ lib/commands/GEOPOS.spec.ts | 35 ++++ lib/commands/GEOPOS.ts | 27 +++ lib/commands/GEOSEARCH.spec.ts | 35 ++++ lib/commands/GEOSEARCH.ts | 16 ++ lib/commands/GEOSEARCHSTORE.spec.ts | 41 +++++ lib/commands/GEOSEARCHSTORE.ts | 40 +++++ lib/commands/GEOSEARCH_WITH.spec.ts | 40 +++++ lib/commands/GEOSEARCH_WITH.ts | 23 +++ lib/commands/HSCAN.ts | 11 +- lib/commands/SCAN.ts | 12 +- lib/commands/SSCAN.ts | 9 +- lib/commands/ZSCAN.ts | 11 +- lib/commands/generic-transformers.spec.ts | 207 +++++++++++++++++++++- lib/commands/generic-transformers.ts | 141 ++++++++++++++- lib/commands/index.ts | 31 +++- lib/multi-command.ts | 12 +- 24 files changed, 924 insertions(+), 59 deletions(-) create mode 100644 lib/commands/GEOADD.spec.ts create mode 100644 lib/commands/GEOADD.ts create mode 100644 lib/commands/GEODIST.spec.ts create mode 100644 lib/commands/GEODIST.ts create mode 100644 lib/commands/GEOHASH.spec.ts create mode 100644 lib/commands/GEOHASH.ts create mode 100644 lib/commands/GEOPOS.spec.ts create mode 100644 lib/commands/GEOPOS.ts create mode 100644 lib/commands/GEOSEARCH.spec.ts create mode 100644 lib/commands/GEOSEARCH.ts create mode 100644 lib/commands/GEOSEARCHSTORE.spec.ts create mode 100644 lib/commands/GEOSEARCHSTORE.ts create mode 100644 lib/commands/GEOSEARCH_WITH.spec.ts create mode 100644 lib/commands/GEOSEARCH_WITH.ts diff --git a/lib/client.ts b/lib/client.ts index cdb54299f8..69d2b9c23e 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -111,7 +111,7 @@ export default class RedisClient): Promise> { - const options = isCommandOptions(args[0]) && args[0]; + 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, - ...script.transformArguments(...(options ? args.slice(1) : args)) - ) + transformedArguments, + options + ), + transformedArguments.preserve ); }; } @@ -197,7 +205,7 @@ export default class RedisClient): 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; @@ -372,12 +380,14 @@ export default class RedisClient): Promise> { - const options = isCommandOptions(args[0]) && args[0]; + 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, - script.transformArguments(...(options ? args.slice(1) : args)), + transformedArguments, options - ) + ), + transformedArguments.preserve ); }; } @@ -121,7 +128,8 @@ export default class RedisCluster { + describe('transformArguments', () => { + it('one member', () => { + assert.deepEqual( + transformArguments('key', { + member: 'member', + longitude: 1, + latitude: 2 + }), + ['GEOADD', 'key', '1', '2', 'member'] + ); + }); + + it('multiple members', () => { + assert.deepEqual( + transformArguments('key', [{ + longitude: 1, + latitude: 2, + member: '3', + }, { + longitude: 4, + latitude: 5, + member: '6', + }]), + ['GEOADD', 'key', '1', '2', '3', '4', '5', '6'] + ); + }); + + it('with NX', () => { + assert.deepEqual( + transformArguments('key', { + longitude: 1, + latitude: 2, + member: 'member' + }, { + NX: true + }), + ['GEOADD', 'key', 'NX', '1', '2', 'member'] + ); + }); + + it('with CH', () => { + assert.deepEqual( + transformArguments('key', { + longitude: 1, + latitude: 2, + member: 'member' + }, { + CH: true + }), + ['GEOADD', 'key', 'CH', '1', '2', 'member'] + ); + }); + + it('with XX, CH', () => { + assert.deepEqual( + transformArguments('key', { + longitude: 1, + latitude: 2, + member: 'member' + }, { + XX: true, + CH: true + }), + ['GEOADD', 'key', 'XX', 'CH', '1', '2', 'member'] + ); + }); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoAdd', async client => { + assert.equal( + await client.geoAdd('key', { + member: 'member', + longitude: 1, + latitude: 2 + }), + 1 + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoAdd', async cluster => { + assert.equal( + await cluster.geoAdd('key', { + member: 'member', + longitude: 1, + latitude: 2 + }), + 1 + ); + }); +}); diff --git a/lib/commands/GEOADD.ts b/lib/commands/GEOADD.ts new file mode 100644 index 0000000000..1236563d54 --- /dev/null +++ b/lib/commands/GEOADD.ts @@ -0,0 +1,49 @@ +import { GeoCoordinates, transformReplyNumber } from './generic-transformers'; + +interface GeoMember extends GeoCoordinates { + member: string; +} + +interface NX { + NX?: true; +} + +interface XX { + XX?: true; +} + +type SetGuards = NX | XX; + +interface GeoAddCommonOptions { + CH?: true; +} + +type GeoAddOptions = SetGuards & GeoAddCommonOptions; + +export const FIRST_KEY_INDEX = 1; + +export function transformArguments(key: string, toAdd: GeoMember | Array, options?: GeoAddOptions): Array { + const args = ['GEOADD', key]; + + if ((options as NX)?.NX) { + args.push('NX'); + } else if ((options as XX)?.XX) { + args.push('XX'); + } + + if (options?.CH) { + args.push('CH'); + } + + for (const { longitude, latitude, member } of (Array.isArray(toAdd) ? toAdd : [toAdd])) { + args.push( + longitude.toString(), + latitude.toString(), + member + ); + } + + return args; +} + +export const transformReply = transformReplyNumber; diff --git a/lib/commands/GEODIST.spec.ts b/lib/commands/GEODIST.spec.ts new file mode 100644 index 0000000000..3b25712347 --- /dev/null +++ b/lib/commands/GEODIST.spec.ts @@ -0,0 +1,26 @@ +import { strict as assert } from 'assert'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { transformArguments } from './GEODIST'; + +describe('GEODIST', () => { + it('transformArguments', () => { + assert.deepEqual( + transformArguments('key', '1', '2'), + ['GEODIST', 'key', '1', '2'] + ); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoDist', async client => { + assert.equal( + await client.geoDist('key', '1', '2'), + null + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoDist', async cluster => { + assert.equal( + await cluster.geoDist('key', '1', '2'), + null + ); + }); +}); diff --git a/lib/commands/GEODIST.ts b/lib/commands/GEODIST.ts new file mode 100644 index 0000000000..6fe6fbb47a --- /dev/null +++ b/lib/commands/GEODIST.ts @@ -0,0 +1,24 @@ +import { GeoUnits } from './generic-transformers'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments( + key: string, + member1: string, + member2: string, + unit?: GeoUnits +): Array { + const args = ['GEODIST', key, member1, member2]; + + if (unit) { + args.push(unit); + } + + return args; +} + +export function transformReply(reply: string | null): number | null { + return reply === null ? null : Number(reply); +} diff --git a/lib/commands/GEOHASH.spec.ts b/lib/commands/GEOHASH.spec.ts new file mode 100644 index 0000000000..b79de23555 --- /dev/null +++ b/lib/commands/GEOHASH.spec.ts @@ -0,0 +1,35 @@ +import { strict as assert } from 'assert'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { transformArguments } from './GEOHASH'; + +describe('GEOHASH', () => { + describe('transformArguments', () => { + it('single member', () => { + assert.deepEqual( + transformArguments('key', 'member'), + ['GEOHASH', 'key', 'member'] + ); + }); + + it('multiple members', () => { + assert.deepEqual( + transformArguments('key', ['1', '2']), + ['GEOHASH', 'key', '1', '2'] + ); + }); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoHash', async client => { + assert.deepEqual( + await client.geoHash('key', 'member'), + [null] + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoHash', async cluster => { + assert.deepEqual( + await cluster.geoHash('key', 'member'), + [null] + ); + }); +}); diff --git a/lib/commands/GEOHASH.ts b/lib/commands/GEOHASH.ts new file mode 100644 index 0000000000..e8f26d33e1 --- /dev/null +++ b/lib/commands/GEOHASH.ts @@ -0,0 +1,19 @@ +import { transformReplyStringArray } from './generic-transformers'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments(key: string, member: string | Array): Array { + const args = ['GEOHASH', key]; + + if (typeof member === 'string') { + args.push(member); + } else { + args.push(...member); + } + + return args; +} + +export const transformReply = transformReplyStringArray; diff --git a/lib/commands/GEOPOS.spec.ts b/lib/commands/GEOPOS.spec.ts new file mode 100644 index 0000000000..98cfa6aa2d --- /dev/null +++ b/lib/commands/GEOPOS.spec.ts @@ -0,0 +1,35 @@ +import { strict as assert } from 'assert'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { transformArguments } from './GEOPOS'; + +describe('GEOPOS', () => { + describe('transformArguments', () => { + it('single member', () => { + assert.deepEqual( + transformArguments('key', 'member'), + ['GEOPOS', 'key', 'member'] + ); + }); + + it('multiple members', () => { + assert.deepEqual( + transformArguments('key', ['1', '2']), + ['GEOPOS', 'key', '1', '2'] + ); + }); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoPos', async client => { + assert.deepEqual( + await client.geoPos('key', 'member'), + [null] + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoPos', async cluster => { + assert.deepEqual( + await cluster.geoPos('key', 'member'), + [null] + ); + }); +}); diff --git a/lib/commands/GEOPOS.ts b/lib/commands/GEOPOS.ts new file mode 100644 index 0000000000..554858ed54 --- /dev/null +++ b/lib/commands/GEOPOS.ts @@ -0,0 +1,27 @@ +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments(key: string, member: string | Array): Array { + const args = ['GEOPOS', key]; + + if (typeof member === 'string') { + args.push(member); + } else { + args.push(...member); + } + + return args; +} + +interface GeoCoordinates { + longitude: string; + latitude: string; +} + +export function transformReply(reply: Array<[string, string] | null>): Array { + return reply.map(coordinates => coordinates === null ? null : { + longitude: coordinates[0], + latitude: coordinates[1] + }); +} diff --git a/lib/commands/GEOSEARCH.spec.ts b/lib/commands/GEOSEARCH.spec.ts new file mode 100644 index 0000000000..764a247155 --- /dev/null +++ b/lib/commands/GEOSEARCH.spec.ts @@ -0,0 +1,35 @@ +import { strict as assert } from 'assert'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { transformArguments } from './GEOSEARCH'; + +describe('GEOSEARCH', () => { + it('transformArguments', () => { + assert.deepEqual( + transformArguments('key', 'member', { + radius: 1, + unit: 'm' + }), + ['GEOSEARCH', 'key', 'FROMMEMBER', 'member', 'BYRADIUS', '1', 'm'] + ); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoSearch', async client => { + assert.deepEqual( + await client.geoSearch('key', 'member', { + radius: 1, + unit: 'm' + }), + [] + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoSearch', async cluster => { + assert.deepEqual( + await cluster.geoSearch('key', 'member', { + radius: 1, + unit: 'm' + }), + [] + ); + }); +}); diff --git a/lib/commands/GEOSEARCH.ts b/lib/commands/GEOSEARCH.ts new file mode 100644 index 0000000000..3872f11c6c --- /dev/null +++ b/lib/commands/GEOSEARCH.ts @@ -0,0 +1,16 @@ +import { GeoSearchFrom, GeoSearchBy, GeoSearchOptions, pushGeoSearchArguments, transformReplyStringArray } from './generic-transformers'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments( + key: string, + from: GeoSearchFrom, + by: GeoSearchBy, + options?: GeoSearchOptions +): Array { + return pushGeoSearchArguments(['GEOSEARCH'], key, from, by, options); +} + +export const transformReply = transformReplyStringArray; diff --git a/lib/commands/GEOSEARCHSTORE.spec.ts b/lib/commands/GEOSEARCHSTORE.spec.ts new file mode 100644 index 0000000000..a1938ef362 --- /dev/null +++ b/lib/commands/GEOSEARCHSTORE.spec.ts @@ -0,0 +1,41 @@ +import { strict as assert } from 'assert'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { transformArguments } from './GEOSEARCHSTORE'; + +describe('GEOSEARCHSTORE', () => { + it('transformArguments', () => { + assert.deepEqual( + transformArguments('destination', 'source', 'member', { + radius: 1, + unit: 'm' + }, { + SORT: 'ASC', + COUNT: { + value: 1, + ANY: true + } + }), + ['GEOSEARCHSTORE', 'destination', 'source', 'FROMMEMBER', 'member', 'BYRADIUS', '1', 'm', 'ASC', 'COUNT', '1', 'ANY'] + ); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoSearchStore', async client => { + assert.equal( + await client.geoSearchStore('source', 'destination', 'member', { + radius: 1, + unit: 'm' + }), + 0 + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoSearchStore', async cluster => { + assert.equal( + await cluster.geoSearchStore('{tag}source', '{tag}destination', 'member', { + radius: 1, + unit: 'm' + }), + 0 + ); + }); +}); diff --git a/lib/commands/GEOSEARCHSTORE.ts b/lib/commands/GEOSEARCHSTORE.ts new file mode 100644 index 0000000000..934abff653 --- /dev/null +++ b/lib/commands/GEOSEARCHSTORE.ts @@ -0,0 +1,40 @@ +import { GeoSearchFrom, GeoSearchBy, GeoSearchOptions, pushGeoSearchArguments, transformReplyNumber } from './generic-transformers'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +interface GeoSearchStoreOptions extends GeoSearchOptions { + STOREDIST?: true; +} + +export function transformArguments( + destination: string, + source: string, + from: GeoSearchFrom, + by: GeoSearchBy, + options?: GeoSearchStoreOptions +): Array { + const args = pushGeoSearchArguments( + ['GEOSEARCHSTORE', destination], + source, + from, + by, + options + ); + + if (options?.STOREDIST) { + args.push('STOREDIST'); + } + + return args; +} + + +// in versions 6.2.0-6.2.4 Redis will return an empty array when `src` is empty +// TODO: issue/PR +export function transformReply(reply: number | []): number { + if (typeof reply === 'number') return reply; + + return 0; +} diff --git a/lib/commands/GEOSEARCH_WITH.spec.ts b/lib/commands/GEOSEARCH_WITH.spec.ts new file mode 100644 index 0000000000..de363f42fa --- /dev/null +++ b/lib/commands/GEOSEARCH_WITH.spec.ts @@ -0,0 +1,40 @@ +import { strict as assert } from 'assert'; +import { TransformArgumentsReply } from '.'; +import { TestRedisServers, itWithClient, TestRedisClusters, itWithCluster } from '../test-utils'; +import { GeoReplyWith } from './generic-transformers'; +import { transformArguments } from './GEOSEARCH_WITH'; + +describe('GEOSEARCH WITH', () => { + it('transformArguments', () => { + const expectedReply: TransformArgumentsReply = ['GEOSEARCH', 'key', 'FROMMEMBER', 'member', 'BYRADIUS', '1', 'm', 'WITHDIST'] + expectedReply.preserve = ['WITHDIST']; + + assert.deepEqual( + transformArguments('key', 'member', { + radius: 1, + unit: 'm' + }, [GeoReplyWith.DISTANCE]), + expectedReply + ); + }); + + itWithClient(TestRedisServers.OPEN, 'client.geoSearchWith', async client => { + assert.deepEqual( + await client.geoSearchWith('key', 'member', { + radius: 1, + unit: 'm' + }, [GeoReplyWith.DISTANCE]), + [] + ); + }); + + itWithCluster(TestRedisClusters.OPEN, 'cluster.geoSearchWith', async cluster => { + assert.deepEqual( + await cluster.geoSearchWith('key', 'member', { + radius: 1, + unit: 'm' + }, [GeoReplyWith.DISTANCE]), + [] + ); + }); +}); diff --git a/lib/commands/GEOSEARCH_WITH.ts b/lib/commands/GEOSEARCH_WITH.ts new file mode 100644 index 0000000000..ef19ca5dfc --- /dev/null +++ b/lib/commands/GEOSEARCH_WITH.ts @@ -0,0 +1,23 @@ +import { TransformArgumentsReply } from '.'; +import { GeoSearchFrom, GeoSearchBy, GeoReplyWith, GeoSearchOptions, transformGeoMembersWithReply } from './generic-transformers'; +import { transformArguments as geoSearchTransformArguments } from './GEOSEARCH'; + +export { FIRST_KEY_INDEX, IS_READ_ONLY } from './GEOSEARCH'; + +export function transformArguments( + key: string, + from: GeoSearchFrom, + by: GeoSearchBy, + replyWith: Array, + options?: GeoSearchOptions +): TransformArgumentsReply { + const args: TransformArgumentsReply = geoSearchTransformArguments(key, from, by, options); + + args.push(...replyWith); + + args.preserve = replyWith; + + return args; +} + +export const transformReply = transformGeoMembersWithReply; diff --git a/lib/commands/HSCAN.ts b/lib/commands/HSCAN.ts index 5019b4597a..2e70ca4b2a 100644 --- a/lib/commands/HSCAN.ts +++ b/lib/commands/HSCAN.ts @@ -1,15 +1,14 @@ -import { ScanOptions, transformScanArguments } from './generic-transformers'; +import { ScanOptions, pushScanArguments } from './generic-transformers'; export const FIRST_KEY_INDEX = 1; export const IS_READ_ONLY = true; export function transformArguments(key: string, cursor: number, options?: ScanOptions): Array { - return [ + return pushScanArguments([ 'HSCAN', - key, - ...transformScanArguments(cursor, options) - ]; + key + ], cursor, options); } export interface HScanTuple { @@ -30,7 +29,7 @@ export function transformReply([cursor, rawTuples]: [string, Array]): HS value: rawTuples[i + 1] }); } - + return { cursor: Number(cursor), tuples: parsedTuples diff --git a/lib/commands/SCAN.ts b/lib/commands/SCAN.ts index b51da61ac6..e3541ea9a7 100644 --- a/lib/commands/SCAN.ts +++ b/lib/commands/SCAN.ts @@ -1,21 +1,17 @@ -import { ScanOptions, transformScanArguments } from './generic-transformers'; +import { ScanOptions, pushScanArguments } from './generic-transformers'; export const IS_READ_ONLY = true; - export interface ScanCommandOptions extends ScanOptions { TYPE?: string; } export function transformArguments(cursor: number, options?: ScanCommandOptions): Array { - const args = [ - 'SCAN', - ...transformScanArguments(cursor, options) - ]; - + const args = pushScanArguments(['SCAN'], cursor, options); + if (options?.TYPE) { args.push('TYPE', options.TYPE); } - + return args; } diff --git a/lib/commands/SSCAN.ts b/lib/commands/SSCAN.ts index 38f31dc68c..9b881f5d88 100644 --- a/lib/commands/SSCAN.ts +++ b/lib/commands/SSCAN.ts @@ -1,13 +1,14 @@ -import { ScanOptions, transformScanArguments } from './generic-transformers'; +import { ScanOptions, pushScanArguments } from './generic-transformers'; + +export const FIRST_KEY_INDEX = 1; export const IS_READ_ONLY = true; export function transformArguments(key: string, cursor: number, options?: ScanOptions): Array { - return [ + return pushScanArguments([ 'SSCAN', key, - ...transformScanArguments(cursor, options) - ]; + ], cursor, options); } interface SScanReply { diff --git a/lib/commands/ZSCAN.ts b/lib/commands/ZSCAN.ts index c56061bda6..174cc1fab1 100644 --- a/lib/commands/ZSCAN.ts +++ b/lib/commands/ZSCAN.ts @@ -1,15 +1,14 @@ -import { ScanOptions, transformReplyNumberInfinity, transformScanArguments, ZMember } from './generic-transformers'; +import { ScanOptions, transformReplyNumberInfinity, pushScanArguments, ZMember } from './generic-transformers'; export const FIRST_KEY_INDEX = 1; export const IS_READ_ONLY = true; export function transformArguments(key: string, cursor: number, options?: ScanOptions): Array { - return [ + return pushScanArguments([ 'ZSCAN', - key, - ...transformScanArguments(cursor, options) - ]; + key + ], cursor, options); } interface ZScanReply { @@ -25,7 +24,7 @@ export function transformReply([cursor, rawMembers]: [string, Array]): Z score: transformReplyNumberInfinity(rawMembers[i + 1]) }); } - + return { cursor: Number(cursor), members: parsedMembers diff --git a/lib/commands/generic-transformers.spec.ts b/lib/commands/generic-transformers.spec.ts index 71def2d8fb..6388837586 100644 --- a/lib/commands/generic-transformers.spec.ts +++ b/lib/commands/generic-transformers.spec.ts @@ -1,8 +1,9 @@ import { strict as assert } from 'assert'; +import { isKeyObject } from 'util/types'; import { transformReplyBoolean, transformReplyBooleanArray, - transformScanArguments, + pushScanArguments, transformReplyNumberInfinity, transformReplyNumberInfinityArray, transformReplyNumberInfinityNull, @@ -12,7 +13,11 @@ import { transformReplyStreamMessages, transformReplyStreamsMessages, transformReplyStreamsMessagesNull, - transformReplySortedSetWithScores + transformReplySortedSetWithScores, + pushGeoCountArgument, + pushGeoSearchArguments, + transformGeoMembersWithReply, + GeoReplyWith } from './generic-transformers'; describe('Generic Transformers', () => { @@ -48,17 +53,17 @@ describe('Generic Transformers', () => { }); }); - describe('transformScanArguments', () => { + describe('pushScanArguments', () => { it('cusror only', () => { assert.deepEqual( - transformScanArguments(0), + pushScanArguments([], 0), ['0'] ); }); it('with MATCH', () => { assert.deepEqual( - transformScanArguments(0, { + pushScanArguments([], 0, { MATCH: 'pattern' }), ['0', 'MATCH', 'pattern'] @@ -67,7 +72,7 @@ describe('Generic Transformers', () => { it('with COUNT', () => { assert.deepEqual( - transformScanArguments(0, { + pushScanArguments([], 0, { COUNT: 1 }), ['0', 'COUNT', '1'] @@ -76,7 +81,7 @@ describe('Generic Transformers', () => { it('with MATCH & COUNT', () => { assert.deepEqual( - transformScanArguments(0, { + pushScanArguments([], 0, { MATCH: 'pattern', COUNT: 1 }), @@ -99,7 +104,7 @@ describe('Generic Transformers', () => { Infinity ); }); - + it('-inf', () => { assert.equal( transformReplyNumberInfinity('-inf'), @@ -271,4 +276,190 @@ describe('Generic Transformers', () => { }] ); }); + + describe('pushGeoCountArgument', () => { + it('undefined', () => { + assert.deepEqual( + pushGeoCountArgument([], undefined), + [] + ); + }); + + it('number', () => { + assert.deepEqual( + pushGeoCountArgument([], 1), + ['COUNT', '1'] + ); + }); + + it('with ANY', () => { + assert.deepEqual( + pushGeoCountArgument([], { + value: 1, + ANY: true + }), + ['COUNT', '1', 'ANY'] + ); + }); + }); + + describe('pushGeoSearchArguments', () => { + it('FROMMEMBER, BYRADIUS', () => { + assert.deepEqual( + pushGeoSearchArguments([], 'key', 'member', { + radius: 1, + unit: 'm' + }), + ['key', 'FROMMEMBER', 'member', 'BYRADIUS', '1', 'm'] + ); + }); + + it('FROMLONLAT, BYBOX', () => { + assert.deepEqual( + pushGeoSearchArguments([], 'key', { + longitude: 1, + latitude: 2 + }, { + width: 1, + height: 2, + unit: 'm' + }), + ['key', 'FROMLONLAT', '1', '2', 'BYBOX', '1', '2', 'm'] + ); + }); + + it('with SORT', () => { + assert.deepEqual( + pushGeoSearchArguments([], 'key', 'member', { + radius: 1, + unit: 'm' + }, { + SORT: 'ASC' + }), + ['key', 'FROMMEMBER', 'member', 'BYRADIUS', '1', 'm', 'ASC'] + ); + }); + }); + + describe('transformGeoMembersWithReply', () => { + it('DISTANCE', () => { + assert.deepEqual( + transformGeoMembersWithReply([ + [ + '1', + '2' + ], + [ + '3', + '4' + ] + ], [GeoReplyWith.DISTANCE]), + [{ + member: '1', + distance: '2' + }, { + member: '3', + distance: '4' + }] + ); + }); + + it('HASH', () => { + assert.deepEqual( + transformGeoMembersWithReply([ + [ + '1', + 2 + ], + [ + '3', + 4 + ] + ], [GeoReplyWith.HASH]), + [{ + member: '1', + hash: 2 + }, { + member: '3', + hash: 4 + }] + ); + }); + + it('COORDINATES', () => { + assert.deepEqual( + transformGeoMembersWithReply([ + [ + '1', + [ + '2', + '3' + ] + ], + [ + '4', + [ + '5', + '6' + ] + ] + ], [GeoReplyWith.COORDINATES]), + [{ + member: '1', + coordinates: { + longitude: '2', + latitude: '3' + } + }, { + member: '4', + coordinates: { + longitude: '5', + latitude: '6' + } + }] + ); + }); + + it('DISTANCE, HASH, COORDINATES', () => { + assert.deepEqual( + transformGeoMembersWithReply([ + [ + '1', + '2', + 3, + [ + '4', + '5' + ] + ], + [ + '6', + '7', + 8, + [ + '9', + '10' + ] + ] + ], [GeoReplyWith.DISTANCE, GeoReplyWith.HASH, GeoReplyWith.COORDINATES]), + [{ + member: '1', + distance: '2', + hash: 3, + coordinates: { + longitude: '4', + latitude: '5' + } + }, { + member: '6', + distance: '7', + hash: 8, + coordinates: { + longitude: '9', + latitude: '10' + } + }] + ); + }); + }); }); diff --git a/lib/commands/generic-transformers.ts b/lib/commands/generic-transformers.ts index 284bc408f0..a51511da11 100644 --- a/lib/commands/generic-transformers.ts +++ b/lib/commands/generic-transformers.ts @@ -51,8 +51,8 @@ export interface ScanOptions { COUNT?: number; } -export function transformScanArguments(cursor: number, options?: ScanOptions): Array { - const args = [cursor.toString()]; +export function pushScanArguments(args: Array, cursor: number, options?: ScanOptions): Array { + args.push(cursor.toString()); if (options?.MATCH) { args.push('MATCH', options.MATCH); @@ -61,7 +61,7 @@ export function transformScanArguments(cursor: number, options?: ScanOptions): A if (options?.COUNT) { args.push('COUNT', options.COUNT.toString()); } - + return args; } @@ -69,7 +69,7 @@ export function transformReplyNumberInfinity(reply: string): number { switch (reply) { case '+inf': return Infinity; - + case '-inf': return -Infinity; @@ -96,7 +96,7 @@ export function transformArgumentNumberInfinity(num: number): string { switch (num) { case Infinity: return '+inf'; - + case -Infinity: return '-inf'; @@ -180,3 +180,134 @@ export function transformReplySortedSetWithScores(reply: Array): Array, count: GeoCountArgument | undefined): Array { + if (typeof count === 'number') { + args.push('COUNT', count.toString()); + } else if (count) { + args.push('COUNT', count.value.toString()); + + if (count.ANY) { + args.push('ANY'); + } + } + + return args; +} + +export type GeoUnits = 'm' | 'km' | 'mi' | 'ft'; + +export interface GeoCoordinates { + longitude: string | number; + latitude: string | number; +} + +type GeoSearchFromMember = string; + +export type GeoSearchFrom = GeoSearchFromMember | GeoCoordinates; + +interface GeoSearchByRadius { + radius: number; + unit: GeoUnits; +} + +interface GeoSearchByBox { + width: number; + height: number; + unit: GeoUnits; +} + +export type GeoSearchBy = GeoSearchByRadius | GeoSearchByBox; + +export interface GeoSearchOptions { + SORT?: 'ASC' | 'DESC'; + COUNT?: GeoCountArgument; +} + +export function pushGeoSearchArguments( + args: Array, + key: string, + from: GeoSearchFrom, + by: GeoSearchBy, + options?: GeoSearchOptions +): Array { + args.push(key); + + if (typeof from === 'string') { + args.push('FROMMEMBER', from); + } else { + args.push('FROMLONLAT', from.longitude.toString(), from.latitude.toString()); + } + + if ('radius' in by) { + args.push('BYRADIUS', by.radius.toString()); + } else { + args.push('BYBOX', by.width.toString(), by.height.toString()); + } + + if (by.unit) { + args.push(by.unit); + } + + if (options?.SORT) { + args.push(options?.SORT); + } + + pushGeoCountArgument(args, options?.COUNT); + + return args; +} + +export enum GeoReplyWith { + DISTANCE = 'WITHDIST', + HASH = 'WITHHASH', + COORDINATES = 'WITHCOORD' +} + +export interface GeoReplyWithMember { + member: string; + distance?: number; + hash?: string; + coordinates?: { + longitude: string; + latitude: string; + }; +} + +export function transformGeoMembersWithReply(reply: Array>, replyWith: Array): Array { + const replyWithSet = new Set(replyWith); + + let index = 0, + distanceIndex = replyWithSet.has(GeoReplyWith.DISTANCE) && ++index, + hashIndex = replyWithSet.has(GeoReplyWith.HASH) && ++index, + coordinatesIndex = replyWithSet.has(GeoReplyWith.COORDINATES) && ++index; + + return reply.map(member => { + const transformedMember: GeoReplyWithMember = { + member: member[0] + }; + + if (distanceIndex) { + transformedMember.distance = member[distanceIndex]; + } + + if (hashIndex) { + transformedMember.hash = member[hashIndex]; + } + + if (coordinatesIndex) { + const [longitude, latitude] = member[coordinatesIndex]; + transformedMember.coordinates = { + longitude, + latitude + }; + } + + return transformedMember; + }); +} \ No newline at end of file diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 872bfdd5a2..9bdb15ed7c 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -50,6 +50,13 @@ import * as EXPIREAT from './EXPIREAT'; import * as FAILOVER from './FAILOVER'; import * as FLUSHALL from './FLUSHALL'; import * as FLUSHDB from './FLUSHDB'; +import * as GEOADD from './GEOADD'; +import * as GEODIST from './GEODIST'; +import * as GEOHASH from './GEOHASH'; +import * as GEOPOS from './GEOPOS'; +import * as GEOSEARCH_WITH from './GEOSEARCH_WITH'; +import * as GEOSEARCH from './GEOSEARCH'; +import * as GEOSEARCHSTORE from './GEOSEARCHSTORE'; import * as GET from './GET'; import * as GETBIT from './GETBIT'; import * as GETDEL from './GETDEL'; @@ -116,7 +123,7 @@ import * as RPOP_COUNT from './RPOP_COUNT'; import * as RPOP from './RPOP'; import * as RPOPLPUSH from './RPOPLPUSH'; import * as RPUSH from './RPUSH'; -import * as RPUSHX from './RPUSHX'; +import * as RPUSHX from './RPUSHX'; import * as SADD from './SADD'; import * as SAVE from './SAVE'; import * as SCAN from './SCAN'; @@ -311,6 +318,20 @@ export default { flushAll: FLUSHALL, FLUSHDB, flushDb: FLUSHDB, + GEOADD, + geoAdd: GEOADD, + GEODIST, + geoDist: GEODIST, + GEOHASH, + geoHash: GEOHASH, + GEOPOS, + geoPos: GEOPOS, + GEOSEARCH_WITH, + geoSearchWith: GEOSEARCH_WITH, + GEOSEARCH, + geoSearch: GEOSEARCH, + GEOSEARCHSTORE, + geoSearchStore: GEOSEARCHSTORE, GET, get: GET, GETBIT, @@ -561,7 +582,7 @@ export default { ZCOUNT, zCount: ZCOUNT, ZDIFF_WITHSCORES, - zDiffWithScores: ZDIFF_WITHSCORES, + zDiffWithScores: ZDIFF_WITHSCORES, ZDIFF, zDiff: ZDIFF, ZDIFFSTORE, @@ -624,11 +645,13 @@ export default { export type RedisReply = string | number | Array | null | undefined; +export type TransformArgumentsReply = Array & { preserve?: unknown }; + export interface RedisCommand { FIRST_KEY_INDEX?: number | ((...args: Array) => string); IS_READ_ONLY?: boolean; - transformArguments(...args: Array): Array; - transformReply(reply: RedisReply): any; + transformArguments(...args: Array): TransformArgumentsReply; + transformReply(reply: RedisReply, preserved: unknown): any; } export interface RedisCommands { diff --git a/lib/multi-command.ts b/lib/multi-command.ts index a639a21640..a8cbe5785a 100644 --- a/lib/multi-command.ts +++ b/lib/multi-command.ts @@ -1,4 +1,4 @@ -import COMMANDS from './commands'; +import COMMANDS, { TransformArgumentsReply } from './commands'; import { RedisCommand, RedisModules, RedisReply } from './commands'; import RedisCommandsQueue from './commands-queue'; import { RedisLuaScript, RedisLuaScripts } from './lua-script'; @@ -24,6 +24,7 @@ export type RedisMultiCommandType, transformReply?: RedisCommand['transformReply']): RedisMultiCommandType { + addCommand(args: TransformArgumentsReply, transformReply?: RedisCommand['transformReply']): RedisMultiCommandType { this.#queue.push({ encodedCommand: RedisCommandsQueue.encodeCommand(args), + preservedArguments: args.preserve, transformReply }); @@ -224,8 +226,8 @@ export default class RedisMultiCommand).map((reply, i) => { - const { transformReply } = queue[i + 1]; - return transformReply ? transformReply(reply) : reply; + const { transformReply, preservedArguments } = queue[i + 1]; + return transformReply ? transformReply(reply, preservedArguments) : reply; }); }