From f523508a23db5ef71913e2f4ed610a4dbfdfa23b Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Tue, 28 Oct 2025 14:41:11 +0200 Subject: [PATCH] feat: add msetex command and tests for it --- packages/client/lib/commands/MSETEX.spec.ts | 393 ++++++++++++++++++++ packages/client/lib/commands/MSETEX.ts | 143 +++++++ packages/client/lib/commands/index.ts | 3 + 3 files changed, 539 insertions(+) create mode 100644 packages/client/lib/commands/MSETEX.spec.ts create mode 100644 packages/client/lib/commands/MSETEX.ts diff --git a/packages/client/lib/commands/MSETEX.spec.ts b/packages/client/lib/commands/MSETEX.spec.ts new file mode 100644 index 00000000000..20eea82136f --- /dev/null +++ b/packages/client/lib/commands/MSETEX.spec.ts @@ -0,0 +1,393 @@ +import { strict as assert } from "node:assert"; +import testUtils, { GLOBAL } from "../test-utils"; +import MSETEX, { ExpirationMode, SetMode } from "./MSETEX"; +import { parseArgs } from "./generic-transformers"; + +describe("MSETEX", () => { + describe("transformArguments", () => { + it("single key-value pair as array", () => { + assert.deepEqual(parseArgs(MSETEX, ["key1", "value1"]), [ + "MSETEX", + "1", + "key1", + "value1", + ]); + }); + + it("array of key value pairs", () => { + assert.deepEqual( + parseArgs(MSETEX, [ + "key1", + "value1", + "key2", + "value2", + "key3", + "value3", + ]), + ["MSETEX", "3", "key1", "value1", "key2", "value2", "key3", "value3"] + ); + }); + + it("array of tuples", () => { + assert.deepEqual( + parseArgs(MSETEX, [ + ["key1", "value1"], + ["key2", "value2"], + ]), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + + it("object of key value pairs", () => { + assert.deepEqual( + parseArgs(MSETEX, { + key1: "value1", + key2: "value2", + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + + it("with EX expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.EX, + value: 1, + }, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "EX", "1"] + ); + }); + + it("with NX set mode", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + mode: SetMode.NX, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "NX"] + ); + }); + + it("with XX set mode and PX expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + mode: SetMode.XX, + expiration: { + type: ExpirationMode.PX, + value: 1, + }, + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "XX", "PX", "1"] + ); + }); + + it("with EXAT Date expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.EXAT, + value: new Date("2025-10-28T11:23:36.203Z"), + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "EXAT", + "1761650616", + ] + ); + }); + + it("with EXAT numeric expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + expiration: { + type: ExpirationMode.EXAT, + value: 1761650616, + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "EXAT", + "1761650616", + ] + ); + }); + + it("with PXAT Date expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + expiration: { + type: ExpirationMode.PXAT, + value: new Date("2025-10-28T11:23:36.203Z"), + }, + }), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "PXAT", + "1761650616203", + ] + ); + }); + + it("with PXAT numeric expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.PXAT, + value: 1761650616203, + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "PXAT", + "1761650616203", + ] + ); + }); + + it("with KEEPTTL expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + expiration: { + type: ExpirationMode.KEEPTTL, + }, + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "KEEPTTL"] + ); + }); + + it("with empty expiration object", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + expiration: {}, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + }); + + testUtils.testAll( + "basic mSetEx", + async (client) => { + assert.equal( + await client.mSetEx(["{key}1", "value1", "{key}2", "value2"]), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with XX", + async (client) => { + const keyValuePairs = { + "{key}1": "value1", + "{key}2": "value2", + }; + + const keysDoNotExist = await client.mSetEx(keyValuePairs, { + mode: SetMode.XX, + }); + + assert.equal(keysDoNotExist, 0); + + await client.mSet(keyValuePairs); + + const keysExist = await client.mSetEx(keyValuePairs, { + mode: SetMode.XX, + }); + + assert.equal(keysExist, 1); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with NX", + async (client) => { + const keyValuePairs = [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ] as Array<[string, string]>; + + const firstAttempt = await client.mSetEx(keyValuePairs, { + mode: SetMode.NX, + }); + + assert.equal(firstAttempt, 1); + + const secondAttempt = await client.mSetEx(keyValuePairs, { + mode: SetMode.NX, + }); + + assert.equal(secondAttempt, 0); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with PX expiration", + async (client) => { + assert.equal( + await client.mSetEx( + [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ], + { + expiration: { + type: ExpirationMode.PX, + value: 500, + }, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with EXAT expiration", + async (client) => { + assert.equal( + await client.mSetEx( + [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ], + { + expiration: { + type: ExpirationMode.EXAT, + value: new Date(Date.now() + 10000), + }, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with KEEPTTL expiration", + async (client) => { + assert.equal( + await client.mSetEx(["{key}1", "value1", "{key}2", "value2"], { + expiration: { + type: ExpirationMode.KEEPTTL, + }, + }), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with all options", + async (client) => { + assert.equal( + await client.mSetEx( + { + "{key}1": "value1", + "{key}2": "value2", + }, + { + expiration: { + type: ExpirationMode.PXAT, + value: Date.now() + 10000, + }, + mode: SetMode.NX, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); +}); diff --git a/packages/client/lib/commands/MSETEX.ts b/packages/client/lib/commands/MSETEX.ts new file mode 100644 index 00000000000..ee32eb6edb4 --- /dev/null +++ b/packages/client/lib/commands/MSETEX.ts @@ -0,0 +1,143 @@ +import { CommandParser } from "../client/parser"; +import { NumberReply, Command, RedisArgument } from "../RESP/types"; +import { transformEXAT, transformPXAT } from "./generic-transformers"; +import { MSetArguments } from "./MSET"; + +export const SetMode = { + /** + * Only set if all keys exist + */ + XX: "XX", + /** + * Only set if none of the keys exist + */ + NX: "NX", +} as const; + +export type SetMode = (typeof SetMode)[keyof typeof SetMode]; + +export const ExpirationMode = { + /** + * Relative expiration (seconds) + */ + EX: "EX", + /** + * Relative expiration (milliseconds) + */ + PX: "PX", + /** + * Absolute expiration (Unix timestamp in seconds) + */ + EXAT: "EXAT", + /** + * Absolute expiration (Unix timestamp in milliseconds) + */ + PXAT: "PXAT", + /** + * Keep existing TTL + */ + KEEPTTL: "KEEPTTL", +} as const; + +export type ExpirationMode = + (typeof ExpirationMode)[keyof typeof ExpirationMode]; + +type SetConditionOption = typeof SetMode.XX | typeof SetMode.NX; + +type ExpirationOption = + | { type: typeof ExpirationMode.EX; value: number } + | { type: typeof ExpirationMode.PX; value: number } + | { type: typeof ExpirationMode.EXAT; value: number | Date } + | { type: typeof ExpirationMode.PXAT; value: number | Date } + | { type: typeof ExpirationMode.KEEPTTL }; + +export function parseMSetExArguments( + parser: CommandParser, + keyValuePairs: MSetArguments +) { + let tuples: Array<[RedisArgument, RedisArgument]> = []; + + if (Array.isArray(keyValuePairs)) { + if (keyValuePairs.length == 0) { + throw new Error("empty keyValuePairs Argument"); + } + if (Array.isArray(keyValuePairs[0])) { + tuples = keyValuePairs as Array<[RedisArgument, RedisArgument]>; + } else { + const arr = keyValuePairs as Array; + for (let i = 0; i < arr.length; i += 2) { + tuples.push([arr[i], arr[i + 1]]); + } + } + } else { + for (const tuple of Object.entries(keyValuePairs)) { + tuples.push([tuple[0], tuple[1]]); + } + } + + // Push the number of keys + parser.push(tuples.length.toString()); + + for (const tuple of tuples) { + parser.pushKey(tuple[0]); + parser.push(tuple[1]); + } +} + +export default { + /** + * Constructs the MSETEX command. + * + * Atomically sets multiple string keys with a shared expiration in a single operation. + * + * @param parser - The command parser + * @param keyValuePairs - Key-value pairs to set (array of tuples, flat array, or object) + * @param options - Configuration for expiration and set modes + * @see https://redis.io/commands/msetex/ + */ + parseCommand( + parser: CommandParser, + keyValuePairs: MSetArguments, + options?: { + expiration?: ExpirationOption; + mode?: SetConditionOption; + } + ) { + parser.push("MSETEX"); + + // Push number of keys and key-value pairs before the options + parseMSetExArguments(parser, keyValuePairs); + + if (options?.mode) { + parser.push(options.mode); + } + + if (options?.expiration) { + switch (options.expiration.type) { + case ExpirationMode.EXAT: + parser.push( + ExpirationMode.EXAT, + transformEXAT(options.expiration.value) + ); + break; + case ExpirationMode.PXAT: + parser.push( + ExpirationMode.PXAT, + transformPXAT(options.expiration.value) + ); + break; + case ExpirationMode.KEEPTTL: + parser.push(ExpirationMode.KEEPTTL); + break; + case ExpirationMode.EX: + case ExpirationMode.PX: + parser.push( + options.expiration.type, + options.expiration.value?.toString() + ); + break; + } + } + }, + transformReply: undefined as unknown as () => NumberReply<0 | 1>, +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 54ede43d011..6c1c6c4d6e9 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -204,6 +204,7 @@ import MODULE_LOAD from './MODULE_LOAD'; import MODULE_UNLOAD from './MODULE_UNLOAD'; import MOVE from './MOVE'; import MSET from './MSET'; +import MSETEX from './MSETEX'; import MSETNX from './MSETNX'; import OBJECT_ENCODING from './OBJECT_ENCODING'; import OBJECT_FREQ from './OBJECT_FREQ'; @@ -782,6 +783,8 @@ export default { move: MOVE, MSET, mSet: MSET, + MSETEX, + mSetEx: MSETEX, MSETNX, mSetNX: MSETNX, OBJECT_ENCODING,