diff --git a/docs/src/content/docs/commands/JSON.ARRINDEX.md b/docs/src/content/docs/commands/JSON.ARRINDEX.md new file mode 100644 index 000000000..1bfaaa16f --- /dev/null +++ b/docs/src/content/docs/commands/JSON.ARRINDEX.md @@ -0,0 +1,153 @@ +--- +title: JSON.ARRINDEX +description: The `JSON.ARRINDEX` command in DiceDB searches for the first occurrence of a JSON value in an array. +--- + +The JSON.ARRINDEX command in DiceDB provides users with the ability to search for the position of a specific element within a JSON array stored at a specified path in a document identified by a given key. By executing this command, users can efficiently locate the index of an element that matches the provided value, enabling streamlined data access and manipulation. + +This functionality is especially useful for developers dealing with large or nested JSON arrays who need to pinpoint the location of particular elements for further processing or validation. With support for specifying paths and flexible querying, JSON.ARRINDEX enhances the capability of managing and navigating complex JSON datasets within DiceDB. + +## Syntax + +```bash +JSON.ARRINDEX key path value [start [stop]] +``` + +## Parameters + +| Parameter | Description | Type | Required | +| --------- | ------------------------------------------------------- | ------ | -------- | +| `key` | The name of the key holding the JSON document. | String | Yes | +| `path` | JSONPath pointing to an array within the JSON document. | String | Yes | +| `value` | The value to search for within the array in JSON document. | Mixed | Yes | +| `start` | Optional index to start the search from. Defaults to 0. | Integer | No | +| `stop` | Optional index to end the search. Defaults to 0. | Integer | No | + + +## Return Values + +| Condition | Return Value | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| Success | ([]integer) `array of integer replies for each path, the first position in the array of each JSON value that matches the path` | +| Wrong number of arguments | Error: `(error) ERR wrong number of arguments for JSON.ARRINDEX command` | +| Key has wrong type | Error: `(error) ERR Existing key has wrong Dice type` | +| Invalid integer arguments | Error: `(error) ERR Couldn't parse as integer` | +| Invalid jsonpath | Error: `(error) ERR Path '' does not exist` | + +## Behaviour + +When the JSON.ARRINDEX command is issued, DiceDB performs the following steps: +1. It checks if argument count is valid or not. If not, an error is thrown. +2. It checks for the validity of value, start(optional) and stop(optional) argument passed. If not, an error is thrown. +3. If the jsonpath passed is invalid, an error is thrown. +4. It checks the type of value passed corresponding to the key. If it is not appropriate, error is thrown. +5. For each value matching the path, it checks if the value is JSON array. +6. If it is JSON array, it finds the first occurrence of the value. +7. If value is found, it adds to array the index where the value was found. Else, -1 is added. +8. If it is not JSON array, (nil) is added to resultant array. +9. The final array is returned. + +## Errors + +1. `Wrong number of arguments`: + + - Error Message: `(error) ERR wrong number of arguments for JSON.ARRINDEX command` + - Raised if the number of arguments are less or more than expected. + +2. `Couldn't parse as integer`: + + - Error Message: `(error) ERR Couldn't parse as integer` + - Raised if the optional start and stop argument are non-integer strings. + - Raised if the value is not a valid integer. + +3. `Key has wrong Dice type`: + + - Error Message: `(error) ERR Existing key has wrong Dice type` + - Raised if the value of the specified key doesn't match the specified value in DiceDb + +4. `Path '' does not exist` + + - Error Message: `(error) ERR Path '' does not exist` + - Raise if the path passed is not valid. + +## Example Usage + +### Basic usage + +Searches for the first occurrence of a JSON value in an array + +```bash +127.0.0.1:7379> JSON.SET a $ '{"name": "Alice", "age": 30, "mobile": [1902, 1903, 1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1903 +1) (integer) 1 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1904 +1) (integer) 2 +``` + +### Finding the occurrence of value starting from given index + +Searches for the first occurrence of a JSON value in an array starting from given index + +```bash +127.0.0.1:7379> JSON.SET b $ '{"name": "Alice", "mobile": [1902, 1903, 1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1902 0 +1) (integer) 0 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1902 1 +1) (integer) -1 +``` + +### Finding the occurrence of value starting from given index and ending at given index (exclusive) + +Searches for the first occurrence of a JSON value in [start, stop) range + +```bash +127.0.0.1:7379> JSON.SET b $ '{"name": "Alice", "mobile": [1902, 1903, 1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1902 0 2 +1) (integer) 0 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1902 1 2 +1) (integer) -1 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1904 0 1 +1) (integer) -1 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1904 0 2 +1) (integer) -1 +127.0.0.1:7379> JSON.ARRINDEX a $.mobile 1904 0 3 +1) (integer) 2 +``` + +### When invalid start and stop argument is passed + +Error When invalid start and stop argument is passed + +```bash +127.0.0.1:7379> JSON.SET b $ '{"name": "Alice", "mobile": [1902, 1903, 1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX b $.mobile iamnotvalidinteger +(error) ERR Couldn't parse as integer +127.0.0.1:7379> JSON.ARRINDEX b $.mobile iamnotvalidinteger iamalsonotvalidinteger +(error) ERR Couldn't parse as integer +``` + +### When the jsonpath is not array object + +Error When jsonpath is not array object + +```bash +127.0.0.1:7379> set b '{"name":"Alice","mobile":[1902,1903,1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX b $.mobile 1902 +(error) Existing key has wrong Redis type +``` + +### When the jsonpath is not valid path + +Error When jsonpath is not valid path + +```bash +127.0.0.1:7379> JSON.SET b $ '{"name": "Alice", "mobile": [1902, 1903, 1904]}' +"OK" +127.0.0.1:7379> JSON.ARRINDEX b $invalid_path 3 +(error) ERR Path '$invalid_path' does not exist +``` \ No newline at end of file diff --git a/integration_tests/commands/http/json_test.go b/integration_tests/commands/http/json_test.go index b0a2700bd..1dcb5c075 100644 --- a/integration_tests/commands/http/json_test.go +++ b/integration_tests/commands/http/json_test.go @@ -81,6 +81,8 @@ func runIntegrationTests(t *testing.T, exec *HTTPCommandExecutor, testCases []In // fmt.Println("hi expected : ", out) // fmt.Println("hi actual :", result) assert.JSONEq(t, out.(string), result.(string)) + case "deep_equal": + assert.ElementsMatch(t, result.([]interface{}), out.([]interface{})) } } }) @@ -1789,3 +1791,177 @@ func TestJsonARRTRIM(t *testing.T) { exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "a"}}) exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "b"}}) } + +func TestJSONARRINDEX(t *testing.T) { + exec := NewHTTPCommandExecutor() + + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "key"}}) + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "key"}}) + + normalArray := `[0,1,2,3,4,3]` + nestedArray := `{"arrays":[{"arr":[1,2,3]},{"arr":[2,3,4]},{"arr":[1]}]}` + nestedArray2 := `{"a":[3],"nested":{"a":{"b":2,"c":1}}}` + + testCases := []IntegrationTestCase{ + { + name: "should return array index when given element is present", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{float64(3)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return -1 when given element is not present", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "value": 10}}, + }, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return array index with start optional param provided", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "values": []string{"3", "4"}}}, + }, + expected: []interface{}{"OK", []interface{}{float64(5)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return array index with start and stop optional param provided", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "values": []string{"4", "4", "5"}}}, + }, + expected: []interface{}{"OK", []interface{}{float64(4)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return -1 with start and stop optional param provided where start > stop", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "values": []string{"3", "2", "1"}}}, + }, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return -1 with start (out of boud) and stop (out of bound) optional param provided", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(normalArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$", "values": []string{"3", "6", "10"}}}, + }, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return list of array indexes for nested json", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$.arrays.*.arr", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{float64(2), float64(1), float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return list of array indexes for multiple json path", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$..arr", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{float64(2), float64(1), float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return array of length 1 for nested json path, with index", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$.arrays[1].arr", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{float64(1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return empty array for nonexistent path in nested json", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$..arr1", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return -1 for each nonexisting value in nested json", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$..arr", "value": 5}}, + }, + expected: []interface{}{"OK", []interface{}{float64(-1), float64(-1), float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return nil for non-array path and -1 for array path if value DNE", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray2)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$..a", "value": 2}}, + }, + expected: []interface{}{"OK", []interface{}{float64(-1), nil}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + { + name: "should return nil for non-array path if value DNE and valid index for array path if value exists", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "key", "path": "$", "json": json.RawMessage(nestedArray2)}}, + {Command: "JSON.ARRINDEX", Body: map[string]interface{}{"key": "key", "path": "$..a", "value": 3}}, + }, + expected: []interface{}{"OK", []interface{}{float64(0), nil}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []HTTPCommand{ + {Command: "DEL", Body: map[string]interface{}{"key": "key"}}, + }, + }, + } + + preTestChecksCommand := HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "key"}} + postTestChecksCommand := HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "key"}} + runIntegrationTests(t, exec, testCases, preTestChecksCommand, postTestChecksCommand) +} \ No newline at end of file diff --git a/integration_tests/commands/resp/json_test.go b/integration_tests/commands/resp/json_test.go index 254ced30d..eab8cba71 100644 --- a/integration_tests/commands/resp/json_test.go +++ b/integration_tests/commands/resp/json_test.go @@ -1679,3 +1679,165 @@ func TestJsonARRTRIM(t *testing.T) { }) } } + +func TestJSONARRINDEX(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + FireCommand(conn, "DEL key") + defer FireCommand(conn, "DEL key") + + normalArray := `[0,1,2,3,4,3]` + nestedArray := `{"arrays":[{"arr":[1,2,3]},{"arr":[2,3,4]},{"arr":[1]}]}` + nestedArray2 := `{"a":[3],"nested":{"a":{"b":2,"c":1}}}` + + tests := []struct { + name string + commands []string + expected []interface{} + assertType []string + }{ + { + name: "should return error if key is not present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex nonExistentKey $ 3"}, + expected: []interface{}{"OK", "ERR could not perform this operation on a key that doesn't exist"}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return error if json path is invalid", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $invalid_path 3"}, + expected: []interface{}{"OK", "ERR Path '$invalid_path' does not exist"}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return error if provided path does not have any data", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $.some_path 3"}, + expected: []interface{}{"OK", []interface{}{}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return error if invalid start index provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 abc"}, + expected: []interface{}{"OK", "ERR Couldn't parse as integer"}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return error if invalid stop index provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 4 abc"}, + expected: []interface{}{"OK", "ERR Couldn't parse as integer"}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return array index when given element is present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3"}, + expected: []interface{}{"OK", []interface{}{int64(3)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return -1 when given element is not present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 10"}, + expected: []interface{}{"OK", []interface{}{int64(-1)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return array index with start optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 4"}, + expected: []interface{}{"OK", []interface{}{int64(5)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return array index with start and stop optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 4 4 5"}, + expected: []interface{}{"OK", []interface{}{int64(4)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return -1 with start and stop optional param provided where start > stop", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 2 1"}, + expected: []interface{}{"OK", []interface{}{int64(-1)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return -1 with start (out of boud) and stop (out of bound) optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 6 10"}, + expected: []interface{}{"OK", []interface{}{int64(-1)}}, + assertType: []string{"equal", "equal"}, + }, + { + name: "should return list of array indexes for nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $.arrays.*.arr 3"}, + expected: []interface{}{"OK", []interface{}{int64(2), int64(1), int64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return list of array indexes for multiple json path", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3"}, + expected: []interface{}{"OK", []interface{}{int64(2), int64(1), int64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return array of length 1 for nested json path, with index", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $.arrays[1].arr 3"}, + expected: []interface{}{"OK", []interface{}{int64(1)}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return empty array for nonexistent path in nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr1 3"}, + expected: []interface{}{"OK", []interface{}{}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return -1 for each nonexisting value in nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 5"}, + expected: []interface{}{"OK", []interface{}{int64(-1), int64(-1), int64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return nil for non-array path and -1 for array path if value DNE", + commands: []string{"json.set key $ " + nestedArray2, "json.arrindex key $..a 2"}, + expected: []interface{}{"OK", []interface{}{int64(-1), "(nil)"}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should return nil for non-array path if value DNE and valid index for array path if value exists", + commands: []string{"json.set key $ " + nestedArray2, "json.arrindex key $..a 3"}, + expected: []interface{}{"OK", []interface{}{int64(0), "(nil)"}}, + assertType: []string{"equal", "deep_equal"}, + }, + { + name: "should handle stop index - 0 which should be last index inclusive", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 1 0", "json.arrindex key $..arr 3 2 0"}, + expected: []interface{}{"OK", []interface{}{int64(2), int64(1), int64(-1)}, []interface{}{int64(2), int64(-1), int64(-1)}}, + assertType: []string{"equal", "deep_equal", "deep_equal"}, + }, + { + name: "should handle stop index - -1 which should be last index exclusive", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 1 -1", "json.arrindex key $..arr 3 2 -1"}, + expected: []interface{}{"OK", []interface{}{int64(-1), int64(1), int64(-1)}, []interface{}{int64(-1), int64(-1), int64(-1)}}, + assertType: []string{"equal", "deep_equal", "deep_equal"}, + }, + { + name: "should handle negative start index", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 -1"}, + expected: []interface{}{"OK", []interface{}{int64(2), int64(-1), int64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + FireCommand(conn, "DEL key") + + for i, cmd := range tc.commands { + result := FireCommand(conn, cmd) + expected := tc.expected[i] + if tc.assertType[i] == "equal" { + assert.Equal(t, result, expected) + } else if tc.assertType[i] == "deep_equal" { + assert.ElementsMatch(t, result.([]interface{}), expected.([]interface{})) + } + } + }) + } +} \ No newline at end of file diff --git a/integration_tests/commands/websocket/json_test.go b/integration_tests/commands/websocket/json_test.go index de7c84fa9..7a216ea84 100644 --- a/integration_tests/commands/websocket/json_test.go +++ b/integration_tests/commands/websocket/json_test.go @@ -74,6 +74,8 @@ func runIntegrationTests(t *testing.T, exec *WebsocketCommandExecutor, conn *web assert.True(t, result.(float64) <= out.(float64) && result.(float64) > 0, "Expected %v to be within 0 to %v", result, out) case "json_equal": assert.JSONEq(t, out.(string), result.(string)) + case "deep_equal": + assert.ElementsMatch(t, result.([]interface{}), out.([]interface{})) } } }) @@ -1235,3 +1237,170 @@ func TestJsonNumMultBy(t *testing.T) { runIntegrationTests(t, exec, conn, testCases, preTestChecksCommand, postTestChecksCommand) } + +func TestJSONARRINDEX(t *testing.T) { + exec := NewWebsocketCommandExecutor() + conn := exec.ConnectToServer() + defer conn.Close() + + preTestChecksCommand := "DEL key" + postTestChecksCommand := "DEL key" + + defer exec.FireCommand(conn, postTestChecksCommand) + + normalArray := `[0,1,2,3,4,3]` + nestedArray := `{"arrays":[{"arr":[1,2,3]},{"arr":[2,3,4]},{"arr":[1]}]}` + nestedArray2 := `{"a":[3],"nested":{"a":{"b":2,"c":1}}}` + + tests := []IntegrationTestCase{ + { + name: "should return error if key is not present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex nonExistentKey $ 3"}, + expected: []interface{}{"OK", "ERR could not perform this operation on a key that doesn't exist"}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return error if json path is invalid", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $invalid_path 3"}, + expected: []interface{}{"OK", "ERR Path '$invalid_path' does not exist"}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return error if provided path does not have any data", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $.some_path 3"}, + expected: []interface{}{"OK", []interface{}{}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return error if invalid start index provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 abc"}, + expected: []interface{}{"OK", "ERR Couldn't parse as integer"}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return error if invalid stop index provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 4 abc"}, + expected: []interface{}{"OK", "ERR Couldn't parse as integer"}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return array index when given element is present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3"}, + expected: []interface{}{"OK", []interface{}{float64(3)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return -1 when given element is not present", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 10"}, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return array index with start optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 4"}, + expected: []interface{}{"OK", []interface{}{float64(5)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return array index with start and stop optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 4 4 5"}, + expected: []interface{}{"OK", []interface{}{float64(4)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return -1 with start and stop optional param provided where start > stop", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 2 1"}, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return -1 with start (out of boud) and stop (out of bound) optional param provided", + commands: []string{"json.set key $ " + normalArray, "json.arrindex key $ 3 6 10"}, + expected: []interface{}{"OK", []interface{}{float64(-1)}}, + assertType: []string{"equal", "equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return list of array indexes for nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $.arrays.*.arr 3"}, + expected: []interface{}{"OK", []interface{}{float64(2), float64(1), float64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return list of array indexes for multiple json path", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3"}, + expected: []interface{}{"OK", []interface{}{float64(2), float64(1), float64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return array of length 1 for nested json path, with index", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $.arrays[1].arr 3"}, + expected: []interface{}{"OK", []interface{}{float64(1)}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return empty array for nonexistent path in nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr1 3"}, + expected: []interface{}{"OK", []interface{}{}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return -1 for each nonexisting value in nested json", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 5"}, + expected: []interface{}{"OK", []interface{}{float64(-1), float64(-1), float64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return nil for non-array path and -1 for array path if value DNE", + commands: []string{"json.set key $ " + nestedArray2, "json.arrindex key $..a 2"}, + expected: []interface{}{"OK", []interface{}{float64(-1), nil}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should return nil for non-array path if value DNE and valid index for array path if value exists", + commands: []string{"json.set key $ " + nestedArray2, "json.arrindex key $..a 3"}, + expected: []interface{}{"OK", []interface{}{float64(0), nil}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should handle stop index - 0 which should be last index inclusive", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 1 0", "json.arrindex key $..arr 3 2 0"}, + expected: []interface{}{"OK", []interface{}{float64(2), float64(1), float64(-1)}, []interface{}{float64(2), float64(-1), float64(-1)}}, + assertType: []string{"equal", "deep_equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should handle stop index - -1 which should be last index exclusive", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 1 -1", "json.arrindex key $..arr 3 2 -1"}, + expected: []interface{}{"OK", []interface{}{float64(-1), float64(1), float64(-1)}, []interface{}{float64(-1), float64(-1), float64(-1)}}, + assertType: []string{"equal", "deep_equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + { + name: "should handle negative start index", + commands: []string{"json.set key $ " + nestedArray, "json.arrindex key $..arr 3 -1"}, + expected: []interface{}{"OK", []interface{}{float64(2), float64(-1), float64(-1)}}, + assertType: []string{"equal", "deep_equal"}, + cleanUp: []string{"DEL key"}, + }, + } + + runIntegrationTests(t, exec, conn, tests, preTestChecksCommand, postTestChecksCommand) +} \ No newline at end of file diff --git a/internal/eval/commands.go b/internal/eval/commands.go index 25bb0b1de..2413d1241 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -603,6 +603,15 @@ var ( IsMigrated: true, Arity: -4, } + jsonArrIndexCmdMeta = DiceCmdMeta{ + Name: "JSON.ARRINDEX", + Info: `JSON.ARRINDEX key path value [start [stop]] + Search for the first occurrence of a JSON value in an array`, + NewEval: evalJSONARRINDEX, + Arity: -3, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + } // Internal command used to spawn request across all shards (works internally with the KEYS command) singleKeysCmdMeta = DiceCmdMeta{ @@ -1466,6 +1475,7 @@ func init() { DiceCmds["CMS.MERGE"] = cmsMergeCmdMeta DiceCmds["LINSERT"] = linsertCmdMeta DiceCmds["LRANGE"] = lrangeCmdMeta + DiceCmds["JSON.ARRINDEX"] = jsonArrIndexCmdMeta DiceCmds["SINGLETOUCH"] = singleTouchCmdMeta DiceCmds["SINGLEDBSIZE"] = singleDBSizeCmdMeta diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 07c553736..ae414b6a2 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -156,6 +156,7 @@ func TestEval(t *testing.T) { testEvalBFINFO(t, store) testEvalBFEXISTS(t, store) testEvalBFADD(t, store) + testEvalJSONARRINDEX(t, store) } func testEvalPING(t *testing.T, store *dstore.Store) { @@ -9200,3 +9201,90 @@ func testEvalLRANGE(t *testing.T, store *dstore.Store) { } runMigratedEvalTests(t, tests, evalLRANGE, store) } + +func testEvalJSONARRINDEX(t *testing.T, store *dstore.Store) { + normalArray := `[0,1,2,3,4,3]` + tests := []evalTestCase{ + { + name: "nil value", + setup: func() {}, + input: nil, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.ARRINDEX"), + }, + }, + { + name: "empty args", + setup: func() {}, + input: []string{}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.ARRINDEX"), + }, + }, + { + name: "start index is invalid", + setup: func() { + key := "EXISTING_KEY" + var rootData interface{} + _ = sonic.Unmarshal([]byte(normalArray), &rootData) + obj := store.NewObj(rootData, -1, object.ObjTypeJSON) + store.Put(key, obj) + }, + input: []string{"EXISTING_KEY", "$", "3", "abc"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: errors.New("ERR Couldn't parse as integer"), + }, + }, + { + name: "stop index is invalid", + setup: func() { + key := "EXISTING_KEY" + var rootData interface{} + _ = sonic.Unmarshal([]byte(normalArray), &rootData) + obj := store.NewObj(rootData, -1, object.ObjTypeJSON) + store.Put(key, obj) + }, + input: []string{"EXISTING_KEY", "$", "3", "4", "abc"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: errors.New("ERR Couldn't parse as integer"), + }, + }, + { + name: "start and stop optional param valid", + setup: func() { + key := "EXISTING_KEY" + var rootData interface{} + _ = sonic.Unmarshal([]byte(normalArray), &rootData) + obj := store.NewObj(rootData, -1, object.ObjTypeJSON) + store.Put(key, obj) + }, + input: []string{"EXISTING_KEY", "$", "4", "4", "5"}, + migratedOutput: EvalResponse{ + Result: []interface{}{4}, + Error: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store = setupTest(store) + + if tt.setup != nil { + tt.setup() + } + + response := evalJSONARRINDEX(tt.input, store) + assert.Equal(t, tt.migratedOutput.Result, response.Result) + if tt.migratedOutput.Error != nil { + assert.EqualError(t, response.Error, tt.migratedOutput.Error.Error()) + } else { + assert.NoError(t, response.Error) + } + }) + } +} \ No newline at end of file diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 1ebe929c2..b0c614654 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -22,6 +22,7 @@ import ( "fmt" "math" "math/bits" + "reflect" "regexp" "sort" "strconv" @@ -6834,6 +6835,141 @@ func evalCommandDocs(args []string) *EvalResponse { return makeEvalResult(result) } +func evalJSONARRINDEX(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 3 || len(args) > 5 { + return makeEvalError(diceerrors.ErrWrongArgumentCount("JSON.ARRINDEX")) + } + + key := args[0] + path := args[1] + start := 0 + stop := 0 + + var value interface{} + var err error + + if strings.Contains(args[2], `"`) { + // user has provided string argument + value = args[2] + } else { + // parse it to float since default arg type would be string + value, err = strconv.ParseFloat(args[2], 64) + + if err != nil { + return makeEvalError(diceerrors.ErrGeneral("Couldn't parse as integer")) + } + } + + // Convert start to integer if provided + if len(args) >= 4 { + var err error + start, err = strconv.Atoi(args[3]) + if err != nil { + return makeEvalError(diceerrors.ErrGeneral("Couldn't parse as integer")) + } + } + + // Convert stop to integer if provided + if len(args) == 5 { + var err error + stop, err = strconv.Atoi(args[4]) + if err != nil { + return makeEvalError(diceerrors.ErrGeneral("Couldn't parse as integer")) + } + } + + // Check if the path specified is valid + expr, err2 := jp.ParseString(path) + if err2 != nil { + return makeEvalError(diceerrors.ErrJSONPathNotFound(path)) + } + + obj := store.Get(key) + if obj == nil { + return makeEvalError(diceerrors.ErrKeyDoesNotExist) + } + + if err2 := object.AssertType(obj.Type, object.ObjTypeJSON); err2 != nil { + return makeEvalError(diceerrors.ErrGeneral("Existing key has wrong Dice type")) + } + + jsonData := obj.Value + + // Check if the value stored is JSON type + _, err = sonic.Marshal(jsonData) + + if err != nil { + return makeEvalError(diceerrors.ErrGeneral("Existing key has wrong Dice type")) + } + + results := expr.Get(jsonData) + arrIndexList := make([]interface{}, 0, len(results)) + + for _, result := range results { + switch utils.GetJSONFieldType(result) { + case utils.ArrayType: + elementFound := false + arr := result.([]interface{}) + length := len(arr) + + adjustedStart, adjustedStop := adjustIndices(start, stop, length) + + if adjustedStart == -1 { + arrIndexList = append(arrIndexList, -1) + continue + } + + // Range [start, stop) : start is inclusive, stop is exclusive + for i := adjustedStart; i < adjustedStop; i++ { + if reflect.DeepEqual(arr[i], value) { + arrIndexList = append(arrIndexList, i) + elementFound = true + break + } + } + + if !elementFound { + arrIndexList = append(arrIndexList, -1) + } + default: + arrIndexList = append(arrIndexList, nil) + } + } + + return makeEvalResult(arrIndexList) +} + +// adjustIndices adjusts the start and stop indices for array traversal. +// It handles negative indices and ensures they are within the array bounds. +func adjustIndices(start, stop, length int) (adjustedStart, adjustedStop int) { + if length == 0 { + return -1, -1 + } + if start < 0 { + start += length + } + + if stop <= 0 { + stop += length + } + if start < 0 { + start = 0 + } + if stop < 0 { + stop = 0 + } + if start >= length { + return -1, -1 + } + if stop > length { + stop = length + } + if start > stop { + return -1, -1 + } + return start, stop +} + // This method executes each operation, contained in ops array, based on commands used. func executeBitfieldOps(value *ByteArray, ops []utils.BitFieldOp) []interface{} { overflowType := WRAP diff --git a/internal/iothread/cmd_meta.go b/internal/iothread/cmd_meta.go index a72e56fd9..e3fe5a156 100644 --- a/internal/iothread/cmd_meta.go +++ b/internal/iothread/cmd_meta.go @@ -179,6 +179,7 @@ const ( CmdCommandDocs = "COMMAND|DOCS" CmdCommandGetKeys = "COMMAND|GETKEYS" CmdCommandGetKeysFlags = "COMMAND|GETKEYSANDFLAGS" + CmdJSONArrIndex = "JSON.ARRINDEX" ) // Multi-shard commands. @@ -570,6 +571,9 @@ var CommandsMeta = map[string]CmdMeta{ CmdCommandGetKeysFlags: { CmdType: SingleShard, }, + CmdJSONArrIndex: { + CmdType: SingleShard, + }, // Multi-shard commands. CmdRename: {