From 3060bb2dfe00ccd2c7102c0b4c78ecf59146c7c9 Mon Sep 17 00:00:00 2001 From: vikram Date: Mon, 21 Oct 2024 01:11:36 +0530 Subject: [PATCH] migration: json.forget, json.del, json.nummultby, json.numincrby, json.toggle commands refactor and migeation --- internal/errors/migrated_errors.go | 3 +- internal/eval/commands.go | 39 ++- internal/eval/eval.go | 410 ---------------------- internal/eval/eval_test.go | 360 ++++++++++++++------ internal/eval/store_eval.go | 526 +++++++++++++++++++++++++++++ internal/server/cmd_meta.go | 25 ++ internal/worker/cmd_meta.go | 72 ++-- 7 files changed, 877 insertions(+), 558 deletions(-) diff --git a/internal/errors/migrated_errors.go b/internal/errors/migrated_errors.go index d9a8dc864..7dab5027f 100644 --- a/internal/errors/migrated_errors.go +++ b/internal/errors/migrated_errors.go @@ -31,7 +31,8 @@ var ( ErrAborted = errors.New("server received ABORT command") ErrEmptyCommand = errors.New("empty command") ErrInvalidIPAddress = errors.New("invalid IP address") - + ErrWrongKeyType = errors.New("ERR Existing key has wrong Dice type") + ErrKeyDoesNotExist = errors.New("ERR could not perform this operation on a key that doesn't exist") // Error generation functions for specific error messages with dynamic parameters. ErrWrongArgumentCount = func(command string) error { return fmt.Errorf("ERR wrong number of arguments for '%s' command", strings.ToLower(command)) // Indicates an incorrect number of arguments for a given command. diff --git a/internal/eval/commands.go b/internal/eval/commands.go index 4b2065576..78ee342cc 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -156,9 +156,10 @@ var ( 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}, + Arity: 2, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalJSONTOGGLE, } jsontypeCmdMeta = DiceCmdMeta{ Name: "JSON.TYPE", @@ -187,9 +188,10 @@ var ( Returns an integer reply specified as the number of paths deleted (0 or more). Returns RespZero if the key doesn't exist or key is expired. Error reply: If the number of arguments is incorrect.`, - Eval: evalJSONDEL, - Arity: -2, - KeySpecs: KeySpecs{BeginIndex: 1}, + Arity: -2, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalJSONDEL, } jsonarrappendCmdMeta = DiceCmdMeta{ Name: "JSON.ARRAPPEND", @@ -205,9 +207,10 @@ var ( Returns an integer reply specified as the number of paths deleted (0 or more). Returns RespZero if the key doesn't exist or key is expired. Error reply: If the number of arguments is incorrect.`, - Eval: evalJSONFORGET, - Arity: -2, - KeySpecs: KeySpecs{BeginIndex: 1}, + Arity: -2, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalJSONFORGET, } jsonarrlenCmdMeta = DiceCmdMeta{ Name: "JSON.ARRLEN", @@ -223,9 +226,10 @@ var ( Name: "JSON.NUMMULTBY", Info: `JSON.NUMMULTBY key path value Multiply the number value stored at the specified path by a value.`, - Eval: evalJSONNUMMULTBY, - Arity: 3, - KeySpecs: KeySpecs{BeginIndex: 1}, + Arity: 3, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalJSONNUMMULTBY, } jsonobjlenCmdMeta = DiceCmdMeta{ Name: "JSON.OBJLEN", @@ -949,11 +953,12 @@ var ( Arity: 1, } jsonnumincrbyCmdMeta = DiceCmdMeta{ - Name: "JSON.NUMINCRBY", - Info: `Increment the number value stored at path by number.`, - Eval: evalJSONNUMINCRBY, - Arity: 3, - KeySpecs: KeySpecs{BeginIndex: 1}, + Name: "JSON.NUMINCRBY", + Info: `Increment the number value stored at path by number.`, + Arity: 3, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalJSONNUMINCRBY, } dumpkeyCMmdMeta = DiceCmdMeta{ Name: "DUMP", diff --git a/internal/eval/eval.go b/internal/eval/eval.go index be4c865f8..c9f3244e1 100644 --- a/internal/eval/eval.go +++ b/internal/eval/eval.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" "time" - "unicode" "unsafe" "github.com/dicedb/dice/internal/eval/geo" @@ -570,20 +569,6 @@ func calculateSizeInBytes(value interface{}) int { } } -// evaLJSONFORGET removes the field specified by the given JSONPath from the JSON document stored under the provided key. -// calls the evalJSONDEL() with the arguments passed -// Returns response.RespZero if key is expired, or it does not exist -// Returns encoded error response if incorrect number of arguments -// If the JSONPath points to the root of the JSON document, the entire key is deleted from the store. -// Returns an integer reply specified as the number of paths deleted (0 or more) -func evalJSONFORGET(args []string, store *dstore.Store) []byte { - if len(args) < 1 { - return diceerrors.NewErrArity("JSON.FORGET") - } - - return evalJSONDEL(args, store) -} - // evalJSONARRLEN return the length of the JSON array at path in key // Returns an array of integer replies, an integer for each matching value, // each is the array's length, or nil, if the matching value is not an array. @@ -846,69 +831,6 @@ func adjustIndex(idx int, arr []any) int { return idx } -// evalJSONDEL delete a value that the given json path include in. -// Returns response.RespZero if key is expired, or it does not exist -// Returns encoded error response if incorrect number of arguments -// Returns an integer reply specified as the number of paths deleted (0 or more) -func evalJSONDEL(args []string, store *dstore.Store) []byte { - if len(args) < 1 { - return diceerrors.NewErrArity("JSON.DEL") - } - key := args[0] - - // Default path is root if not specified - path := defaultRootPath - if len(args) > 1 { - path = args[1] - } - - // Retrieve the object from the database - obj := store.Get(key) - if obj == nil { - return clientio.RespZero - } - - errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) - if errWithMessage != nil { - return errWithMessage - } - - jsonData := obj.Value - - _, err := sonic.Marshal(jsonData) - if err != nil { - return diceerrors.NewErrWithMessage("Existing key has wrong Dice type") - } - - if len(args) == 1 || path == defaultRootPath { - store.Del(key) - return clientio.RespOne - } - - expr, err := jp.ParseString(path) - if err != nil { - return diceerrors.NewErrWithMessage("invalid JSONPath") - } - results := expr.Get(jsonData) - - hasBrackets := strings.Contains(path, "[") && strings.Contains(path, "]") - - // If the command has square brackets then we have to delete an element inside an array - if hasBrackets { - _, err = expr.Remove(jsonData) - } else { - err = expr.Del(jsonData) - } - - if err != nil { - return diceerrors.NewErrWithMessage(err.Error()) - } - // Create a new object with the updated JSON data - newObj := store.NewObj(jsonData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) - store.Put(key, newObj) - return clientio.Encode(len(results), false) -} - // evalJSONTYPE retrieves a JSON value type stored at the specified key // args must contain at least the key; (path unused in this implementation) // Returns response.RespNIL if key is expired, or it does not exist @@ -1066,61 +988,6 @@ func evalJSONMGET(args []string, store *dstore.Store) []byte { return clientio.Encode(interfaceObj, 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") - } - - var 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 @@ -1246,153 +1113,6 @@ func parseFloatInt(input string) (result interface{}, err error) { return } -// Returns the new value after incrementing or multiplying the existing value -func incrMultValue(value any, multiplier interface{}, operation jsonOperation) (newVal interface{}, resultString string, isModified bool) { - switch utils.GetJSONFieldType(value) { - case utils.NumberType: - oldVal := value.(float64) - var newVal float64 - if v, ok := multiplier.(float64); ok { - switch operation { - case IncrBy: - newVal = oldVal + v - case MultBy: - newVal = oldVal * v - } - } else { - v, _ := multiplier.(int64) - switch operation { - case IncrBy: - newVal = oldVal + float64(v) - case MultBy: - newVal = oldVal * float64(v) - } - } - resultString := strconv.FormatFloat(newVal, 'f', -1, 64) - return newVal, resultString, true - case utils.IntegerType: - if v, ok := multiplier.(float64); ok { - oldVal := float64(value.(int64)) - var newVal float64 - switch operation { - case IncrBy: - newVal = oldVal + v - case MultBy: - newVal = oldVal * v - } - resultString := strconv.FormatFloat(newVal, 'f', -1, 64) - return newVal, resultString, true - } else { - v, _ := multiplier.(int64) - oldVal := value.(int64) - var newVal int64 - switch operation { - case IncrBy: - newVal = oldVal + v - case MultBy: - newVal = oldVal * v - } - resultString := strconv.FormatInt(newVal, 10) - return newVal, resultString, true - } - default: - return value, "null", false - } -} - -// evalJSONNUMMULTBY multiplies the JSON fields matching the specified JSON path at the specified key -// args must contain key, JSON path and the multiplier value -// Returns encoded error response if incorrect number of arguments -// Returns encoded error if the JSON path or key is invalid -// Returns bulk string reply specified as a stringified updated values for each path -// Returns null if matching field is non-numerical -func evalJSONNUMMULTBY(args []string, store *dstore.Store) []byte { - if len(args) < 3 { - return diceerrors.NewErrArity("JSON.NUMMULTBY") - } - key := args[0] - - // Retrieve the object from the database - obj := store.Get(key) - if obj == nil { - return diceerrors.NewErrWithFormattedMessage("could not perform this operation on a key that doesn't exist") - } - - // Check if the object is of JSON type - errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) - if errWithMessage != nil { - return errWithMessage - } - path := args[1] - // Parse the JSONPath expression - expr, err := jp.ParseString(path) - if err != nil { - return diceerrors.NewErrWithMessage("invalid JSONPath") - } - - // Get json matching expression - jsonData := obj.Value - results := expr.Get(jsonData) - if len(results) == 0 { - return clientio.Encode("[]", false) - } - - for i, r := range args[2] { - if !unicode.IsDigit(r) && r != '.' && r != '-' { - if i == 0 { - return diceerrors.NewErrWithFormattedMessage("-ERR expected value at line 1 column %d", i+1) - } - return diceerrors.NewErrWithFormattedMessage("-ERR trailing characters at line 1 column %d", i+1) - } - } - - // Parse the mulplier value - multiplier, err := parseFloatInt(args[2]) - if err != nil { - return diceerrors.NewErrWithMessage(diceerrors.IntOrOutOfRangeErr) - } - - // Update matching values using Modify - resultArray := make([]string, 0, len(results)) - if path == defaultRootPath { - newValue, resultString, modified := incrMultValue(jsonData, multiplier, MultBy) - if modified { - jsonData = newValue - } - resultArray = append(resultArray, resultString) - } else { - _, err := expr.Modify(jsonData, func(value any) (interface{}, bool) { - newValue, resultString, modified := incrMultValue(value, multiplier, MultBy) - resultArray = append(resultArray, resultString) - return newValue, modified - }) - if err != nil { - return diceerrors.NewErrWithMessage("invalid JSONPath") - } - } - - // Stringified updated values - resultString := `[` + strings.Join(resultArray, ",") + `]` - - newObj := &object.Obj{ - Value: jsonData, - TypeEncoding: object.ObjTypeJSON, - } - exp, ok := dstore.GetExpiry(obj, store) - - var exDurationMs int64 = -1 - if ok { - exDurationMs = int64(exp - uint64(utils.GetCurrentTime().UnixMilli())) - } - // newObj has default expiry time of -1 , we need to set it - if exDurationMs > 0 { - store.SetExpiry(newObj, exDurationMs) - } - - store.Put(key, newObj) - return clientio.Encode(resultString, false) -} - // evalJSONARRAPPEND appends the value(s) provided in the args to the given array path // in the JSON object saved at key in arguments. // Args must contain at least a key, path and value. @@ -3739,136 +3459,6 @@ func evalSELECT(args []string, store *dstore.Store) []byte { return clientio.RespOK } -// formatFloat formats float64 as string. -// Optionally appends a decimal (.0) for whole numbers, -// if b is true. -func formatFloat(f float64, b bool) string { - formatted := strconv.FormatFloat(f, 'f', -1, 64) - if b { - parts := strings.Split(formatted, ".") - if len(parts) == 1 { - formatted += ".0" - } - } - return formatted -} - -// takes original value, increment values (float or int), a flag representing if increment is float -// returns new value, string representation, a boolean representing if the value was modified -func incrementValue(value any, isIncrFloat bool, incrFloat float64, incrInt int64) (newVal interface{}, stringRepresentation string, isModified bool) { - switch utils.GetJSONFieldType(value) { - case utils.NumberType: - oldVal := value.(float64) - var newVal float64 - if isIncrFloat { - newVal = oldVal + incrFloat - } else { - newVal = oldVal + float64(incrInt) - } - resultString := formatFloat(newVal, isIncrFloat) - return newVal, resultString, true - case utils.IntegerType: - if isIncrFloat { - oldVal := float64(value.(int64)) - newVal := oldVal + incrFloat - resultString := formatFloat(newVal, isIncrFloat) - return newVal, resultString, true - } else { - oldVal := value.(int64) - newVal := oldVal + incrInt - resultString := fmt.Sprintf("%d", newVal) - return newVal, resultString, true - } - default: - return value, null, false - } -} - -func evalJSONNUMINCRBY(args []string, store *dstore.Store) []byte { - if len(args) < 3 { - return diceerrors.NewErrArity("JSON.NUMINCRBY") - } - key := args[0] - obj := store.Get(key) - - if obj == nil { - return diceerrors.NewErrWithFormattedMessage("-ERR could not perform this operation on a key that doesn't exist") - } - - // Check if the object is of JSON type - errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) - if errWithMessage != nil { - return errWithMessage - } - - path := args[1] - - jsonData := obj.Value - // Parse the JSONPath expression - expr, err := jp.ParseString(path) - if err != nil { - return diceerrors.NewErrWithMessage("invalid JSONPath") - } - - isIncrFloat := false - - for i, r := range args[2] { - if !unicode.IsDigit(r) && r != '.' && r != '-' { - if i == 0 { - return diceerrors.NewErrWithFormattedMessage("-ERR expected value at line 1 column %d", i+1) - } - return diceerrors.NewErrWithFormattedMessage("-ERR trailing characters at line 1 column %d", i+1) - } - if r == '.' { - isIncrFloat = true - } - } - var incrFloat float64 - var incrInt int64 - if isIncrFloat { - incrFloat, err = strconv.ParseFloat(args[2], 64) - if err != nil { - return diceerrors.NewErrWithMessage(diceerrors.IntOrOutOfRangeErr) - } - } else { - incrInt, err = strconv.ParseInt(args[2], 10, 64) - if err != nil { - return diceerrors.NewErrWithMessage(diceerrors.IntOrOutOfRangeErr) - } - } - results := expr.Get(jsonData) - - if len(results) == 0 { - respString := "[]" - return clientio.Encode(respString, false) - } - - resultArray := make([]string, 0, len(results)) - - if path == defaultRootPath { - newValue, resultString, isModified := incrementValue(jsonData, isIncrFloat, incrFloat, incrInt) - if isModified { - jsonData = newValue - } - resultArray = append(resultArray, resultString) - } else { - // Execute the JSONPath query - _, err := expr.Modify(jsonData, func(value any) (interface{}, bool) { - newValue, resultString, isModified := incrementValue(value, isIncrFloat, incrFloat, incrInt) - resultArray = append(resultArray, resultString) - return newValue, isModified - }) - if err != nil { - return diceerrors.NewErrWithMessage("invalid JSONPath") - } - } - - resultString := `[` + strings.Join(resultArray, ",") + `]` - - obj.Value = jsonData - return clientio.Encode(resultString, false) -} - // evalJSONOBJKEYS retrieves the keys of a JSON object stored at path specified. // It takes two arguments: the key where the JSON document is stored, and an optional JSON path. // It returns a list of keys from the object at the specified path or an error if the path is invalid. diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 6e8409033..eced1ae19 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -1254,17 +1254,26 @@ func BenchmarkEvalJSONOBJLEN(b *testing.B) { func testEvalJSONDEL(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.del' command\r\n"), + "json.del nil value": { + name: "json.del nil value", + setup: func() {}, + input: nil, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.DEL"), + }, }, - "key does not exist": { - setup: func() {}, - input: []string{"NONEXISTENT_KEY"}, - output: clientio.RespZero, + "json.del key does not exist": { + name: "json.del key does not exist", + setup: func() {}, + input: []string{"NONEXISTENT_KEY"}, + migratedOutput: EvalResponse{ + Result: 0, + Error: nil, + }, }, - "root path del": { + "json.del root path del": { + name: "json.del : root path del", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1274,10 +1283,14 @@ func testEvalJSONDEL(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"EXISTING_KEY"}, - output: clientio.RespOne, + input: []string{"EXISTING_KEY"}, + migratedOutput: EvalResponse{ + Result: int64(1), + Error: nil, + }, }, - "part path del": { + "json.del partial path del": { + name: "json.del : partial path del", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1288,10 +1301,14 @@ func testEvalJSONDEL(t *testing.T, store *dstore.Store) { store.Put(key, obj) }, - input: []string{"EXISTING_KEY", "$..language"}, - output: []byte(":2\r\n"), + input: []string{"EXISTING_KEY", "$..language"}, + migratedOutput: EvalResponse{ + Result: int64(2), + Error: nil, + }, }, - "wildcard path del": { + "json.del wildcard path del": { + name: "json.del wildcard path del", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1302,26 +1319,38 @@ func testEvalJSONDEL(t *testing.T, store *dstore.Store) { store.Put(key, obj) }, - input: []string{"EXISTING_KEY", "$.*"}, - output: []byte(":6\r\n"), + input: []string{"EXISTING_KEY", "$.*"}, + migratedOutput: EvalResponse{ + Result: int64(6), + Error: nil, + }, }, } - runEvalTests(t, tests, evalJSONDEL, store) + runMigratedEvalTests(t, tests, evalJSONDEL, store) } func testEvalJSONFORGET(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.forget' command\r\n"), + "JSON.FORGET nil value": { + name: "JSON.FORGET : nil value", + setup: func() {}, + input: nil, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.FORGET"), + }, }, - "key does not exist": { - setup: func() {}, - input: []string{"NONEXISTENT_KEY"}, - output: clientio.RespZero, + "JSON.FORGET key does not exist": { + name: "JSON.FORGET : key does not exist", + setup: func() {}, + input: []string{"NONEXISTENT_KEY"}, + migratedOutput: EvalResponse{ + Result: 0, + Error: nil, + }, }, - "root path forget": { + "JSON.FORGET : root path forget": { + name: "JSON.FORGET : root path forget", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1331,10 +1360,14 @@ func testEvalJSONFORGET(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"EXISTING_KEY"}, - output: clientio.RespOne, + input: []string{"EXISTING_KEY"}, + migratedOutput: EvalResponse{ + Result: int64(1), + Error: nil, + }, }, - "part path forget": { + "JSON.FORGET : partial path forget": { + name: "JSON.FORGET : partial path forget", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1345,10 +1378,14 @@ func testEvalJSONFORGET(t *testing.T, store *dstore.Store) { store.Put(key, obj) }, - input: []string{"EXISTING_KEY", "$..language"}, - output: []byte(":2\r\n"), + input: []string{"EXISTING_KEY", "$..language"}, + migratedOutput: EvalResponse{ + Result: int64(2), + Error: nil, + }, }, - "wildcard path forget": { + "JSON.FORGET : wildcard path forget": { + name: "JSON.FORGET : wildcard path forget", setup: func() { key := "EXISTING_KEY" value := "{\"age\":13,\"high\":1.60,\"pet\":null,\"language\":[\"python\",\"golang\"], " + @@ -1359,11 +1396,14 @@ func testEvalJSONFORGET(t *testing.T, store *dstore.Store) { store.Put(key, obj) }, - input: []string{"EXISTING_KEY", "$.*"}, - output: []byte(":6\r\n"), + input: []string{"EXISTING_KEY", "$.*"}, + migratedOutput: EvalResponse{ + Result: int64(6), + Error: nil, + }, }, } - runEvalTests(t, tests, evalJSONFORGET, store) + runMigratedEvalTests(t, tests, evalJSONFORGET, store) } func testEvalJSONCLEAR(t *testing.T, store *dstore.Store) { @@ -1755,21 +1795,34 @@ func testEvalJSONSET(t *testing.T, store *dstore.Store) { func testEvalJSONNUMMULTBY(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.nummultby' command\r\n"), + name: "JSON.NUMMULTBY : nil value", + setup: func() {}, + input: nil, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.NUMMULTBY"), + }, }, "empty array": { - setup: func() {}, - input: []string{}, - output: []byte("-ERR wrong number of arguments for 'json.nummultby' command\r\n"), + name: "JSON.NUMMULTBY : empty array", + setup: func() {}, + input: []string{}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.NUMMULTBY"), + }, }, "insufficient args": { - setup: func() {}, - input: []string{"doc"}, - output: []byte("-ERR wrong number of arguments for 'json.nummultby' command\r\n"), + name: "JSON.NUMMULTBY : insufficient args", + setup: func() {}, + input: []string{"doc"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.NUMMULTBY"), + }, }, "non-numeric multiplier on existing key": { + name: "JSON.NUMMULTBY : non-numeric multiplier on existing key", setup: func() { key := "doc" value := "{\"a\":10,\"b\":[{\"a\":2}, {\"a\":5}, {\"a\":\"c\"}]}" @@ -1778,10 +1831,14 @@ func testEvalJSONNUMMULTBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"doc", "$.a", "qwe"}, - output: []byte("-ERR expected value at line 1 column 1\r\n"), + input: []string{"doc", "$.a", "qwe"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral("expected value at line 1 column 1"), + }, }, "nummultby on non integer root fields": { + name: "JSON.NUMMULTBY : nummultby on non integer root fields", setup: func() { key := "doc" value := "{\"a\": \"b\",\"b\":[{\"a\":2}, {\"a\":5}, {\"a\":\"c\"}]}" @@ -1790,10 +1847,14 @@ func testEvalJSONNUMMULTBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"doc", "$.a", "2"}, - output: []byte("$6\r\n[null]\r\n"), + input: []string{"doc", "$.a", "2"}, + migratedOutput: EvalResponse{ + Result: "[null]", + Error: nil, + }, }, "nummultby on recursive fields": { + name: "JSON.NUMMULTBY : nummultby on recursive fields", setup: func() { key := "doc" value := "{\"a\": \"b\",\"b\":[{\"a\":2}, {\"a\":5}, {\"a\":\"c\"}]}" @@ -1802,10 +1863,14 @@ func testEvalJSONNUMMULTBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"doc", "$..a", "2"}, - output: []byte("$16\r\n[4,10,null,null]\r\n"), + input: []string{"doc", "$..a", "2"}, + migratedOutput: EvalResponse{ + Result: "[4,10,null,null]", + Error: nil, + }, }, "nummultby on integer root fields": { + name: "JSON.NUMMULTBY : nummultby on integer root fields", setup: func() { key := "doc" value := "{\"a\":10,\"b\":[{\"a\":2}, {\"a\":5}, {\"a\":\"c\"}]}" @@ -1814,10 +1879,14 @@ func testEvalJSONNUMMULTBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"doc", "$.a", "2"}, - output: []byte("$4\r\n[20]\r\n"), + input: []string{"doc", "$.a", "2"}, + migratedOutput: EvalResponse{ + Result: "[20]", + Error: nil, + }, }, "nummultby on non-existent key": { + name: "JSON.NUMMULTBY : nummultby on non-existent key", setup: func() { key := "doc" value := "{\"a\":10,\"b\":[{\"a\":2}, {\"a\":5}, {\"a\":\"c\"}]}" @@ -1826,11 +1895,14 @@ func testEvalJSONNUMMULTBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"doc", "$..fe", "2"}, - output: []byte("$2\r\n[]\r\n"), + input: []string{"doc", "$..fe", "2"}, + migratedOutput: EvalResponse{ + Result: "[]", + Error: nil, + }, }, } - runEvalTests(t, tests, evalJSONNUMMULTBY, store) + runMigratedEvalTests(t, tests, evalJSONNUMMULTBY, store) } func testEvalJSONARRAPPEND(t *testing.T, store *dstore.Store) { @@ -1949,22 +2021,35 @@ func testEvalJSONARRAPPEND(t *testing.T, store *dstore.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"), + "JSON.TOGGLE : nil value": { + name: "JSON.TOGGLE : nil value", + setup: func() {}, + input: nil, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.TOGGLE"), + }, }, - "empty array": { - setup: func() {}, - input: []string{}, - output: []byte("-ERR wrong number of arguments for 'json.toggle' command\r\n"), + "JSON.TOGGLE : no arguments supplied": { + name: "JSON.TOGGLE : no arguments supplied", + setup: func() {}, + input: []string{}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.TOGGLE"), + }, }, - "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"), + "JSON.TOGGLE : key does not exist": { + name: "JSON.TOGGLE : key does not exist", + setup: func() {}, + input: []string{"NONEXISTENT_KEY", ".active"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrKeyDoesNotExist, + }, }, - "key exists, toggling boolean true to false": { + "JSON.TOGGLE : key exists, toggling boolean true to false": { + name: "JSON.TOGGLE : key exists, toggling boolean true to false", setup: func() { key := "EXISTING_KEY" value := `{"active":true}` @@ -1976,10 +2061,14 @@ func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"EXISTING_KEY", ".active"}, - output: clientio.Encode([]interface{}{0}, false), + input: []string{"EXISTING_KEY", ".active"}, + migratedOutput: EvalResponse{ + Result: []interface{}{0}, + Error: nil, + }, }, - "key exists, toggling boolean false to true": { + "JSON.TOGGLE : key exists, toggling boolean false to true": { + name: "JSON.TOGGLE : key exists, toggling boolean false to true", setup: func() { key := "EXISTING_KEY" value := `{"active":false}` @@ -1991,10 +2080,14 @@ func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"EXISTING_KEY", ".active"}, - output: clientio.Encode([]interface{}{1}, false), + input: []string{"EXISTING_KEY", ".active"}, + migratedOutput: EvalResponse{ + Result: []interface{}{1}, + Error: nil, + }, }, - "key exists but expired": { + "JSON.TOGGLE : key exists but expired": { + name: "JSON.TOGGLE : key exists but expired", setup: func() { key := "EXISTING_KEY" value := "{\"active\":true}" @@ -2005,10 +2098,14 @@ func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) { 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"), + input: []string{"EXISTING_KEY", ".active"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrKeyDoesNotExist, + }, }, - "nested JSON structure with multiple booleans": { + "JSON.TOGGLE : nested JSON structure with multiple booleans": { + name: "JSON.TOGGLE : nested JSON structure with multiple booleans", setup: func() { key := "NESTED_KEY" value := `{"isSimple":true,"nested":{"isSimple":false}}` @@ -2017,10 +2114,14 @@ func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) { 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), + input: []string{"NESTED_KEY", "$..isSimple"}, + migratedOutput: EvalResponse{ + Result: []interface{}{0, 1}, + Error: nil, + }, }, - "deeply nested JSON structure with multiple matching fields": { + "JSON.TOGGLE : deeply nested JSON structure with multiple matching fields": { + name: "JSON.TOGGLE : deeply nested JSON structure with multiple matching fields", setup: func() { key := "DEEP_NESTED_KEY" value := `{"field": true, "nested": {"field": false, "nested": {"field": true}}}` @@ -2029,11 +2130,14 @@ func testEvalJSONTOGGLE(t *testing.T, store *dstore.Store) { 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), + input: []string{"DEEP_NESTED_KEY", "$..field"}, + migratedOutput: EvalResponse{ + Result: []interface{}{0, 1, 0}, + Error: nil, + }, }, } - runEvalTests(t, tests, evalJSONTOGGLE, store) + runMigratedEvalTests(t, tests, evalJSONTOGGLE, store) } func testEvalTTL(t *testing.T, store *dstore.Store) { @@ -3098,8 +3202,11 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$.a", "3"}, - output: []byte("$3\r\n[5]\r\n"), + input: []string{"number", "$.a", "3"}, + migratedOutput: EvalResponse{ + Result: "[5]", + Error: nil, + }, }, "incr on float field": { @@ -3111,8 +3218,11 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$.a", "1.5"}, - output: []byte("$5\r\n[4.0]\r\n"), + input: []string{"number", "$.a", "1.5"}, + migratedOutput: EvalResponse{ + Result: "[4.0]", + Error: nil, + }, }, "incr on multiple fields": { @@ -3124,10 +3234,18 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$..*", "5"}, - output: []byte("$22\r\n[25,20,null,7,15,null]\r\n"), - validator: func(output []byte) { - outPutString := string(output) + input: []string{"number", "$..*", "5"}, + migratedOutput: EvalResponse{ + Result: "[25,20,null,7,15,null]", + Error: nil, + }, + newValidator: func(output interface{}) { + outPutString, ok := output.(string) + if !ok { + t.Errorf("expected output to be of type string, got %T", output) + return + } + startIndex := strings.Index(outPutString, "[") endIndex := strings.Index(outPutString, "]") arrayString := outPutString[startIndex+1 : endIndex] @@ -3145,8 +3263,11 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$.a[1]", "5"}, - output: []byte("$3\r\n[7]\r\n"), + input: []string{"number", "$.a[1]", "5"}, + migratedOutput: EvalResponse{ + Result: "[7]", + Error: nil, + }, }, "incr on non-existent field": { setup: func() { @@ -3157,8 +3278,11 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$.b", "3"}, - output: []byte("$2\r\n[]\r\n"), + input: []string{"number", "$.b", "3"}, + migratedOutput: EvalResponse{ + Result: "[]", + Error: nil, + }, }, "incr with mixed fields": { setup: func() { @@ -3169,14 +3293,39 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$..*", "2"}, - output: []byte("$17\r\n[3,4,null,7,null]\r\n"), - validator: func(output []byte) { - outPutString := string(output) + input: []string{"number", "$..*", "2"}, + migratedOutput: EvalResponse{ + Result: "[3,4,7,null,null]", + Error: nil, + }, + newValidator: func(output interface{}) { + // Ensure that the output is a string before proceeding + outPutString, ok := output.(string) + if !ok { + t.Errorf("expected output to be of type string, got %T", output) + return + } + + // Find the positions of the first "[" and the last "]" startIndex := strings.Index(outPutString, "[") - endIndex := strings.Index(outPutString, "]") + endIndex := strings.LastIndex(outPutString, "]") + + // Check if both brackets are found + if startIndex == -1 || endIndex == -1 || startIndex >= endIndex { + t.Errorf("invalid array format in output string: %s", outPutString) + return + } + + // Extract the array substring between the brackets arrayString := outPutString[startIndex+1 : endIndex] + + // Split the array string by commas and trim spaces around elements arr := strings.Split(arrayString, ",") + for i := range arr { + arr[i] = strings.TrimSpace(arr[i]) + } + + // Validate that the array contains the expected elements testifyAssert.ElementsMatch(t, arr, []string{"3", "4", "7", "null", "null"}) }, }, @@ -3190,12 +3339,15 @@ func testEvalJSONNUMINCRBY(t *testing.T, store *dstore.Store) { obj := store.NewObj(rootData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) store.Put(key, obj) }, - input: []string{"number", "$..c", "5"}, - output: []byte("$4\r\n[15]\r\n"), + input: []string{"number", "$..c", "5"}, + migratedOutput: EvalResponse{ + Result: "[15]", + Error: nil, + }, }, } - runEvalTests(t, tests, evalJSONNUMINCRBY, store) + runMigratedEvalTests(t, tests, evalJSONNUMINCRBY, store) } func runEvalTests(t *testing.T, tests map[string]evalTestCase, evalFunc func([]string, *dstore.Store) []byte, store *dstore.Store) { diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 9bcf7a071..8c7747b8a 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -5,6 +5,7 @@ import ( "math" "strconv" "strings" + "unicode" "github.com/axiomhq/hyperloglog" "github.com/bytedance/sonic" @@ -957,6 +958,531 @@ func evalJSONSTRLEN(args []string, store *dstore.Store) *EvalResponse { } } +// evaLJSONFORGET removes the field specified by the given JSONPath from the JSON document stored under the provided key. +// calls the evalJSONDEL() with the arguments passed +// If the specified key has expired or does not exist, it returns 0. +// Returns encoded error response if incorrect number of arguments +// If the JSONPath points to the root of the JSON document, the entire key is deleted from the store. +// Returns an integer reply specified as the number of paths deleted (0 or more) +func evalJSONFORGET(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 1 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.FORGET"), + } + } + + return evalJSONDEL(args, store) +} + +// evalJSONDEL deletes a value specified by the given JSON path from the store. +// It returns an integer indicating the number of paths deleted (0 or more). +// If the specified key has expired or does not exist, it returns 0. +// If the number of arguments provided is incorrect, it returns an encoded error response. +func evalJSONDEL(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 1 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.DEL"), + } + } + key := args[0] + + // Default path is root if not specified + path := defaultRootPath + if len(args) > 1 { + path = args[1] + } + + // Retrieve the object from the database + obj := store.Get(key) + if obj == nil { + return &EvalResponse{ + Result: 0, + Error: nil, + } + } + + errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) + if errWithMessage != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + jsonData := obj.Value + + _, err := sonic.Marshal(jsonData) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongKeyType, + } + } + + if len(args) == 1 || path == defaultRootPath { + store.Del(key) + return &EvalResponse{ + Result: int64(1), + Error: nil, + } + } + + expr, err := jp.ParseString(path) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + results := expr.Get(jsonData) + + hasBrackets := strings.Contains(path, "[") && strings.Contains(path, "]") + + // If the command has square brackets then we have to delete an element inside an array + if hasBrackets { + _, err = expr.Remove(jsonData) + } else { + err = expr.Del(jsonData) + } + + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral(err.Error()), + } + } + // Create a new object with the updated JSON data + newObj := store.NewObj(jsonData, -1, object.ObjTypeJSON, object.ObjEncodingJSON) + store.Put(key, newObj) + + return &EvalResponse{ + Result: int64(len(results)), + Error: nil, + } +} + +// 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) *EvalResponse { + if len(args) < 2 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.TOGGLE"), + } + } + key := args[0] + path := args[1] + + obj := store.Get(key) + if obj == nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrKeyDoesNotExist, + } + } + + errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) + if errWithMessage != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + jsonData := obj.Value + expr, err := jp.ParseString(path) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + + var 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 &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral("failed to toggle values"), + } + } + + if modified { + obj.Value = jsonData + } + + toggleResults = ReverseSlice(toggleResults) + return &EvalResponse{ + Result: toggleResults, + Error: nil, + } +} + +// formatFloat formats float64 as string. +// Optionally appends a decimal (.0) for whole numbers, +// if b is true. +func formatFloat(f float64, b bool) string { + formatted := strconv.FormatFloat(f, 'f', -1, 64) + if b { + parts := strings.Split(formatted, ".") + if len(parts) == 1 { + formatted += ".0" + } + } + return formatted +} + +// takes original value, increment values (float or int), a flag representing if increment is float +// returns new value, string representation, a boolean representing if the value was modified +func incrementValue(value any, isIncrFloat bool, incrFloat float64, incrInt int64) (newVal interface{}, stringRepresentation string, isModified bool) { + switch utils.GetJSONFieldType(value) { + case utils.NumberType: + oldVal := value.(float64) + var newVal float64 + if isIncrFloat { + newVal = oldVal + incrFloat + } else { + newVal = oldVal + float64(incrInt) + } + resultString := formatFloat(newVal, isIncrFloat) + return newVal, resultString, true + case utils.IntegerType: + if isIncrFloat { + oldVal := float64(value.(int64)) + newVal := oldVal + incrFloat + resultString := formatFloat(newVal, isIncrFloat) + return newVal, resultString, true + } else { + oldVal := value.(int64) + newVal := oldVal + incrInt + resultString := fmt.Sprintf("%d", newVal) + return newVal, resultString, true + } + default: + return value, null, false + } +} + +func evalJSONNUMINCRBY(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 3 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.NUMINCRBY"), + } + } + key := args[0] + obj := store.Get(key) + + if obj == nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrKeyDoesNotExist, + } + } + + // Check if the object is of JSON type + errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) + if errWithMessage != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + path := args[1] + + jsonData := obj.Value + // Parse the JSONPath expression + expr, err := jp.ParseString(path) + + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + + isIncrFloat := false + + for i, r := range args[2] { + if !unicode.IsDigit(r) && r != '.' && r != '-' { + if i == 0 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral(fmt.Sprintf("expected value at line 1 column %d", i+1)), + } + } + + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral(fmt.Sprintf("trailing characters at line 1 column %d", i+1)), + } + } + if r == '.' { + isIncrFloat = true + } + } + var incrFloat float64 + var incrInt int64 + if isIncrFloat { + incrFloat, err = strconv.ParseFloat(args[2], 64) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrIntegerOutOfRange, + } + } + } else { + incrInt, err = strconv.ParseInt(args[2], 10, 64) + + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrIntegerOutOfRange, + } + } + } + results := expr.Get(jsonData) + + if len(results) == 0 { + respString := "[]" + return &EvalResponse{ + Result: respString, + Error: nil, + } + } + + resultArray := make([]string, 0, len(results)) + + if path == defaultRootPath { + newValue, resultString, isModified := incrementValue(jsonData, isIncrFloat, incrFloat, incrInt) + if isModified { + jsonData = newValue + } + resultArray = append(resultArray, resultString) + } else { + // Execute the JSONPath query + _, err := expr.Modify(jsonData, func(value any) (interface{}, bool) { + newValue, resultString, isModified := incrementValue(value, isIncrFloat, incrFloat, incrInt) + resultArray = append(resultArray, resultString) + return newValue, isModified + }) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + } + + resultString := `[` + strings.Join(resultArray, ",") + `]` + + obj.Value = jsonData + + return &EvalResponse{ + Result: resultString, + Error: nil, + } +} + +// Returns the new value after incrementing or multiplying the existing value +func incrMultValue(value any, multiplier interface{}, operation jsonOperation) (newVal interface{}, resultString string, isModified bool) { + switch utils.GetJSONFieldType(value) { + case utils.NumberType: + oldVal := value.(float64) + var newVal float64 + if v, ok := multiplier.(float64); ok { + switch operation { + case IncrBy: + newVal = oldVal + v + case MultBy: + newVal = oldVal * v + } + } else { + v, _ := multiplier.(int64) + switch operation { + case IncrBy: + newVal = oldVal + float64(v) + case MultBy: + newVal = oldVal * float64(v) + } + } + resultString := strconv.FormatFloat(newVal, 'f', -1, 64) + return newVal, resultString, true + case utils.IntegerType: + if v, ok := multiplier.(float64); ok { + oldVal := float64(value.(int64)) + var newVal float64 + switch operation { + case IncrBy: + newVal = oldVal + v + case MultBy: + newVal = oldVal * v + } + resultString := strconv.FormatFloat(newVal, 'f', -1, 64) + return newVal, resultString, true + } else { + v, _ := multiplier.(int64) + oldVal := value.(int64) + var newVal int64 + switch operation { + case IncrBy: + newVal = oldVal + v + case MultBy: + newVal = oldVal * v + } + resultString := strconv.FormatInt(newVal, 10) + return newVal, resultString, true + } + default: + return value, "null", false + } +} + +// evalJSONNUMMULTBY multiplies the JSON fields matching the specified JSON path at the specified key +// args must contain key, JSON path and the multiplier value +// Returns encoded error response if incorrect number of arguments +// Returns encoded error if the JSON path or key is invalid +// Returns bulk string reply specified as a stringified updated values for each path +// Returns null if matching field is non-numerical +func evalJSONNUMMULTBY(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 3 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("JSON.NUMMULTBY"), + } + } + key := args[0] + + // Retrieve the object from the database + obj := store.Get(key) + if obj == nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrKeyDoesNotExist, + } + } + + // Check if the object is of JSON type + errWithMessage := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeJSON, object.ObjEncodingJSON) + if errWithMessage != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + path := args[1] + // Parse the JSONPath expression + expr, err := jp.ParseString(path) + + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + + // Get json matching expression + jsonData := obj.Value + results := expr.Get(jsonData) + if len(results) == 0 { + return &EvalResponse{ + Result: "[]", + Error: nil, + } + } + + for i, r := range args[2] { + if !unicode.IsDigit(r) && r != '.' && r != '-' && r != 'e' && r != 'E' { + if i == 0 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral(fmt.Sprintf("expected value at line 1 column %d", i+1)), + } + } + + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrGeneral(fmt.Sprintf("trailing characters at line 1 column %d", i+1)), + } + } + } + + // Parse the mulplier value + multiplier, err := parseFloatInt(args[2]) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrIntegerOutOfRange, + } + } + + // Update matching values using Modify + resultArray := make([]string, 0, len(results)) + if path == defaultRootPath { + newValue, resultString, modified := incrMultValue(jsonData, multiplier, MultBy) + if modified { + jsonData = newValue + } + resultArray = append(resultArray, resultString) + } else { + _, err := expr.Modify(jsonData, func(value any) (interface{}, bool) { + newValue, resultString, modified := incrMultValue(value, multiplier, MultBy) + resultArray = append(resultArray, resultString) + return newValue, modified + }) + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrJSONPathNotFound(path), + } + } + } + + // Stringified updated values + resultString := `[` + strings.Join(resultArray, ",") + `]` + + newObj := &object.Obj{ + Value: jsonData, + TypeEncoding: object.ObjTypeJSON, + } + exp, ok := dstore.GetExpiry(obj, store) + + var exDurationMs int64 = -1 + if ok { + exDurationMs = int64(exp - uint64(utils.GetCurrentTime().UnixMilli())) + } + // newObj has default expiry time of -1 , we need to set it + if exDurationMs > 0 { + store.SetExpiry(newObj, exDurationMs) + } + + store.Put(key, newObj) + return &EvalResponse{ + Result: resultString, + Error: nil, + } +} + func evalPFCOUNT(args []string, store *dstore.Store) *EvalResponse { if len(args) < 1 { return &EvalResponse{ diff --git a/internal/server/cmd_meta.go b/internal/server/cmd_meta.go index ba0c695fa..d9a2e4b82 100644 --- a/internal/server/cmd_meta.go +++ b/internal/server/cmd_meta.go @@ -113,6 +113,26 @@ var ( Cmd: "JSON.OBJLEN", CmdType: SingleShard, } + jsonforgetCmdMeta = CmdsMeta{ + Cmd: "JSON.FORGET", + CmdType: SingleShard, + } + jsondelCmdMeta = CmdsMeta{ + Cmd: "JSON.DEL", + CmdType: SingleShard, + } + jsontoggleCmdMeta = CmdsMeta{ + Cmd: "JSON.TOGGLE", + CmdType: SingleShard, + } + jsonNumIncrByCmdMeta = CmdsMeta{ + Cmd: "JSON.NUMINCRBY", + CmdType: SingleShard, + } + jsonNumMultByCmdMeta = CmdsMeta{ + Cmd: "JSON.NUMMULTBY", + CmdType: SingleShard, + } incrCmdMeta = CmdsMeta{ Cmd: "INCR", @@ -184,6 +204,11 @@ func init() { WorkerCmdsMeta["JSON.CLEAR"] = jsonclearCmdMeta WorkerCmdsMeta["JSON.STRLEN"] = jsonstrlenCmdMeta WorkerCmdsMeta["JSON.OBJLEN"] = jsonobjlenCmdMeta + WorkerCmdsMeta["JSON.FORGET"] = jsonforgetCmdMeta + WorkerCmdsMeta["JSON.DEL"] = jsondelCmdMeta + WorkerCmdsMeta["JSON.TOGGLE"] = jsontoggleCmdMeta + WorkerCmdsMeta["JSON.NUMINCRBY"] = jsonNumIncrByCmdMeta + WorkerCmdsMeta["JSON.NUMMULTBY"] = jsonNumMultByCmdMeta WorkerCmdsMeta["ZADD"] = zaddCmdMeta WorkerCmdsMeta["ZRANGE"] = zrangeCmdMeta WorkerCmdsMeta["ZRANK"] = zrankCmdMeta diff --git a/internal/worker/cmd_meta.go b/internal/worker/cmd_meta.go index 13b47e530..3483622d3 100644 --- a/internal/worker/cmd_meta.go +++ b/internal/worker/cmd_meta.go @@ -62,32 +62,37 @@ const ( // Watch commands const ( - CmdGetWatch = "GET.WATCH" - CmdZRangeWatch = "ZRANGE.WATCH" - CmdZPopMin = "ZPOPMIN" - CmdJSONClear = "JSON.CLEAR" - CmdJSONStrlen = "JSON.STRLEN" - CmdJSONObjlen = "JSON.OBJLEN" - CmdZAdd = "ZADD" - CmdZRange = "ZRANGE" - CmdZRank = "ZRANK" - CmdPFAdd = "PFADD" - CmdPFCount = "PFCOUNT" - CmdPFMerge = "PFMERGE" - CmdIncr = "INCR" - CmdIncrBy = "INCRBY" - CmdDecr = "DECR" - CmdDecrBy = "DECRBY" - CmdIncrByFloat = "INCRBYFLOAT" - CmdHIncrBy = "HINCRBY" - CmdHIncrByFloat = "HINCRBYFLOAT" - CmdHRandField = "HRANDFIELD" - CmdGetRange = "GETRANGE" - CmdAppend = "APPEND" - CmdBFAdd = "BF.ADD" - CmdBFReserve = "BF.RESERVE" - CmdBFInfo = "BF.INFO" - CmdBFExists = "BF.EXISTS" + CmdGetWatch = "GET.WATCH" + CmdZRangeWatch = "ZRANGE.WATCH" + CmdZPopMin = "ZPOPMIN" + CmdJSONClear = "JSON.CLEAR" + CmdJSONStrlen = "JSON.STRLEN" + CmdJSONObjlen = "JSON.OBJLEN" + CmdJSONForget = "JSON.FORGET" + CmdJSONDel = "JSON.DEL" + CmdJSONToggle = "JSON.TOGGLE" + CmdJSONNumIncrBY = "JSON.NUMINCRBY" + CmdJSONNumMultBY = "JSON.NUMMULTBY" + CmdZAdd = "ZADD" + CmdZRange = "ZRANGE" + CmdZRank = "ZRANK" + CmdPFAdd = "PFADD" + CmdPFCount = "PFCOUNT" + CmdPFMerge = "PFMERGE" + CmdIncr = "INCR" + CmdIncrBy = "INCRBY" + CmdDecr = "DECR" + CmdDecrBy = "DECRBY" + CmdIncrByFloat = "INCRBYFLOAT" + CmdHIncrBy = "HINCRBY" + CmdHIncrByFloat = "HINCRBYFLOAT" + CmdHRandField = "HRANDFIELD" + CmdGetRange = "GETRANGE" + CmdAppend = "APPEND" + CmdBFAdd = "BF.ADD" + CmdBFReserve = "BF.RESERVE" + CmdBFInfo = "BF.INFO" + CmdBFExists = "BF.EXISTS" ) type CmdMeta struct { @@ -147,6 +152,21 @@ var CommandsMeta = map[string]CmdMeta{ CmdJSONObjlen: { CmdType: SingleShard, }, + CmdJSONForget: { + CmdType: SingleShard, + }, + CmdJSONDel: { + CmdType: SingleShard, + }, + CmdJSONToggle: { + CmdType: SingleShard, + }, + CmdJSONNumIncrBY: { + CmdType: SingleShard, + }, + CmdJSONNumMultBY: { + CmdType: SingleShard, + }, CmdPFAdd: { CmdType: SingleShard, },