diff --git a/packages/client/lib/commands/HGETDEL.spec.ts b/packages/client/lib/commands/HGETDEL.spec.ts new file mode 100644 index 0000000000..b2e19967f1 --- /dev/null +++ b/packages/client/lib/commands/HGETDEL.spec.ts @@ -0,0 +1,48 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { BasicCommandParser } from '../client/parser'; +import HGETDEL from './HGETDEL'; + +describe('HGETDEL parseCommand', () => { + it('hGetDel parseCommand base', () => { + const parser = new BasicCommandParser; + HGETDEL.parseCommand(parser, 'key', 'field'); + assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '1', 'field']); + }); + + it('hGetDel parseCommand variadic', () => { + const parser = new BasicCommandParser; + HGETDEL.parseCommand(parser, 'key', ['field1', 'field2']); + assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '2', 'field1', 'field2']); + }); +}); + + +describe('HGETDEL call', () => { + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty single field', async client => { + assert.deepEqual( + await client.hGetDel('key', 'filed1'), + [null] + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty multiple fields', async client => { + assert.deepEqual( + await client.hGetDel('key', ['filed1', 'field2']), + [null, null] + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel partially populated multiple fields', async client => { + await client.hSet('key', 'field1', 'value1') + assert.deepEqual( + await client.hGetDel('key', ['field1', 'field2']), + ['value1', null] + ); + + assert.deepEqual( + await client.hGetDel('key', 'field1'), + [null] + ); + }, GLOBAL.SERVERS.OPEN); +}); diff --git a/packages/client/lib/commands/HGETDEL.ts b/packages/client/lib/commands/HGETDEL.ts new file mode 100644 index 0000000000..a0326c425e --- /dev/null +++ b/packages/client/lib/commands/HGETDEL.ts @@ -0,0 +1,13 @@ +import { CommandParser } from '../client/parser'; +import { RedisVariadicArgument } from './generic-transformers'; +import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types'; + +export default { + parseCommand(parser: CommandParser, key: RedisArgument, fields: RedisVariadicArgument) { + parser.push('HGETDEL'); + parser.pushKey(key); + parser.push('FIELDS') + parser.pushVariadicWithLength(fields); + }, + transformReply: undefined as unknown as () => ArrayReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/HGETEX.spec.ts b/packages/client/lib/commands/HGETEX.spec.ts new file mode 100644 index 0000000000..2625a0ac02 --- /dev/null +++ b/packages/client/lib/commands/HGETEX.spec.ts @@ -0,0 +1,78 @@ +import { strict as assert } from 'node:assert'; +import testUtils,{ GLOBAL } from '../test-utils'; +import { BasicCommandParser } from '../client/parser'; +import HGETEX from './HGETEX'; +import { setTimeout } from 'timers/promises'; + +describe('HGETEX parseCommand', () => { + it('hGetEx parseCommand base', () => { + const parser = new BasicCommandParser; + HGETEX.parseCommand(parser, 'key', 'field'); + assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'FIELDS', '1', 'field']); + }); + + it('hGetEx parseCommand expiration PERSIST string', () => { + const parser = new BasicCommandParser; + HGETEX.parseCommand(parser, 'key', 'field', {expiration: 'PERSIST'}); + assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']); + }); + + it('hGetEx parseCommand expiration PERSIST obj', () => { + const parser = new BasicCommandParser; + HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'PERSIST'}}); + assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']); + }); + + it('hGetEx parseCommand expiration EX obj', () => { + const parser = new BasicCommandParser; + HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'EX', value: 1000}}); + assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EX', '1000', 'FIELDS', '1', 'field']); + }); + + it('hGetEx parseCommand expiration EXAT obj variadic', () => { + const parser = new BasicCommandParser; + HGETEX.parseCommand(parser, 'key', ['field1', 'field2'], {expiration: {type: 'EXAT', value: 1000}}); + assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EXAT', '1000', 'FIELDS', '2', 'field1', 'field2']); + }); +}); + + +describe('HGETEX call', () => { + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty single field', async client => { + assert.deepEqual( + await client.hGetEx('key', 'field1', {expiration: 'PERSIST'}), + [null] + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty multiple fields', async client => { + assert.deepEqual( + await client.hGetEx('key', ['field1', 'field2'], {expiration: 'PERSIST'}), + [null, null] + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx set expiry', async client => { + await client.hSet('key', 'field', 'value') + assert.deepEqual( + await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}}), + ['value'] + ); + await setTimeout(100) + assert.deepEqual( + await client.hGet('key', 'field'), + null + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'gGetEx set expiry PERSIST', async client => { + await client.hSet('key', 'field', 'value') + await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}}) + await client.hGetEx('key', 'field', {expiration: 'PERSIST'}) + await setTimeout(100) + assert.deepEqual( + await client.hGet('key', 'field'), + 'value' + ) + }, GLOBAL.SERVERS.OPEN); +}); \ No newline at end of file diff --git a/packages/client/lib/commands/HGETEX.ts b/packages/client/lib/commands/HGETEX.ts new file mode 100644 index 0000000000..ce265e15bd --- /dev/null +++ b/packages/client/lib/commands/HGETEX.ts @@ -0,0 +1,42 @@ +import { CommandParser } from '../client/parser'; +import { RedisVariadicArgument } from './generic-transformers'; +import { ArrayReply, Command, BlobStringReply, NullReply, RedisArgument } from '../RESP/types'; + +export interface HGetExOptions { + expiration?: { + type: 'EX' | 'PX' | 'EXAT' | 'PXAT'; + value: number; + } | { + type: 'PERSIST'; + } | 'PERSIST'; +} + +export default { + parseCommand( + parser: CommandParser, + key: RedisArgument, + fields: RedisVariadicArgument, + options?: HGetExOptions + ) { + parser.push('HGETEX'); + parser.pushKey(key); + + if (options?.expiration) { + if (typeof options.expiration === 'string') { + parser.push(options.expiration); + } else if (options.expiration.type === 'PERSIST') { + parser.push('PERSIST'); + } else { + parser.push( + options.expiration.type, + options.expiration.value.toString() + ); + } + } + + parser.push('FIELDS') + + parser.pushVariadicWithLength(fields); + }, + transformReply: undefined as unknown as () => ArrayReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/HSETEX.spec.ts b/packages/client/lib/commands/HSETEX.spec.ts new file mode 100644 index 0000000000..fc38e0f0f4 --- /dev/null +++ b/packages/client/lib/commands/HSETEX.spec.ts @@ -0,0 +1,98 @@ +import { strict as assert } from 'node:assert'; +import testUtils,{ GLOBAL } from '../test-utils'; +import { BasicCommandParser } from '../client/parser'; +import HSETEX from './HSETEX'; + +describe('HSETEX parseCommand', () => { + it('hSetEx parseCommand base', () => { + const parser = new BasicCommandParser; + HSETEX.parseCommand(parser, 'key', ['field', 'value']); + assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'field', 'value']); + }); + + it('hSetEx parseCommand base empty obj', () => { + const parser = new BasicCommandParser; + assert.throws(() => {HSETEX.parseCommand(parser, 'key', {})}); + }); + + it('hSetEx parseCommand base one key obj', () => { + const parser = new BasicCommandParser; + HSETEX.parseCommand(parser, 'key', {'k': 'v'}); + assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'k', 'v']); + }); + + it('hSetEx parseCommand array', () => { + const parser = new BasicCommandParser; + HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2', 'value2']); + assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']); + }); + + it('hSetEx parseCommand array invalid args, throws an error', () => { + const parser = new BasicCommandParser; + assert.throws(() => {HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2'])}); + }); + + it('hSetEx parseCommand array in array', () => { + const parser1 = new BasicCommandParser; + HSETEX.parseCommand(parser1, 'key', [['field1', 'value1'], ['field2', 'value2']]); + assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']); + + const parser2 = new BasicCommandParser; + HSETEX.parseCommand(parser2, 'key', [['field1', 'value1'], ['field2', 'value2'], ['field3', 'value3']]); + assert.deepEqual(parser2.redisArgs, ['HSETEX', 'key', 'FIELDS', '3', 'field1', 'value1', 'field2', 'value2', 'field3', 'value3']); + }); + + it('hSetEx parseCommand map', () => { + const parser1 = new BasicCommandParser; + HSETEX.parseCommand(parser1, 'key', new Map([['field1', 'value1'], ['field2', 'value2']])); + assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']); + }); + + it('hSetEx parseCommand obj', () => { + const parser1 = new BasicCommandParser; + HSETEX.parseCommand(parser1, 'key', {field1: "value1", field2: "value2"}); + assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']); + }); + + it('hSetEx parseCommand options FNX KEEPTTL', () => { + const parser = new BasicCommandParser; + HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FNX', expiration: 'KEEPTTL'}); + assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FNX', 'KEEPTTL', 'FIELDS', '1', 'field', 'value']); + }); + + it('hSetEx parseCommand options FXX EX 500', () => { + const parser = new BasicCommandParser; + HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FXX', expiration: {type: 'EX', value: 500}}); + assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FXX', 'EX', '500', 'FIELDS', '1', 'field', 'value']); + }); +}); + + +describe('HSETEX call', () => { + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hSetEx calls', async client => { + assert.deepEqual( + await client.hSetEx('key_hsetex_call', ['field1', 'value1'], {expiration: {type: "EX", value: 500}, mode: "FNX"}), + 1 + ); + + assert.deepEqual( + await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}), + 0 + ); + + assert.deepEqual( + await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}), + 0 + ); + + assert.deepEqual( + await client.hSetEx('key_hsetex_call', ['field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}), + 1 + ); + + assert.deepEqual( + await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}), + 1 + ); + }, GLOBAL.SERVERS.OPEN); +}); \ No newline at end of file diff --git a/packages/client/lib/commands/HSETEX.ts b/packages/client/lib/commands/HSETEX.ts new file mode 100644 index 0000000000..3827538934 --- /dev/null +++ b/packages/client/lib/commands/HSETEX.ts @@ -0,0 +1,110 @@ +import { BasicCommandParser, CommandParser } from '../client/parser'; +import { Command, NumberReply, RedisArgument } from '../RESP/types'; + +export interface HSetExOptions { + expiration?: { + type: 'EX' | 'PX' | 'EXAT' | 'PXAT'; + value: number; + } | { + type: 'KEEPTTL'; + } | 'KEEPTTL'; + mode?: 'FNX' | 'FXX' + } + +export type HashTypes = RedisArgument | number; + +type HSETEXObject = Record; + +type HSETEXMap = Map; + +type HSETEXTuples = Array<[HashTypes, HashTypes]> | Array; + +export default { + parseCommand( + parser: CommandParser, + key: RedisArgument, + fields: HSETEXObject | HSETEXMap | HSETEXTuples, + options?: HSetExOptions + ) { + parser.push('HSETEX'); + parser.pushKey(key); + + if (options?.mode) { + parser.push(options.mode) + } + if (options?.expiration) { + if (typeof options.expiration === 'string') { + parser.push(options.expiration); + } else if (options.expiration.type === 'KEEPTTL') { + parser.push('KEEPTTL'); + } else { + parser.push( + options.expiration.type, + options.expiration.value.toString() + ); + } + } + + parser.push('FIELDS') + if (fields instanceof Map) { + pushMap(parser, fields); + } else if (Array.isArray(fields)) { + pushTuples(parser, fields); + } else { + pushObject(parser, fields); + } + }, + transformReply: undefined as unknown as () => NumberReply<0 | 1> +} as const satisfies Command; + + +function pushMap(parser: CommandParser, map: HSETEXMap): void { + parser.push(map.size.toString()) + for (const [key, value] of map.entries()) { + parser.push( + convertValue(key), + convertValue(value) + ); + } +} + +function pushTuples(parser: CommandParser, tuples: HSETEXTuples): void { + const tmpParser = new BasicCommandParser + _pushTuples(tmpParser, tuples) + + if (tmpParser.redisArgs.length%2 != 0) { + throw Error('invalid number of arguments, expected key value ....[key value] pairs, got key without value') + } + + parser.push((tmpParser.redisArgs.length/2).toString()) + parser.push(...tmpParser.redisArgs) +} + +function _pushTuples(parser: CommandParser, tuples: HSETEXTuples): void { + for (const tuple of tuples) { + if (Array.isArray(tuple)) { + _pushTuples(parser, tuple); + continue; + } + parser.push(convertValue(tuple)); + } +} + +function pushObject(parser: CommandParser, object: HSETEXObject): void { + const len = Object.keys(object).length + if (len == 0) { + throw Error('object without keys') + } + + parser.push(len.toString()) + for (const key of Object.keys(object)) { + parser.push( + convertValue(key), + convertValue(object[key]) + ); + } +} + +function convertValue(value: HashTypes): RedisArgument { + return typeof value === 'number' ? value.toString() : value; +} \ No newline at end of file diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index fc139a948e..91eab7107a 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -269,7 +269,7 @@ export function pushVariadicArgument( return args; } -export function parseOptionalVariadicArgument( +export function parseOptionalVariadicArgument( parser: CommandParser, name: RedisArgument, value?: RedisVariadicArgument diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 024ee2191b..5cd81331a4 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -138,6 +138,8 @@ import HEXPIREAT from './HEXPIREAT'; import HEXPIRETIME from './HEXPIRETIME'; import HGET from './HGET'; import HGETALL from './HGETALL'; +import HGETDEL from './HGETDEL'; +import HGETEX from './HGETEX'; import HINCRBY from './HINCRBY'; import HINCRBYFLOAT from './HINCRBYFLOAT'; import HKEYS from './HKEYS'; @@ -154,6 +156,7 @@ import HRANDFIELD from './HRANDFIELD'; import HSCAN from './HSCAN'; import HSCAN_NOVALUES from './HSCAN_NOVALUES'; import HSET from './HSET'; +import HSETEX from './HSETEX'; import HSETNX from './HSETNX'; import HSTRLEN from './HSTRLEN'; import HTTL from './HTTL'; @@ -621,6 +624,10 @@ export default { hGet: HGET, HGETALL, hGetAll: HGETALL, + HGETDEL, + hGetDel: HGETDEL, + HGETEX, + hGetEx: HGETEX, HINCRBY, hIncrBy: HINCRBY, HINCRBYFLOAT, @@ -653,6 +660,8 @@ export default { hScanNoValues: HSCAN_NOVALUES, HSET, hSet: HSET, + HSETEX, + hSetEx: HSETEX, HSETNX, hSetNX: HSETNX, HSTRLEN,