diff --git a/echovault/api_string.go b/echovault/api_string.go index 8f33b013..2aaa3984 100644 --- a/echovault/api_string.go +++ b/echovault/api_string.go @@ -15,8 +15,9 @@ package echovault import ( - "github.com/echovault/echovault/internal" "strconv" + + "github.com/echovault/echovault/internal" ) // SetRange replaces a portion of the string at the provided key starting at the offset with a new string. @@ -76,3 +77,19 @@ func (server *EchoVault) GetRange(key string, start, end int) (string, error) { } return internal.ParseStringResponse(b) } + +// Append concatenates the string at the key with the value provided +// If the key does not exists it functions like a SET command +// +// Returns: The lenght of the new concatenated value at key +// +// Errors: +// +// - "value at key is not a string" - when the value at the keys is not a string. +func (server *EchoVault) Append(key string, value string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"APPEND", key, value}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} diff --git a/echovault/api_string_test.go b/echovault/api_string_test.go index 2d13c79d..2c219721 100644 --- a/echovault/api_string_test.go +++ b/echovault/api_string_test.go @@ -311,3 +311,57 @@ func TestEchoVault_STRLEN(t *testing.T) { }) } } + +func TestEchoVault_APPEND(t *testing.T) { + server := createEchoVault() + tests := []struct { + name string + presetValue interface{} + key string + value string + want int + wantErr bool + }{ + { + name: "Test APPEND with no preset value", + key: "key1", + value: "Hello ", + want: 6, + wantErr: false, + }, + { + name: "Test APPEND with preset value", + presetValue: "Hello ", + key: "key2", + value: "World", + want: 11, + wantErr: false, + }, + { + name: "Test APPEND with integer preset value", + key: "key3", + presetValue: 10, + value: "Hello ", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.Append(tt.key, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("APPEND() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("APPEND() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/modules/string/commands.go b/internal/modules/string/commands.go index 5537c609..799f7743 100644 --- a/internal/modules/string/commands.go +++ b/internal/modules/string/commands.go @@ -17,6 +17,7 @@ package str import ( "errors" "fmt" + "github.com/echovault/echovault/internal" "github.com/echovault/echovault/internal/constants" ) @@ -166,6 +167,36 @@ func handleSubStr(params internal.HandlerFuncParams) ([]byte, error) { return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(str), str)), nil } +func handleAppend(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := appendKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + value := params.Command[2] + if !keyExists { + if err = params.SetValues(params.Context, map[string]interface{}{ + key: internal.AdaptType(value), + }); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(value))), nil + } + currentValue, ok := params.GetValues(params.Context, []string{key})[key].(string) + if !ok { + return nil, fmt.Errorf("Value at key %s is not a string", key) + } + newValue := fmt.Sprintf("%v%s", currentValue, value) + if err = params.SetValues(params.Context, map[string]interface{}{ + key: internal.AdaptType(newValue), + }); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(newValue))), nil +} + func Commands() []internal.Command { return []internal.Command{ { @@ -205,5 +236,14 @@ Overwrites part of a string value with another by offset. Creates the key if it KeyExtractionFunc: subStrKeyFunc, HandlerFunc: handleSubStr, }, + { + Command: "append", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(APPEND key value) If key already exists and is a string, this command appends the value at the end of the string. If key does not exist it is created and set as an empty string, so APPEND will be similar to [SET] in this special case.`, + Sync: true, + KeyExtractionFunc: appendKeyFunc, + HandlerFunc: handleAppend, + }, } } diff --git a/internal/modules/string/commands_test.go b/internal/modules/string/commands_test.go index c92e419e..8b91ebf8 100644 --- a/internal/modules/string/commands_test.go +++ b/internal/modules/string/commands_test.go @@ -16,14 +16,15 @@ package str_test import ( "errors" + "strconv" + "strings" + "testing" + "github.com/echovault/echovault/echovault" "github.com/echovault/echovault/internal" "github.com/echovault/echovault/internal/config" "github.com/echovault/echovault/internal/constants" "github.com/tidwall/resp" - "strconv" - "strings" - "testing" ) func Test_String(t *testing.T) { @@ -450,4 +451,106 @@ func Test_String(t *testing.T) { }) } }) + + t.Run("Test_HandleAppend", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedError error + }{ + { + name: "Test APPEND with no preset value", + key: "AppendKey1", + command: []string{"APPEND", "AppendKey1", "Hello"}, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "Test APPEND with preset value", + key: "AppendKey2", + presetValue: "Hello ", + command: []string{"APPEND", "AppendKey2", "World"}, + expectedResponse: 11, + expectedError: nil, + }, + { + name: "Test APPEND with integer preset value", + key: "AppendKey4", + presetValue: 10, + command: []string{"APPEND", "AppendKey4", "World"}, + expectedResponse: 0, + expectedError: errors.New("Value at key AppendKey4 is not a string"), + }, + { + name: "Command too short", + command: []string{"APPEND", "AppendKey5"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Command too long", + command: []string{"APPEND", "AppendKey5", "new value", "extra value"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != "" { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.AnyValue(test.presetValue), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) } diff --git a/internal/modules/string/key_funcs.go b/internal/modules/string/key_funcs.go index 960e784e..792f6bc6 100644 --- a/internal/modules/string/key_funcs.go +++ b/internal/modules/string/key_funcs.go @@ -16,6 +16,7 @@ package str import ( "errors" + "github.com/echovault/echovault/internal" "github.com/echovault/echovault/internal/constants" ) @@ -52,3 +53,14 @@ func subStrKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { WriteKeys: make([]string, 0), }, nil } + +func appendKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +}