diff --git a/docs/src/content/docs/commands/ZRANK.md b/docs/src/content/docs/commands/ZRANK.md new file mode 100644 index 0000000000..93a2e3e887 --- /dev/null +++ b/docs/src/content/docs/commands/ZRANK.md @@ -0,0 +1,81 @@ +--- +title: ZRANK +description: The `ZRANK` command in DiceDB is used to determine the rank of a member in a sorted set. It returns the position of a member in the sorted set, with the lowest score having rank 0. +--- + +## Syntax + +```bash +ZRANK key member [WITHSCORE] +``` + +## Parameters + +| Parameter | Description | Type | Required | +|-------------|-----------------------------------------------------------------------------|--------|----------| +| `key` | The key of the sorted set. | String | Yes | +| `member` | The member whose rank is to be determined. | String | Yes | +| `WITHSCORE` | If provided, the command will also return the score of the member. | String | No | + +## Return values + +| Condition | Return Value | +|------------------------------------------------|---------------------------------------------------| +| If member exists in the sorted set | Integer (rank of the member) | +| If `WITHSCORE` option is used | Array (rank and score of the member) | +| If member or key does not exist | `nil` | + +## Behaviour + +- The `ZRANK` command searches for the specified member within the sorted set associated with the given key. +- If the key exists and is a sorted set, the command returns the rank of the member based on its score, with the lowest score having rank 0. +- If the `WITHSCORE` option is provided, the command returns both the rank and the score of the member as an array. +- If the key does not exist or the member is not found in the sorted set, the command returns `nil`. + +## Errors + +1. `Wrong type of value or key`: + - Error Message: `(error) WRONGTYPE Operation against a key holding the wrong kind of value` + - Occurs when the specified key exists but is not associated with a sorted set. + +2. `Invalid syntax or number of arguments`: + - Error Message: `(error) ERR wrong number of arguments for 'zrank' command` + - Occurs if the command is issued with an incorrect number of arguments. + +3. `Invalid option`: + - Error Message: `(error) ERR syntax error` + - Occurs if an invalid option is provided. + +## Example Usage + +### Basic Usage + +Retrieve the rank of `member1` in the sorted set `myzset`: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 2 member2 3 member3 +(integer) 3 +127.0.0.1:7379> ZRANK myzset member1 +(integer) 0 +127.0.0.1:7379> ZRANK myzset member3 +(integer) 2 +``` + +### Using WITHSCORE Option + +Retrieve both the rank and the score of `member2` in the sorted set `myzset`: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 2 member2 +(integer) 2 +127.0.0.1:7379> ZRANK myzset member2 WITHSCORE +(integer) [1, 2] +``` + +## Best Practices + +- Use `ZRANK` in combination with `ZADD` and `ZSCORE` for efficient management of sorted sets and leaderboards. + +## Notes + +- This command is particularly useful for implementing leaderboards, pagination in ranked lists, and analytics on data distribution. diff --git a/integration_tests/commands/async/zset_test.go b/integration_tests/commands/async/zset_test.go deleted file mode 100644 index 0eaa1dba61..0000000000 --- a/integration_tests/commands/async/zset_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package async - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -func TestZADD(t *testing.T) { - conn := getLocalConnection() - defer conn.Close() - - FireCommand(conn, "DEL key") - defer FireCommand(conn, "DEL key") - - testCases := []TestCase{ - { - name: "ZADD with two new members", - commands: []string{"ZADD key 1 member1 2 member2"}, - expected: []interface{}{int64(2)}, - }, - { - name: "ZADD with three new members", - commands: []string{"ZADD key 3 member3 4 member4 5 member5"}, - expected: []interface{}{int64(3)}, - }, - { - name: "ZADD with existing members", - commands: []string{"ZADD key 1 member1 2 member2 3 member3 4 member4 5 member5"}, - expected: []interface{}{int64(0)}, - }, - { - name: "ZADD with mixed new and existing members", - commands: []string{"ZADD key 1 member1 2 member2 3 member3 4 member4 5 member5 6 member6"}, - expected: []interface{}{int64(1)}, - }, - { - name: "ZADD without any members", - commands: []string{"ZADD key 1"}, - expected: []interface{}{"ERR wrong number of arguments for 'zadd' command"}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - for i, cmd := range tc.commands { - result := FireCommand(conn, cmd) - assert.DeepEqual(t, tc.expected[i], result) - } - }) - } -} - -func TestZRANGE(t *testing.T) { - conn := getLocalConnection() - defer conn.Close() - - FireCommand(conn, "DEL key") - defer FireCommand(conn, "DEL key") - - FireCommand(conn, "ZADD key 1 member1 2 member2 3 member3 4 member4 5 member5 6 member6") - defer FireCommand(conn, "DEL key") - - testCases := []TestCase{ - { - name: "ZRANGE with mixed indices", - commands: []string{"ZRANGE key 0 -1"}, - expected: []interface{}{[]interface{}{"member1", "member2", "member3", "member4", "member5", "member6"}}, - }, - { - name: "ZRANGE with positive indices #1", - commands: []string{"ZRANGE key 0 2"}, - expected: []interface{}{[]interface{}{"member1", "member2", "member3"}}, - }, - { - name: "ZRANGE with positive indices #2", - commands: []string{"ZRANGE key 2 4"}, - expected: []interface{}{[]interface{}{"member3", "member4", "member5"}}, - }, - { - name: "ZRANGE with all positive indices", - commands: []string{"ZRANGE key 0 10"}, - expected: []interface{}{[]interface{}{"member1", "member2", "member3", "member4", "member5", "member6"}}, - }, - { - name: "ZRANGE with out of bound indices", - commands: []string{"ZRANGE key 10 20"}, - expected: []interface{}{[]interface{}{}}, - }, - { - name: "ZRANGE with positive indices and scores", - commands: []string{"ZRANGE key 0 10 WITHSCORES"}, - expected: []interface{}{[]interface{}{"member1", "1", "member2", "2", "member3", "3", "member4", "4", "member5", "5", "member6", "6"}}, - }, - { - name: "ZRANGE with positive indices and scores in reverse order", - commands: []string{"ZRANGE key 0 10 REV WITHSCORES"}, - expected: []interface{}{[]interface{}{"member6", "6", "member5", "5", "member4", "4", "member3", "3", "member2", "2", "member1", "1"}}, - }, - { - name: "ZRANGE with negative indices", - commands: []string{"ZRANGE key -1 -1"}, - expected: []interface{}{[]interface{}{"member6"}}, - }, - { - name: "ZRANGE with negative indices and scores", - commands: []string{"ZRANGE key -8 -5 WITHSCORES"}, - expected: []interface{}{[]interface{}{"member1", "1", "member2", "2"}}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - for i, cmd := range tc.commands { - result := FireCommand(conn, cmd) - assert.DeepEqual(t, tc.expected[i], result) - } - }) - } -} - -func TestZPOPMIN(t *testing.T) { - conn := getLocalConnection() - defer conn.Close() - - testCases := []TestCase{ - { - name: "ZPOPMIN on non-existing key with/without count argument", - commands: []string{"ZPOPMIN NON_EXISTENT_KEY"}, - expected: []interface{}{[]interface{}{}}, - }, - { - name: "ZPOPMIN with wrong type of key with/without count argument", - commands: []string{"SET stringkey string_value", "ZPOPMIN stringkey", "DEL stringkey"}, - expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value", int64(1)}, - }, - { - name: "ZPOPMIN on existing key (without count argument)", - commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{"member1", "1"}, int64(1)}, - }, - { - name: "ZPOPMIN with normal count argument", - commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 2", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2"}, int64(1)}, - }, - { - name: "ZPOPMIN with count argument but multiple members have the same score", - commands: []string{"ZADD myzset 1 member1 1 member2 1 member3", "ZPOPMIN myzset 2", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "1"}, int64(1)}, - }, - { - name: "ZPOPMIN with negative count argument", - commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset -1", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{}, int64(1)}, - }, - { - name: "ZPOPMIN with invalid count argument", - commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset INCORRECT_COUNT_ARGUMENT", "DEL myzset"}, - expected: []interface{}{int64(1), "ERR value is not an integer or out of range", int64(1)}, - }, - { - name: "ZPOPMIN with count argument greater than length of sorted set", - commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 10", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2", "member3", "3"}, int64(1)}, - }, - { - name: "ZPOPMIN on empty sorted set", - commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset 1", "ZPOPMIN myzset", "DEL myzset"}, - expected: []interface{}{int64(1), []interface{}{"member1", "1"}, []interface{}{}, int64(1)}, - }, - { - name: "ZPOPMIN with floating-point scores", - commands: []string{"ZADD myzset 1.5 member1 2.7 member2 3.8 member3", "ZPOPMIN myzset", "DEL myzset"}, - expected: []interface{}{int64(3), []interface{}{"member1", "1.5"}, int64(1)}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - for i, cmd := range tc.commands { - result := FireCommand(conn, cmd) - assert.DeepEqual(t, tc.expected[i], result) - } - }) - } -} diff --git a/integration_tests/commands/http/zrank_test.go b/integration_tests/commands/http/zrank_test.go new file mode 100644 index 0000000000..87166dd1ab --- /dev/null +++ b/integration_tests/commands/http/zrank_test.go @@ -0,0 +1,142 @@ +package http + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZRANK(t *testing.T) { + exec := NewHTTPCommandExecutor() + + // Clean up before and after tests + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "myset"}}) + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "myset"}}) + + // Initialize the sorted set with members and their scores + exec.FireCommand(HTTPCommand{ + Command: "ZADD", + Body: map[string]interface{}{ + "key": "myset", + "key_values": map[string]interface{}{ + "1": "member1", + "2": "member2", + "3": "member3", + "4": "member4", + "5": "member5", + }, + }, + }) + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + }{ + { + name: "ZRANK of existing member", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + "value": "member1", + }, + }, + }, + expected: []interface{}{float64(0)}, + }, + { + name: "ZRANK of non-existing member", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + "value": "member6", + }, + }, + }, + expected: []interface{}{nil}, + }, + { + name: "ZRANK with WITHSCORE option for existing member", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + "value": "member3", + "withscore": true, + }, + }, + }, + expected: []interface{}{[]interface{}{float64(2), float64(3)}}, + }, + { + name: "ZRANK with WITHSCORE option for non-existing member", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + "value": "member6", + "withscore": true, + }, + }, + }, + expected: []interface{}{nil}, + }, + { + name: "ZRANK on non-existing key", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "nonexistingset", + "value": "member1", + }, + }, + }, + expected: []interface{}{nil}, + }, + { + name: "ZRANK with wrong number of arguments", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + }, + }, + }, + expected: []interface{}{"ERR wrong number of arguments for 'zrank' command"}, + }, + { + name: "ZRANK with invalid option", + commands: []HTTPCommand{ + { + Command: "ZRANK", + Body: map[string]interface{}{ + "key": "myset", + "values": []interface{}{ + "member1", + "invalidoption", + }, + }, + }, + }, + expected: []interface{}{"ERR syntax error"}, + }, + } + + for _, tc := range testCases { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + assert.Equal(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/resp/zrank_test.go b/integration_tests/commands/resp/zrank_test.go new file mode 100644 index 0000000000..bc95295ae6 --- /dev/null +++ b/integration_tests/commands/resp/zrank_test.go @@ -0,0 +1,65 @@ +package resp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZRANK(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + FireCommand(conn, "DEL key") + defer FireCommand(conn, "DEL key") + + // Initialize the sorted set with members and their scores + FireCommand(conn, "ZADD key 1 member1 2 member2 3 member3 4 member4 5 member5") + + testCases := []TestCase{ + { + name: "ZRANK of existing member", + commands: []string{"ZRANK key member1"}, + expected: []interface{}{int64(0)}, + }, + { + name: "ZRANK of non-existing member", + commands: []string{"ZRANK key member6"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK with WITHSCORE option for existing member", + commands: []string{"ZRANK key member3 WITHSCORE"}, + expected: []interface{}{[]interface{}{int64(2), int64(3)}}, + }, + { + name: "ZRANK with WITHSCORE option for non-existing member", + commands: []string{"ZRANK key member6 WITHSCORE"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK on non-existing key", + commands: []string{"ZRANK nonexisting member1"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK with wrong number of arguments", + commands: []string{"ZRANK key"}, + expected: []interface{}{"ERR wrong number of arguments for 'zrank' command"}, + }, + { + name: "ZRANK with invalid option", + commands: []string{"ZRANK key member1 INVALID_OPTION"}, + expected: []interface{}{"ERR syntax error"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result := FireCommand(conn, cmd) + assert.Equal(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/websocket/zrank_test.go b/integration_tests/commands/websocket/zrank_test.go new file mode 100644 index 0000000000..31b1218add --- /dev/null +++ b/integration_tests/commands/websocket/zrank_test.go @@ -0,0 +1,73 @@ +package websocket + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZRANK(t *testing.T) { + exec := NewWebsocketCommandExecutor() + conn := exec.ConnectToServer() + + // Clean up before and after tests + DeleteKey(t, conn, exec, "myset") + defer func() { + resp, err := exec.FireCommandAndReadResponse(conn, "DEL myset") + assert.Nil(t, err) + assert.Equal(t, float64(1), resp, "Cleanup failed") + }() + + // Initialize the sorted set with members and their scores + _, err := exec.FireCommandAndReadResponse(conn, "ZADD myset 1 member1 2 member2 3 member3 4 member4 5 member5") + assert.Nil(t, err) + + testCases := []TestCase{ + { + name: "ZRANK of existing member", + commands: []string{"ZRANK myset member1"}, + expected: []interface{}{float64(0)}, + }, + { + name: "ZRANK of non-existing member", + commands: []string{"ZRANK myset member6"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK with WITHSCORE option for existing member", + commands: []string{"ZRANK myset member3 WITHSCORE"}, + expected: []interface{}{[]interface{}{float64(2), float64(3)}}, + }, + { + name: "ZRANK with WITHSCORE option for non-existing member", + commands: []string{"ZRANK myset member6 WITHSCORE"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK on non-existing myset", + commands: []string{"ZRANK nonexisting member1"}, + expected: []interface{}{"(nil)"}, + }, + { + name: "ZRANK with wrong number of arguments", + commands: []string{"ZRANK myset"}, + expected: []interface{}{"ERR wrong number of arguments for 'zrank' command"}, + }, + { + name: "ZRANK with invalid option", + commands: []string{"ZRANK myset member1 INVALID_OPTION"}, + expected: []interface{}{"ERR syntax error"}, + }, + } + + for _, tc := range testCases { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, err := exec.FireCommandAndReadResponse(conn, cmd) + assert.Nil(t, err) + assert.Equal(t, tc.expected[i], result) + } + }) + } +} diff --git a/internal/eval/commands.go b/internal/eval/commands.go index 236fd45f87..44a79cb5a3 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -1055,6 +1055,17 @@ var ( IsMigrated: true, NewEval: evalZPOPMIN, } + zrankCmdMeta = DiceCmdMeta{ + Name: "ZRANK", + Info: `ZRANK key member [WITHSCORE] + Returns the rank of member in the sorted set stored at key, with the scores ordered from low to high. + The rank (or index) is 0-based, which means that the member with the lowest score has rank 0. + The optional WITHSCORE argument supplements the command's reply with the score of the element returned.`, + Arity: -2, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalZRANK, + } bitfieldCmdMeta = DiceCmdMeta{ Name: "BITFIELD", Info: `The command treats a string as an array of bits as well as bytearray data structure, @@ -1246,6 +1257,7 @@ func init() { DiceCmds["ZADD"] = zaddCmdMeta DiceCmds["ZRANGE"] = zrangeCmdMeta DiceCmds["ZPOPMIN"] = zpopminCmdMeta + DiceCmds["ZRANK"] = zrankCmdMeta DiceCmds["JSON.STRAPPEND"] = jsonstrappendCmdMeta } diff --git a/internal/eval/constants.go b/internal/eval/constants.go index a9dacabb83..1f04158720 100644 --- a/internal/eval/constants.go +++ b/internal/eval/constants.go @@ -29,6 +29,7 @@ const ( null string = "null" WithValues string = "WITHVALUES" WithScores string = "WITHSCORES" + WithScore string = "WITHSCORE" REV string = "REV" GET string = "GET" SET string = "SET" diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 285604b9b7..ef13a9e19f 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -110,6 +110,7 @@ func TestEval(t *testing.T) { testEvalZADD(t, store) testEvalZRANGE(t, store) testEvalZPOPMIN(t, store) + testEvalZRANK(t, store) testEvalHVALS(t, store) testEvalBitField(t, store) testEvalHINCRBYFLOAT(t, store) @@ -5950,6 +5951,112 @@ func BenchmarkEvalZPOPMIN(b *testing.B) { } } +func testEvalZRANK(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "ZRANK with non-existing key": { + input: []string{"non_existing_key", "member"}, + migratedOutput: EvalResponse{ + Result: clientio.NIL, + Error: nil, + }, + }, + "ZRANK with existing member": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "member2"}, + migratedOutput: EvalResponse{ + Result: int64(1), + Error: nil, + }, + }, + "ZRANK with non-existing member": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "non_existing_member"}, + migratedOutput: EvalResponse{ + Result: clientio.NIL, + Error: nil, + }, + }, + "ZRANK with WITHSCORE option": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "member2", "WITHSCORE"}, + migratedOutput: EvalResponse{ + Result: []interface{}{int64(1), float64(2)}, + Error: nil, + }, + }, + "ZRANK with invalid option": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "member2", "INVALID_OPTION"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrSyntax, + }, + }, + "ZRANK with multiple members having same score": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "1", "member2", "1", "member3"}, store) + }, + input: []string{"myzset", "member3"}, + migratedOutput: EvalResponse{ + Result: int64(2), + Error: nil, + }, + }, + "ZRANK with non-integer scores": { + setup: func() { + evalZADD([]string{"myzset", "1.5", "member1", "2.5", "member2"}, store) + }, + input: []string{"myzset", "member2"}, + migratedOutput: EvalResponse{ + Result: int64(1), + Error: nil, + }, + }, + "ZRANK with too many arguments": { + input: []string{"myzset", "member", "WITHSCORES", "extra"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("ZRANK"), + }, + }, + } + + runMigratedEvalTests(t, tests, evalZRANK, store) +} + +func BenchmarkEvalZRANK(b *testing.B) { + store := dstore.NewStore(nil, nil) + + // Set up initial sorted set + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + + benchmarks := []struct { + name string + input []string + withScore bool + }{ + {"ZRANK existing member", []string{"myzset", "member3"}, false}, + {"ZRANK non-existing member", []string{"myzset", "nonexistent"}, false}, + {"ZRANK with WITHSCORE", []string{"myzset", "member2", "WITHSCORE"}, true}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + evalZRANK(bm.input, store) + } + }) + } +} + func testEvalBitField(t *testing.T, store *dstore.Store) { testCases := map[string]evalTestCase{ "BITFIELD signed SET": { diff --git a/internal/eval/sortedset/sorted_set.go b/internal/eval/sortedset/sorted_set.go index ee38dc72b7..df9d307b3b 100644 --- a/internal/eval/sortedset/sorted_set.go +++ b/internal/eval/sortedset/sorted_set.go @@ -68,6 +68,28 @@ func (ss *Set) Upsert(score float64, member string) bool { return !exists } +func (ss *Set) RankWithScore(member string, reverse bool) (rank int64, score float64) { + score, exists := ss.memberMap[member] + if !exists { + return -1, 0 + } + + rank = int64(0) + ss.tree.Ascend(func(item btree.Item) bool { + if item.(*Item).Member == member { + return false + } + rank++ + return true + }) + + if reverse { + rank = int64(len(ss.memberMap)) - rank - 1 + } + + return +} + // Remove removes a member from the and returns true if the member was removed, false if it did not exist. func (ss *Set) Remove(member string) bool { score, exists := ss.memberMap[member] diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 0cfc6ad2fc..13849a3d01 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -453,6 +453,69 @@ func evalZRANGE(args []string, store *dstore.Store) *EvalResponse { } } +// evalZRANK returns the rank of the member in the sorted set stored at key. +// The rank (or index) is 0-based, which means that the member with the lowest score has rank 0. +// If the 'WITHSCORE' option is specified, it returns both the rank and the score of the member. +// Returns nil if the key does not exist or the member is not a member of the sorted set. +func evalZRANK(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 2 || len(args) > 3 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("ZRANK"), + } + } + + key := args[0] + member := args[1] + withScore := false + + if len(args) == 3 { + if !strings.EqualFold(args[2], WithScore) { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrSyntax, + } + } + withScore = true + } + + obj := store.Get(key) + if obj == nil { + return &EvalResponse{ + Result: clientio.NIL, + Error: nil, + } + } + + sortedSet, err := sortedset.FromObject(obj) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + rank, score := sortedSet.RankWithScore(member, false) + if rank == -1 { + return &EvalResponse{ + Result: clientio.NIL, + Error: nil, + } + } + + if withScore { + return &EvalResponse{ + Result: []interface{}{rank, score}, + Error: nil, + } + } + + return &EvalResponse{ + Result: rank, + Error: nil, + } +} + // evalJSONCLEAR Clear container values (arrays/objects) and set numeric values to 0, // Already cleared values are ignored for empty containers and zero numbers // args must contain at least the key; (path unused in this implementation) diff --git a/internal/server/cmd_meta.go b/internal/server/cmd_meta.go index e55df150c9..3acbd87089 100644 --- a/internal/server/cmd_meta.go +++ b/internal/server/cmd_meta.go @@ -74,6 +74,10 @@ var ( Cmd: "ZPOPMIN", CmdType: SingleShard, } + zrankCmdMeta = CmdsMeta{ + Cmd: "ZRANK", + CmdType: SingleShard, + } pfaddCmdMeta = CmdsMeta{ Cmd: "PFADD", CmdType: SingleShard, @@ -135,6 +139,7 @@ func init() { WorkerCmdsMeta["JSON.OBJLEN"] = jsonobjlenCmdMeta WorkerCmdsMeta["ZADD"] = zaddCmdMeta WorkerCmdsMeta["ZRANGE"] = zrangeCmdMeta + WorkerCmdsMeta["ZRANK"] = zrankCmdMeta WorkerCmdsMeta["PFADD"] = pfaddCmdMeta WorkerCmdsMeta["ZPOPMIN"] = zpopminCmdMeta WorkerCmdsMeta["PFCOUNT"] = pfcountCmdMeta diff --git a/internal/worker/cmd_meta.go b/internal/worker/cmd_meta.go index 6f45243f8e..d2b9d6a944 100644 --- a/internal/worker/cmd_meta.go +++ b/internal/worker/cmd_meta.go @@ -200,6 +200,9 @@ var CommandsMeta = map[string]CmdMeta{ CmdZAdd: { CmdType: SingleShard, }, + CmdZRank: { + CmdType: SingleShard, + }, CmdZRange: { CmdType: SingleShard, },