Skip to content

Commit

Permalink
#503: Added support for JSON.TOGGLE (#546)
Browse files Browse the repository at this point in the history
Co-authored-by: Jyotinder Singh <jyotindrsingh@gmail.com>
  • Loading branch information
TheRanomial and JyotinderSingh authored Sep 16, 2024
1 parent 8d02493 commit 75c0af9
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 0 deletions.
100 changes: 100 additions & 0 deletions integration_tests/commands/toggle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package commands

import (
"encoding/json"

"testing"

"github.com/dicedb/dice/testutils"
"gotest.tools/v3/assert"
)

func compareJSON(t *testing.T, expected, actual string) {
var expectedMap map[string]interface{}
var actualMap map[string]interface{}

err1 := json.Unmarshal([]byte(expected), &expectedMap)
err2 := json.Unmarshal([]byte(actual), &actualMap)

assert.NilError(t, err1)
assert.NilError(t, err2)

assert.DeepEqual(t, expectedMap, actualMap)
}

func TestEvalJSONTOGGLE(t *testing.T) {
conn := getLocalConnection()
defer conn.Close()

simpleJSON := `{"name":true,"age":false}`
complexJson := `{"field":true,"nested":{"field":false,"nested":{"field":true}}}`

testCases := []struct {
name string
commands []string
expected []interface{}
}{
{
name: "JSON.TOGGLE with existing key",
commands: []string{`JSON.SET user $ ` + simpleJSON, "JSON.TOGGLE user $.name"},
expected: []interface{}{"OK", []any{int64(0)}},
},
{
name: "JSON.TOGGLE with non-existing key",
commands: []string{"JSON.TOGGLE user $.flag"},
expected: []interface{}{"ERR could not perform this operation on a key that doesn't exist"},
},
{
name: "JSON.TOGGLE with invalid path",
commands: []string{`JSON.SET testkey $ ` + simpleJSON, "JSON.TOGGLE user $.invalidPath"},
expected: []interface{}{"WRONGTYPE Operation against a key holding the wrong kind of value", "ERR could not perform this operation on a key that doesn't exist"},
},
{
name: "JSON.TOGGLE with invalid command format",
commands: []string{"JSON.TOGGLE testKey"},
expected: []interface{}{"ERR wrong number of arguments for 'json.toggle' command"},
},
{
name: "deeply nested JSON structure with multiple matching fields",
commands: []string{
`JSON.SET user $ ` + complexJson,
"JSON.GET user",
"JSON.TOGGLE user $..field",
"JSON.GET user",
},
expected: []interface{}{
"OK",
`{"field":true,"nested":{"field":false,"nested":{"field":true}}}`,
[]any{int64(0), int64(1), int64(0)}, // Toggle: true -> false, false -> true, true -> false
`{"field":false,"nested":{"field":true,"nested":{"field":false}}}`,
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
FireCommand(conn, "DEL user")
for i, cmd := range tc.commands {
result := FireCommand(conn, cmd)
switch expected := tc.expected[i].(type) {
case string:
if isJSONString(expected) {
compareJSON(t, expected, result.(string))
} else {
assert.Equal(t, expected, result)
}
case []interface{}:
assert.Assert(t, testutils.UnorderedEqual(expected, result))
default:
assert.DeepEqual(t, expected, result)
}
}
})
}
}

func isJSONString(s string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}

18 changes: 18 additions & 0 deletions internal/eval/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ var (
Arity: 2,
KeySpecs: KeySpecs{BeginIndex: 1},
}

jsontoggleCmdMeta = DiceCmdMeta{
Name: "JSON.TOGGLE",
Info: `JSON.TOGGLE key [path]
Toggles Boolean values between true and false at the path.Return
If the path is enhanced syntax:
1.Array of integers (0 - false, 1 - true) that represent the resulting Boolean value at each path.
2.If a value is a not a Boolean value, its corresponding return value is null.
3.NONEXISTENT if the document key does not exist.
If the path is restricted syntax:
1.String ("true"/"false") that represents the resulting Boolean value.
2.NONEXISTENT if the document key does not exist.
3.WRONGTYPE error if the value at the path is not a Boolean value.`,
Eval: evalJSONTOGGLE,
Arity: 2,
KeySpecs: KeySpecs{BeginIndex: 1},
}
jsontypeCmdMeta = DiceCmdMeta{
Name: "JSON.TYPE",
Info: `JSON.TYPE key [path]
Expand Down Expand Up @@ -704,6 +721,7 @@ func init() {
DiceCmds["GET"] = getCmdMeta
DiceCmds["MSET"] = msetCmdMeta
DiceCmds["JSON.SET"] = jsonsetCmdMeta
DiceCmds["JSON.TOGGLE"] = jsontoggleCmdMeta
DiceCmds["JSON.GET"] = jsongetCmdMeta
DiceCmds["JSON.TYPE"] = jsontypeCmdMeta
DiceCmds["JSON.CLEAR"] = jsonclearCmdMeta
Expand Down
72 changes: 72 additions & 0 deletions internal/eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,78 @@ func evalJSONGET(args []string, store *dstore.Store) []byte {
return clientio.Encode(string(resultBytes), false)
}

// evalJSONTOGGLE toggles a boolean value stored at the specified key and path.
// args must contain at least the key and path (where the boolean is located).
// If the key does not exist or is expired, it returns response.RespNIL.
// If the field at the specified path is not a boolean, it returns an encoded error response.
// If the boolean is `true`, it toggles to `false` (returns :0), and if `false`, it toggles to `true` (returns :1).
// Returns an encoded error response if the incorrect number of arguments is provided.
func evalJSONTOGGLE(args []string, store *dstore.Store) []byte {
if len(args) < 2 {
return diceerrors.NewErrArity("JSON.TOGGLE")
}
key := args[0]
path := args[1]

obj := store.Get(key)
if obj == nil {
return diceerrors.NewErrWithFormattedMessage("-ERR could not perform this operation on a key that doesn't exist")
}

errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON)
if errWithMessage != nil {
return errWithMessage
}

jsonData := obj.Value
expr, err := jp.ParseString(path)
if err != nil {
return diceerrors.NewErrWithMessage("invalid JSONPath")
}

toggleResults := []interface{}{}
modified := false

_, err = expr.Modify(jsonData, func(value interface{}) (interface{}, bool) {
boolValue, ok := value.(bool)
if !ok {
toggleResults = append(toggleResults, nil)
return value, false
}
newValue := !boolValue
toggleResults = append(toggleResults, boolToInt(newValue))
modified = true
return newValue, true
})

if err != nil {
return diceerrors.NewErrWithMessage("failed to toggle values")
}

if modified {
obj.Value = jsonData
}

toggleResults = ReverseSlice(toggleResults)
return clientio.Encode(toggleResults, false)
}

func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

// ReverseSlice takes a slice of any type and returns a new slice with the elements reversed.
func ReverseSlice[T any](slice []T) []T {
reversed := make([]T, len(slice))
for i, v := range slice {
reversed[len(slice)-1-i] = v
}
return reversed
}

// evalJSONSET stores a JSON value at the specified key
// args must contain at least the key, path (unused in this implementation), and JSON string
// Returns encoded error response if incorrect number of arguments
Expand Down
96 changes: 96 additions & 0 deletions internal/eval/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"

"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -49,6 +50,7 @@ func TestEval(t *testing.T) {
testEvalJSONTYPE(t, store)
testEvalJSONGET(t, store)
testEvalJSONSET(t, store)
testEvalJSONTOGGLE(t,store)
testEvalJSONARRAPPEND(t, store)
testEvalTTL(t, store)
testEvalDel(t, store)
Expand Down Expand Up @@ -864,6 +866,7 @@ func testEvalJSONGET(t *testing.T, store *dstore.Store) {
runEvalTests(t, tests, evalJSONGET, store)
}


func testEvalJSONSET(t *testing.T, store *dstore.Store) {
tests := map[string]evalTestCase{
"nil value": {
Expand Down Expand Up @@ -1015,6 +1018,97 @@ func testEvalJSONARRAPPEND(t *testing.T, store *dstore.Store) {
runEvalTests(t, tests, evalJSONARRAPPEND, store)
}

func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) {
tests := map[string]evalTestCase{
"nil value": {
setup: func() {},
input: nil,
output: []byte("-ERR wrong number of arguments for 'json.toggle' command\r\n"),
},
"empty array": {
setup: func() {},
input: []string{},
output: []byte("-ERR wrong number of arguments for 'json.toggle' command\r\n"),
},
"key does not exist": {
setup: func() {},
input: []string{"NONEXISTENT_KEY", ".active"},
output: []byte("-ERR could not perform this operation on a key that doesn't exist\r\n"),
},
"key exists, toggling boolean true to false": {
setup: func() {
key := "EXISTING_KEY"
value := `{"active":true}`
var rootData interface{}
err := sonic.Unmarshal([]byte(value), &rootData)
if err != nil {
fmt.Printf("Debug: Error unmarshaling JSON: %v\n", err)
}
obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON)
store.Put(key, obj)

},
input: []string{"EXISTING_KEY", ".active"},
output: clientio.Encode([]interface{}{0}, false),
},
"key exists, toggling boolean false to true": {
setup: func() {
key := "EXISTING_KEY"
value := `{"active":false}`
var rootData interface{}
err := sonic.Unmarshal([]byte(value), &rootData)
if err != nil {
fmt.Printf("Debug: Error unmarshaling JSON: %v\n", err)
}
obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON)
store.Put(key, obj)
},
input: []string{"EXISTING_KEY", ".active"},
output: clientio.Encode([]interface{}{1}, false),
},
"key exists but expired": {
setup: func() {
key := "EXISTING_KEY"
value := "{\"active\":true}"
obj := &object.Obj{
Value: value,
LastAccessedAt: uint32(time.Now().Unix()),
}
store.Put(key, obj)
store.SetExpiry(obj, int64(-2*time.Millisecond))
},
input: []string{"EXISTING_KEY", ".active"},
output: []byte("-ERR could not perform this operation on a key that doesn't exist\r\n"),
},
"nested JSON structure with multiple booleans": {
setup: func() {
key := "NESTED_KEY"
value := `{"isSimple":true,"nested":{"isSimple":false}}`
var rootData interface{}
_ = sonic.Unmarshal([]byte(value), &rootData)
obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON)
store.Put(key, obj)
},
input: []string{"NESTED_KEY", "$..isSimple"},
output: clientio.Encode([]interface{}{0, 1}, false),
},
"deeply nested JSON structure with multiple matching fields": {
setup: func() {
key := "DEEP_NESTED_KEY"
value := `{"field": true, "nested": {"field": false, "nested": {"field": true}}}`
var rootData interface{}
_= sonic.Unmarshal([]byte(value), &rootData)
obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON)
store.Put(key, obj)
},
input: []string{"DEEP_NESTED_KEY", "$..field"},
output: clientio.Encode([]interface{}{0, 1, 0}, false),
},
}
runEvalTests(t, tests, evalJSONTOGGLE, store)
}


func testEvalTTL(t *testing.T, store *dstore.Store) {
tests := map[string]evalTestCase{
"nil value": {
Expand Down Expand Up @@ -1497,6 +1591,7 @@ func testEvalJSONSTRLEN(t *testing.T, store *dstore.Store) {
runEvalTests(t, tests, evalJSONSTRLEN, store)
}


func testEvalLLEN(t *testing.T, store *dstore.Store) {
tests := map[string]evalTestCase{
"nil value": {
Expand Down Expand Up @@ -1549,6 +1644,7 @@ func runEvalTests(t *testing.T, tests map[string]evalTestCase, evalFunc func([]s
tc.validator(output)
} else {
assert.Equal(t, string(tc.output), string(output))

}
})
}
Expand Down

0 comments on commit 75c0af9

Please sign in to comment.