diff --git a/CHANGELOG.md b/CHANGELOG.md index e583955a3d..75aa8272ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Python: Added HKEYS command ([#1228](https://github.com/aws/glide-for-redis/pull/1228)) * Python: Added ZREMRANGEBYSCORE command ([#1151](https://github.com/aws/glide-for-redis/pull/1151)) * Node: Added SPOP, SPOPCOUNT commands. ([#1117](https://github.com/aws/glide-for-redis/pull/1117)) +* Node: Added ZRANGE command ([#1115](https://github.com/aws/glide-for-redis/pull/1115)) * Python: Added RENAME command ([#1252](https://github.com/aws/glide-for-redis/pull/1252)) #### Fixes diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index c7b7854694..62ecfa1b95 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -12,7 +12,10 @@ import * as net from "net"; import { Buffer, BufferWriter, Reader, Writer } from "protobufjs"; import { ExpireOptions, - ScoreLimit, + RangeByIndex, + RangeByLex, + RangeByScore, + ScoreBoundary, SetOptions, StreamAddOptions, StreamReadOptions, @@ -75,6 +78,8 @@ import { createZcount, createZpopmax, createZpopmin, + createZrange, + createZrangeWithScores, createZrank, createZrem, createZremRangeByRank, @@ -1669,12 +1674,94 @@ export class BaseClient { */ public zcount( key: string, - minScore: ScoreLimit, - maxScore: ScoreLimit, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, ): Promise { return this.createWritePromise(createZcount(key, minScore, maxScore)); } + /** Returns the specified range of elements in the sorted set stored at `key`. + * ZRANGE can perform different types of range queries: by index (rank), by the score, or by lexicographical order. + * + * See https://redis.io/commands/zrange/ for more details. + * To get the elements with their scores, see `zrangeWithScores`. + * + * @param key - The key of the sorted set. + * @param rangeQuery - The range query object representing the type of range query to perform. + * For range queries by index (rank), use RangeByIndex. + * For range queries by lexicographical order, use RangeByLex. + * For range queries by score, use RangeByScore. + * @param reverse - If true, reverses the sorted set, with index 0 as the element with the highest score. + * @returns A list of elements within the specified range. + * If `key` does not exist, it is treated as an empty sorted set, and the command returns an empty array. + * + * @example + * ```typescript + * // Example usage of zrange method to retrieve all members of a sorted set in ascending order + * const result = await client.zrange("my_sorted_set", { start: 0, stop: -1 }); + * console.log(result1); // Output: ['member1', 'member2', 'member3'] - Returns all members in ascending order. + * + * @example + * // Example usage of zrange method to retrieve members within a score range in ascending order + * const result = await client.zrange("my_sorted_set", { + * start: "negativeInfinity", + * stop: { value: 3, isInclusive: false }, + * type: "byScore", + * }); + * console.log(result); // Output: ['member2', 'member3'] - Returns members with scores within the range of negative infinity to 3, in ascending order. + * ``` + */ + public zrange( + key: string, + rangeQuery: RangeByScore | RangeByLex | RangeByIndex, + reverse: boolean = false, + ): Promise { + return this.createWritePromise(createZrange(key, rangeQuery, reverse)); + } + + /** Returns the specified range of elements with their scores in the sorted set stored at `key`. + * Similar to ZRANGE but with a WITHSCORE flag. + * See https://redis.io/commands/zrange/ for more details. + * + * @param key - The key of the sorted set. + * @param rangeQuery - The range query object representing the type of range query to perform. + * For range queries by index (rank), use RangeByIndex. + * For range queries by lexicographical order, use RangeByLex. + * For range queries by score, use RangeByScore. + * @param reverse - If true, reverses the sorted set, with index 0 as the element with the highest score. + * @returns A map of elements and their scores within the specified range. + * If `key` does not exist, it is treated as an empty sorted set, and the command returns an empty map. + * + * @example + * ```typescript + * // Example usage of zrangeWithScores method to retrieve members within a score range with their scores + * const result = await client.zrangeWithScores("my_sorted_set", { + * start: { value: 10, isInclusive: false }, + * stop: { value: 20, isInclusive: false }, + * type: "byScore", + * }); + * console.log(result); // Output: {'member1': 10.5, 'member2': 15.2} - Returns members with scores between 10 and 20 with their scores. + * + * @example + * // Example usage of zrangeWithScores method to retrieve members within a score range with their scores + * const result = await client.zrangeWithScores("my_sorted_set", { + * start: "negativeInfinity", + * stop: { value: 3, isInclusive: false }, + * type: "byScore", + * }); + * console.log(result); // Output: {'member4': -2.0, 'member7': 1.5} - Returns members with scores within the range of negative infinity to 3, with their scores. + * ``` + */ + public zrangeWithScores( + key: string, + rangeQuery: RangeByScore | RangeByLex | RangeByIndex, + reverse: boolean = false, + ): Promise> { + return this.createWritePromise( + createZrangeWithScores(key, rangeQuery, reverse), + ); + } + /** Returns the length of the string value stored at `key`. * See https://redis.io/commands/strlen/ for more details. * @@ -1876,8 +1963,8 @@ export class BaseClient { */ public zremRangeByScore( key: string, - minScore: ScoreLimit, - maxScore: ScoreLimit, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, ): Promise { return this.createWritePromise( createZremRangeByScore(key, minScore, maxScore), diff --git a/node/src/Commands.ts b/node/src/Commands.ts index bbdb756b49..47f88c69eb 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -811,42 +811,181 @@ export function createZscore( return createCommand(RequestType.ZScore, [key, member]); } -export type ScoreLimit = +export type ScoreBoundary = + /** + * Positive infinity bound for sorted set. + */ | `positiveInfinity` + /** + * Negative infinity bound for sorted set. + */ | `negativeInfinity` + /** + * Represents a specific numeric score boundary in a sorted set. + */ | { - bound: number; + /** + * The score value. + */ + value: T; + /** + * Whether the score value is inclusive. Defaults to True. + */ isInclusive?: boolean; }; -function getScoreLimitArg(score: ScoreLimit): string { +/** + * Represents a range by index (rank) in a sorted set. + * The `start` and `stop` arguments represent zero-based indexes. + */ +export type RangeByIndex = { + /** + * The start index of the range. + */ + start: number; + /** + * The stop index of the range. + */ + stop: number; +}; + +/** + * Represents a range by score or a range by lex in a sorted set. + * The `start` and `stop` arguments represent score boundaries. + */ +type SortedSetRange = { + /** + * The start boundary. + */ + start: ScoreBoundary; + /** + * The stop boundary. + */ + stop: ScoreBoundary; + /** + * The limit argument for a range query. + * Represents a limit argument for a range query in a sorted set to + * be used in [ZRANGE](https://redis.io/commands/zrange) command. + * + * The optional LIMIT argument can be used to obtain a sub-range from the matching elements + * (similar to SELECT LIMIT offset, count in SQL). + */ + limit?: { + /** + * The offset from the start of the range. + */ + offset: number; + /** + * The number of elements to include in the range. + * A negative count returns all elements from the offset. + */ + count: number; + }; +}; + +export type RangeByScore = SortedSetRange & { type: "byScore" }; +export type RangeByLex = SortedSetRange & { type: "byLex" }; + +/** + * Returns a string representation of a score boundary in Redis protocol format. + * @param score - The score boundary object containing value and inclusivity information. + * @param isLex - Indicates whether to return lexical representation for positive/negative infinity. + * @returns A string representation of the score boundary in Redis protocol format. + */ +function getScoreBoundaryArg( + score: ScoreBoundary | ScoreBoundary, + isLex: boolean = false, +): string { if (score == "positiveInfinity") { - return "+inf"; + return isLex ? "+" : "+inf"; } else if (score == "negativeInfinity") { - return "-inf"; + return isLex ? "-" : "-inf"; + } + + if (score.isInclusive == false) { + return "(" + score.value.toString(); } - const value = - score.isInclusive == false - ? "(" + score.bound.toString() - : score.bound.toString(); + const value = isLex ? "[" + score.value.toString() : score.value.toString(); return value; } +function createZrangeArgs( + key: string, + rangeQuery: RangeByScore | RangeByLex | RangeByIndex, + reverse: boolean, + withScores: boolean, +): string[] { + const args: string[] = [key]; + + if (typeof rangeQuery.start != "number") { + rangeQuery = rangeQuery as RangeByScore | RangeByLex; + const isLex = rangeQuery.type == "byLex"; + args.push(getScoreBoundaryArg(rangeQuery.start, isLex)); + args.push(getScoreBoundaryArg(rangeQuery.stop, isLex)); + args.push(isLex == true ? "BYLEX" : "BYSCORE"); + } else { + args.push(rangeQuery.start.toString()); + args.push(rangeQuery.stop.toString()); + } + + if (reverse) { + args.push("REV"); + } + + if ("limit" in rangeQuery && rangeQuery.limit !== undefined) { + args.push( + "LIMIT", + String(rangeQuery.limit.offset), + String(rangeQuery.limit.count), + ); + } + + if (withScores) { + args.push("WITHSCORES"); + } + + return args; +} + /** * @internal */ export function createZcount( key: string, - minScore: ScoreLimit, - maxScore: ScoreLimit, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, ): redis_request.Command { const args = [key]; - args.push(getScoreLimitArg(minScore)); - args.push(getScoreLimitArg(maxScore)); + args.push(getScoreBoundaryArg(minScore)); + args.push(getScoreBoundaryArg(maxScore)); return createCommand(RequestType.Zcount, args); } +/** + * @internal + */ +export function createZrange( + key: string, + rangeQuery: RangeByIndex | RangeByScore | RangeByLex, + reverse: boolean = false, +): redis_request.Command { + const args = createZrangeArgs(key, rangeQuery, reverse, false); + return createCommand(RequestType.Zrange, args); +} + +/** + * @internal + */ +export function createZrangeWithScores( + key: string, + rangeQuery: RangeByIndex | RangeByScore | RangeByLex, + reverse: boolean = false, +): redis_request.Command { + const args = createZrangeArgs(key, rangeQuery, reverse, true); + return createCommand(RequestType.Zrange, args); +} + /** * @internal */ @@ -927,12 +1066,12 @@ export function createZremRangeByRank( */ export function createZremRangeByScore( key: string, - minScore: ScoreLimit, - maxScore: ScoreLimit, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, ): redis_request.Command { const args = [key]; - args.push(getScoreLimitArg(minScore)); - args.push(getScoreLimitArg(maxScore)); + args.push(getScoreBoundaryArg(minScore)); + args.push(getScoreBoundaryArg(maxScore)); return createCommand(RequestType.ZRemRangeByScore, args); } diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index c6b90c912a..b2fbdef317 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -5,7 +5,10 @@ import { ExpireOptions, InfoOptions, - ScoreLimit, + RangeByIndex, + RangeByLex, + RangeByScore, + ScoreBoundary, SetOptions, StreamAddOptions, StreamReadOptions, @@ -80,6 +83,8 @@ import { createZcount, createZpopmax, createZpopmin, + createZrange, + createZrangeWithScores, createZrank, createZrem, createZremRangeByRank, @@ -925,10 +930,62 @@ export class BaseTransaction> { * If `key` does not exist, it is treated as an empty sorted set, and the command returns 0. * If `minScore` is greater than `maxScore`, 0 is returned. */ - public zcount(key: string, minScore: ScoreLimit, maxScore: ScoreLimit): T { + public zcount( + key: string, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, + ): T { return this.addAndReturn(createZcount(key, minScore, maxScore)); } + /** Returns the specified range of elements in the sorted set stored at `key`. + * ZRANGE can perform different types of range queries: by index (rank), by the score, or by lexicographical order. + * + * See https://redis.io/commands/zrange/ for more details. + * To get the elements with their scores, see `zrangeWithScores`. + * + * @param key - The key of the sorted set. + * @param rangeQuery - The range query object representing the type of range query to perform. + * For range queries by index (rank), use RangeByIndex. + * For range queries by lexicographical order, use RangeByLex. + * For range queries by score, use RangeByScore. + * @param reverse - If true, reverses the sorted set, with index 0 as the element with the highest score. + * + * Command Response - A list of elements within the specified range. + * If `key` does not exist, it is treated as an empty sorted set, and the command returns an empty array. + */ + public zrange( + key: string, + rangeQuery: RangeByScore | RangeByLex | RangeByIndex, + reverse: boolean = false, + ): T { + return this.addAndReturn(createZrange(key, rangeQuery, reverse)); + } + + /** Returns the specified range of elements with their scores in the sorted set stored at `key`. + * Similar to ZRANGE but with a WITHSCORE flag. + * See https://redis.io/commands/zrange/ for more details. + * + * @param key - The key of the sorted set. + * @param rangeQuery - The range query object representing the type of range query to perform. + * For range queries by index (rank), use RangeByIndex. + * For range queries by lexicographical order, use RangeByLex. + * For range queries by score, use RangeByScore. + * @param reverse - If true, reverses the sorted set, with index 0 as the element with the highest score. + * + * Command Response - A map of elements and their scores within the specified range. + * If `key` does not exist, it is treated as an empty sorted set, and the command returns an empty map. + */ + public zrangeWithScores( + key: string, + rangeQuery: RangeByScore | RangeByLex | RangeByIndex, + reverse: boolean = false, + ): T { + return this.addAndReturn( + createZrangeWithScores(key, rangeQuery, reverse), + ); + } + /** Returns the string representation of the type of the value stored at `key`. * See https://redis.io/commands/type/ for more details. * @@ -1037,8 +1094,8 @@ export class BaseTransaction> { */ public zremRangeByScore( key: string, - minScore: ScoreLimit, - maxScore: ScoreLimit, + minScore: ScoreBoundary, + maxScore: ScoreBoundary, ): T { return this.addAndReturn( createZremRangeByScore(key, minScore, maxScore), diff --git a/node/tests/SharedTests.ts b/node/tests/SharedTests.ts index 8047052dd8..eedb43a015 100644 --- a/node/tests/SharedTests.ts +++ b/node/tests/SharedTests.ts @@ -1550,25 +1550,25 @@ export function runBaseTests(config: { expect( await client.zcount( key1, - { bound: 1, isInclusive: false }, - { bound: 3, isInclusive: false }, + { value: 1, isInclusive: false }, + { value: 3, isInclusive: false }, ), ).toEqual(1); expect( await client.zcount( key1, - { bound: 1, isInclusive: false }, - { bound: 3 }, + { value: 1, isInclusive: false }, + { value: 3 }, ), ).toEqual(2); expect( await client.zcount(key1, "negativeInfinity", { - bound: 3, + value: 3, }), ).toEqual(3); expect( await client.zcount(key1, "positiveInfinity", { - bound: 3, + value: 3, }), ).toEqual(0); expect( @@ -1588,6 +1588,217 @@ export function runBaseTests(config: { config.timeout, ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `zrange by index test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key = uuidv4(); + const membersScores = { one: 1, two: 2, three: 3 }; + expect(await client.zadd(key, membersScores)).toEqual(3); + + expect(await client.zrange(key, { start: 0, stop: 1 })).toEqual( + ["one", "two"], + ); + expect( + await client.zrangeWithScores(key, { start: 0, stop: -1 }), + ).toEqual({ one: 1.0, two: 2.0, three: 3.0 }); + expect( + await client.zrange(key, { start: 0, stop: 1 }, true), + ).toEqual(["three", "two"]); + expect(await client.zrange(key, { start: 3, stop: 1 })).toEqual( + [], + ); + expect( + await client.zrangeWithScores(key, { start: 3, stop: 1 }), + ).toEqual({}); + }, protocol); + }, + config.timeout, + ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `zrange by score test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key = uuidv4(); + const membersScores = { one: 1, two: 2, three: 3 }; + expect(await client.zadd(key, membersScores)).toEqual(3); + + expect( + await client.zrange(key, { + start: "negativeInfinity", + stop: { value: 3, isInclusive: false }, + type: "byScore", + }), + ).toEqual(["one", "two"]); + + expect( + await client.zrangeWithScores(key, { + start: "negativeInfinity", + stop: "positiveInfinity", + type: "byScore", + }), + ).toEqual({ one: 1.0, two: 2.0, three: 3.0 }); + + expect( + await client.zrange( + key, + { + start: { value: 3, isInclusive: false }, + stop: "negativeInfinity", + type: "byScore", + }, + true, + ), + ).toEqual(["two", "one"]); + + expect( + await client.zrange(key, { + start: "negativeInfinity", + stop: "positiveInfinity", + limit: { offset: 1, count: 2 }, + type: "byScore", + }), + ).toEqual(["two", "three"]); + + expect( + await client.zrange( + key, + { + start: "negativeInfinity", + stop: { value: 3, isInclusive: false }, + type: "byScore", + }, + true, + ), + ).toEqual([]); + + expect( + await client.zrange(key, { + start: "positiveInfinity", + stop: { value: 3, isInclusive: false }, + type: "byScore", + }), + ).toEqual([]); + + expect( + await client.zrangeWithScores( + key, + { + start: "negativeInfinity", + stop: { value: 3, isInclusive: false }, + type: "byScore", + }, + true, + ), + ).toEqual({}); + + expect( + await client.zrangeWithScores(key, { + start: "positiveInfinity", + stop: { value: 3, isInclusive: false }, + type: "byScore", + }), + ).toEqual({}); + }, protocol); + }, + config.timeout, + ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `zrange by lex test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key = uuidv4(); + const membersScores = { a: 1, b: 2, c: 3 }; + expect(await client.zadd(key, membersScores)).toEqual(3); + + expect( + await client.zrange(key, { + start: "negativeInfinity", + stop: { value: "c", isInclusive: false }, + type: "byLex", + }), + ).toEqual(["a", "b"]); + + expect( + await client.zrange(key, { + start: "negativeInfinity", + stop: "positiveInfinity", + limit: { offset: 1, count: 2 }, + type: "byLex", + }), + ).toEqual(["b", "c"]); + + expect( + await client.zrange( + key, + { + start: { value: "c", isInclusive: false }, + stop: "negativeInfinity", + type: "byLex", + }, + true, + ), + ).toEqual(["b", "a"]); + + expect( + await client.zrange( + key, + { + start: "negativeInfinity", + stop: { value: "c", isInclusive: false }, + type: "byLex", + }, + true, + ), + ).toEqual([]); + + expect( + await client.zrange(key, { + start: "positiveInfinity", + stop: { value: "c", isInclusive: false }, + type: "byLex", + }), + ).toEqual([]); + }, protocol); + }, + config.timeout, + ); + + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `zrange different typesn of keys test_%p`, + async (protocol) => { + await runTest(async (client: BaseClient) => { + const key = uuidv4(); + expect( + await client.zrange("nonExistingKey", { + start: 0, + stop: 1, + }), + ).toEqual([]); + + expect( + await client.zrangeWithScores("nonExistingKey", { + start: 0, + stop: 1, + }), + ).toEqual({}); + + expect(await client.set(key, "value")).toEqual("OK"); + + await expect( + client.zrange(key, { start: 0, stop: 1 }), + ).rejects.toThrow(); + + await expect( + client.zrangeWithScores(key, { start: 0, stop: 1 }), + ).rejects.toThrow(); + }, protocol); + }, + config.timeout, + ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( `type test_%p`, async (protocol) => { @@ -1956,15 +2167,15 @@ export function runBaseTests(config: { expect( await client.zremRangeByScore( key, - { bound: 1, isInclusive: false }, - { bound: 2 }, + { value: 1, isInclusive: false }, + { value: 2 }, ), ).toEqual(1); expect( await client.zremRangeByScore( key, - { bound: 1 }, + { value: 1 }, "negativeInfinity", ), ).toEqual(0); diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index 02b279b98a..7288ee541f 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -172,7 +172,11 @@ export async function transactionTest( args.push(4); baseTransaction.zscore(key8, "member2"); args.push(3.0); - baseTransaction.zcount(key8, { bound: 2 }, "positiveInfinity"); + baseTransaction.zrange(key8, { start: 0, stop: -1 }); + args.push(["member2", "member3", "member4", "member5"]); + baseTransaction.zrangeWithScores(key8, { start: 0, stop: -1 }); + args.push({ member2: 3, member3: 3.5, member4: 4, member5: 5 }); + baseTransaction.zcount(key8, { value: 2 }, "positiveInfinity"); args.push(4); baseTransaction.zpopmin(key8); args.push({ member2: 3.0 }); diff --git a/python/python/glide/async_commands/core.py b/python/python/glide/async_commands/core.py index 8265f0b0c8..64d28fcb0c 100644 --- a/python/python/glide/async_commands/core.py +++ b/python/python/glide/async_commands/core.py @@ -1721,7 +1721,7 @@ async def zrange_withscores( Examples: >>> await client.zrange_withscores("my_sorted_set", RangeByScore(ScoreBoundary(10), ScoreBoundary(20))) {'member1': 10.5, 'member2': 15.2} # Returns members with scores between 10 and 20 with their scores. - >>> await client.zrange("my_sorted_set", RangeByScore(start=InfBound.NEG_INF, stop=ScoreBoundary(3))) + >>> await client.zrange_withscores("my_sorted_set", RangeByScore(start=InfBound.NEG_INF, stop=ScoreBoundary(3))) {'member4': -2.0, 'member7': 1.5} # Returns members with with scores within the range of negative infinity to 3, with their scores. """ args = _create_zrange_args(key, range_query, reverse, with_scores=True)