diff --git a/packages/client/lib/commands/BLMOVEM.spec.ts b/packages/client/lib/commands/BLMOVEM.spec.ts new file mode 100644 index 00000000000..f6ddfbcaa08 --- /dev/null +++ b/packages/client/lib/commands/BLMOVEM.spec.ts @@ -0,0 +1,61 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL, BLOCKING_MIN_VALUE } from '../test-utils'; +import BLMOVEM from './BLMOVEM'; +import { parseArgs } from './generic-transformers'; + +describe('BLMOVEM', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', 0), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', '0'] + ); + }); + + it('with COUNT and ORDER', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'LEFT', 0, { + COUNT: 3, + ORDER: 'OBO' + }), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'LEFT', '0', 'COUNT', '3', 'OBO'] + ); + }); + + it('with EXACTLY and ORDER', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', 0, { + EXACTLY: 2, + ORDER: 'BULK' + }), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', '0', 'EXACTLY', '2', 'BULK'] + ); + }); + }); + + testUtils.testAll('blMoveM - null on timeout', async client => { + assert.equal( + await client.blMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', BLOCKING_MIN_VALUE), + null + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('blMoveM - with elements', async client => { + const [, reply] = await Promise.all([ + client.rPush('{tag}source', ['1', '2', '3']), + client.blMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', BLOCKING_MIN_VALUE, { + COUNT: 2, + ORDER: 'BULK' + }) + ]); + assert.deepEqual(reply, ['1', '2']); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); +}); diff --git a/packages/client/lib/commands/BLMOVEM.ts b/packages/client/lib/commands/BLMOVEM.ts new file mode 100644 index 00000000000..fc6cf5173e1 --- /dev/null +++ b/packages/client/lib/commands/BLMOVEM.ts @@ -0,0 +1,23 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { ListSide } from './generic-transformers'; +import LMOVEM, { LMoveMOptions, parseLMoveMOptions } from './LMOVEM'; + +export default { + IS_READ_ONLY: false, + parseCommand( + parser: CommandParser, + source: RedisArgument, + destination: RedisArgument, + sourceSide: ListSide, + destinationSide: ListSide, + timeout: number, + options?: LMoveMOptions + ) { + parser.push('BLMOVEM'); + parser.pushKeys([source, destination]); + parser.push(sourceSide, destinationSide, timeout.toString()); + parseLMoveMOptions(parser, options); + }, + transformReply: LMOVEM.transformReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/LMOVEM.spec.ts b/packages/client/lib/commands/LMOVEM.spec.ts new file mode 100644 index 00000000000..f426b047766 --- /dev/null +++ b/packages/client/lib/commands/LMOVEM.spec.ts @@ -0,0 +1,145 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import LMOVEM from './LMOVEM'; +import { parseArgs } from './generic-transformers'; + +describe('LMOVEM', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT'), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT'] + ); + }); + + it('with COUNT', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', { + COUNT: 3 + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', 'COUNT', '3'] + ); + }); + + it('with COUNT and ORDER', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'OBO' + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'LEFT', 'COUNT', '3', 'OBO'] + ); + }); + + it('with EXACTLY and ORDER', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', { + EXACTLY: 2, + ORDER: 'BULK' + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', 'EXACTLY', '2', 'BULK'] + ); + }); + }); + + testUtils.testAll('lMoveM - null when source empty', async client => { + assert.equal( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT'), + null + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT with OBO ordering', async client => { + await client.rPush('{tag}source', ['1', '2', '3', '4', '5']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'OBO' + }), + ['3', '2', '1'] + ); + assert.deepEqual( + await client.lRange('{tag}destination', 0, -1), + ['3', '2', '1'] + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['4', '5'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT with BULK ordering', async client => { + await client.rPush('{tag}source', ['1', '2', '3', '4', '5']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'BULK' + }), + ['1', '2', '3'] + ); + assert.deepEqual( + await client.lRange('{tag}destination', 0, -1), + ['1', '2', '3'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - no options moves a single element as an array', async client => { + await client.rPush('{tag}source', ['1', '2', '3']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT'), + ['1'] + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['2', '3'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT clamps to available elements', async client => { + await client.rPush('{tag}source', ['1', '2', '3']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 10, + ORDER: 'BULK' + }), + ['1', '2', '3'] + ); + assert.equal(await client.exists('{tag}source'), 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - EXACTLY with too few elements moves nothing', async client => { + await client.rPush('{tag}source', ['1', '2']); + assert.equal( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', { + EXACTLY: 3, + ORDER: 'BULK' + }), + null + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['1', '2'] + ); + assert.equal(await client.exists('{tag}destination'), 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); +}); diff --git a/packages/client/lib/commands/LMOVEM.ts b/packages/client/lib/commands/LMOVEM.ts new file mode 100644 index 00000000000..cd7ee895472 --- /dev/null +++ b/packages/client/lib/commands/LMOVEM.ts @@ -0,0 +1,59 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types'; +import { ListSide } from './generic-transformers'; + +/** + * Ordering of the moved elements at the destination. + * + * - `OBO` (one-by-one): push each element as it is popped, reversing the + * moved block's relative order (stack semantics). + * - `BULK`: preserve the moved block's original relative order (queue semantics). + */ +export type LMoveMOrder = 'OBO' | 'BULK'; + +export type LMoveMOptions = { + /** Move up to `COUNT` elements (fewer if the source has fewer). */ + COUNT: number; + ORDER?: LMoveMOrder; +} | { + /** Move exactly `EXACTLY` elements; if the source has fewer, move nothing and reply with null. */ + EXACTLY: number; + ORDER?: LMoveMOrder; +}; + +/** + * Pushes the trailing `[ count [OBO|BULK]]` arguments shared by + * LMOVEM and BLMOVEM. Kept separate from the command bodies because BLMOVEM's + * `timeout` sits between the sides and these options, so only the tail is shared. + */ +export function parseLMoveMOptions(parser: CommandParser, options?: LMoveMOptions) { + if (options) { + if ('EXACTLY' in options) { + parser.push('EXACTLY', options.EXACTLY.toString()); + } else { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options.ORDER !== undefined) { + parser.push(options.ORDER); + } + } +} + +export default { + IS_READ_ONLY: false, + parseCommand( + parser: CommandParser, + source: RedisArgument, + destination: RedisArgument, + sourceSide: ListSide, + destinationSide: ListSide, + options?: LMoveMOptions + ) { + parser.push('LMOVEM'); + parser.pushKeys([source, destination]); + parser.push(sourceSide, destinationSide); + parseLMoveMOptions(parser, options); + }, + transformReply: undefined as unknown as () => ArrayReply | NullReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 3ae4f4485ff..65b98a70f61 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -42,6 +42,7 @@ import BITFIELD from './BITFIELD'; import BITOP from './BITOP'; import BITPOS from './BITPOS'; import BLMOVE from './BLMOVE'; +import BLMOVEM from './BLMOVEM'; import BLMPOP from './BLMPOP'; import BLPOP from './BLPOP'; import BRPOP from './BRPOP'; @@ -208,6 +209,7 @@ import LINDEX from './LINDEX'; import LINSERT from './LINSERT'; import LLEN from './LLEN'; import LMOVE from './LMOVE'; +import LMOVEM from './LMOVEM'; import LMPOP from './LMPOP'; import LOLWUT from './LOLWUT'; import LPOP_COUNT from './LPOP_COUNT'; @@ -951,6 +953,30 @@ export default { * @param timeout - Timeout in seconds, 0 to block indefinitely */ blMove: BLMOVE, + /** + * Moves multiple elements from one list to another; or blocks until the source has elements to move + * @param source - Key of the source list + * @param destination - Key of the destination list + * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) + * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) + * @param timeout - Timeout in seconds, 0 to block indefinitely + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/blmovem/ + * @since 8.10 + */ + BLMOVEM, + /** + * Moves multiple elements from one list to another; or blocks until the source has elements to move + * @param source - Key of the source list + * @param destination - Key of the destination list + * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) + * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) + * @param timeout - Timeout in seconds, 0 to block indefinitely + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/blmovem/ + * @since 8.10 + */ + blMoveM: BLMOVEM, /** * Pops elements from multiple lists; blocks until elements are available * @param timeout - Timeout in seconds, 0 to block indefinitely @@ -3037,6 +3063,30 @@ export default { * @see https://redis.io/commands/lmove/ */ lMove: LMOVE, + /** + * Moves multiple elements from one list to another + * + * @param source - The source list key + * @param destination - The destination list key + * @param sourceSide - The side to pop from (LEFT or RIGHT) + * @param destinationSide - The side to push to (LEFT or RIGHT) + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/lmovem/ + * @since 8.10 + */ + LMOVEM, + /** + * Moves multiple elements from one list to another + * + * @param source - The source list key + * @param destination - The destination list key + * @param sourceSide - The side to pop from (LEFT or RIGHT) + * @param destinationSide - The side to push to (LEFT or RIGHT) + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/lmovem/ + * @since 8.10 + */ + lMoveM: LMOVEM, /** * Constructs the LMPOP command *