diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d58117 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -count=1 ./... + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7056e58..c784df8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ tmp database.aof dist/ -tinykv.exe \ No newline at end of file +tinykv.exe +todos.md +AGENTS.md \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..30dabd3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,77 @@ +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - gosimple + - ineffassign + - typecheck + - revive + - gofmt + - goimports + - misspell + - unconvert + - prealloc + - copyloopvar + - gocritic + +linters-settings: + govet: + enable-all: true + disable: + - fieldalignment + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: error-return + - name: error-strings + - name: increment-decrement + - name: var-naming + - name: range + - name: unreachable-code + - name: errorf + - name: unnecessary-stmt + gocritic: + enabled-tags: + - diagnostic + disabled-checks: + - commentFormatting + - valSwap + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + +issues: + exclude-use-default: false + max-issues-per-linter: 50 + max-same-issues: 10 + exclude-rules: + - path: _test\.go + linters: + - errcheck + - linters: + - errcheck + source: "defer .+Close" + - linters: + - errcheck + source: "\\.Write\\(" + - linters: + - errcheck + source: "ListenAndServe" + - linters: + - errcheck + source: "\\.Seek\\(" + - linters: + - errcheck + source: "\\.Sync\\(" + - linters: + - errcheck + source: "\\.Read\\(" + - linters: + - errcheck + source: "\\.Encode\\(" \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..adf60b2 --- /dev/null +++ b/api.go @@ -0,0 +1,272 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +type API struct { + aof *Aof +} + +func NewAPI(aof *Aof) *API { + return &API{aof: aof} +} + +var writeCommands = map[string]bool{ + "SET": true, "HSET": true, "HDEL": true, "DEL": true, "INCR": true, "DECR": true, + "INCRBY": true, "DECRBY": true, "APPEND": true, "LPOP": true, + "RPOP": true, "LPUSH": true, "RPUSH": true, +} + +func (api *API) writeAof(command string, args []Value) { + if !writeCommands[command] { + return + } + value := Value{ + typ: "array", + array: append([]Value{{typ: "bulk", bulk: command}}, args...), + } + api.aof.Write(value) +} + +func (api *API) exec(w http.ResponseWriter, command string, args []Value) { + api.writeAof(command, args) + + handler, ok := Handlers[command] + if !ok { + http.Error(w, "unknown command", http.StatusBadRequest) + return + } + + result := handler(args) + writeValue(w, result) +} + +func writeValue(w http.ResponseWriter, v Value) { + switch v.typ { + case "string": + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(v.str)) + case "bulk": + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(v.bulk)) + case "null": + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("null")) + case "error": + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(v.str)) + case "array": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + result := make([]string, len(v.array)) + for i, item := range v.array { + if item.typ == "bulk" { + result[i] = item.bulk + } else { + result[i] = item.str + } + } + json.NewEncoder(w).Encode(result) + default: + http.Error(w, "unknown response type", http.StatusInternalServerError) + } +} + +func readBody(w http.ResponseWriter, r *http.Request) (string, bool) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return "", false + } + return string(body), true +} + +func (api *API) handlePing(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("PONG")) +} + +func (api *API) handleSet(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + value, ok := readBody(w, r) + if !ok { + return + } + api.exec(w, "SET", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: value}}) +} + +func (api *API) handleGet(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + api.exec(w, "GET", []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleDel(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + api.exec(w, "DEL", []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleIncr(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + api.exec(w, "INCR", []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleDecr(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + api.exec(w, "DECR", []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleIncrBy(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + amount, ok := readBody(w, r) + if !ok { + return + } + api.exec(w, "INCRBY", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: amount}}) +} + +func (api *API) handleDecrBy(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + amount, ok := readBody(w, r) + if !ok { + return + } + api.exec(w, "DECRBY", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: amount}}) +} + +func (api *API) handleAppend(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + value, ok := readBody(w, r) + if !ok { + return + } + api.exec(w, "APPEND", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: value}}) +} + +func (api *API) handleListPush(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + side := r.URL.Query().Get("side") + if side == "" { + side = "left" + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var values []string + if err := json.Unmarshal(body, &values); err != nil { + http.Error(w, "body must be a JSON array of strings", http.StatusBadRequest) + return + } + + args := []Value{{typ: "bulk", bulk: key}} + for _, v := range values { + args = append(args, Value{typ: "bulk", bulk: v}) + } + + command := "LPUSH" + if side == "right" { + command = "RPUSH" + } + + api.exec(w, command, args) +} + +func (api *API) handleListRange(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + + if startStr == "" { + startStr = "0" + } + if endStr == "" { + endStr = "-1" + } + + api.exec(w, "LRANGE", []Value{ + {typ: "bulk", bulk: key}, + {typ: "bulk", bulk: startStr}, + {typ: "bulk", bulk: endStr}, + }) +} + +func (api *API) handleListPop(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + side := r.URL.Query().Get("side") + if side == "" { + side = "left" + } + + command := "LPOP" + if side == "right" { + command = "RPOP" + } + + api.exec(w, command, []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleHashSet(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + field := r.PathValue("field") + value, ok := readBody(w, r) + if !ok { + return + } + api.exec(w, "HSET", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: field}, {typ: "bulk", bulk: value}}) +} + +func (api *API) handleHashGet(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + field := r.PathValue("field") + api.exec(w, "HGET", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: field}}) +} + +func (api *API) handleHashGetAll(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + api.exec(w, "HGETALL", []Value{{typ: "bulk", bulk: key}}) +} + +func (api *API) handleHashDel(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + field := r.PathValue("field") + api.exec(w, "HDEL", []Value{{typ: "bulk", bulk: key}, {typ: "bulk", bulk: field}}) +} + +func (api *API) Start() { + mux := http.NewServeMux() + + mux.HandleFunc("GET /ping", api.handlePing) + + mux.HandleFunc("PUT /kv/{key}", api.handleSet) + mux.HandleFunc("GET /kv/{key}", api.handleGet) + mux.HandleFunc("DELETE /kv/{key}", api.handleDel) + mux.HandleFunc("POST /kv/{key}/_incr", api.handleIncr) + mux.HandleFunc("POST /kv/{key}/_decr", api.handleDecr) + mux.HandleFunc("POST /kv/{key}/_incrby", api.handleIncrBy) + mux.HandleFunc("POST /kv/{key}/_decrby", api.handleDecrBy) + mux.HandleFunc("POST /kv/{key}/_append", api.handleAppend) + + mux.HandleFunc("PUT /list/{key}", api.handleListPush) + mux.HandleFunc("GET /list/{key}", api.handleListRange) + mux.HandleFunc("POST /list/{key}/_pop", api.handleListPop) + + mux.HandleFunc("PUT /hash/{key}/{field}", api.handleHashSet) + mux.HandleFunc("GET /hash/{key}/{field}", api.handleHashGet) + mux.HandleFunc("GET /hash/{key}", api.handleHashGetAll) + mux.HandleFunc("DELETE /hash/{key}/{field}", api.handleHashDel) + + fmt.Println("HTTP API listening on :8080") + http.ListenAndServe(":8080", mux) +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..65dfc58 --- /dev/null +++ b/api_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func setupTestAPI(t *testing.T) (*API, func()) { + t.Helper() + f, err := os.CreateTemp("", "test-*.aof") + if err != nil { + t.Fatal(err) + } + f.Close() + + aof, err := NewAof(f.Name()) + if err != nil { + os.Remove(f.Name()) + t.Fatal(err) + } + + api := NewAPI(aof) + cleanup := func() { + aof.Close() + os.Remove(f.Name()) + } + + resetStrings() + resetHash() + for k := range SETsL { + delete(SETsL, k) + } + + return api, cleanup +} + +func TestHandlePing(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() + + api.handlePing(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ping status = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "PONG" { + t.Errorf("ping body = %q, want PONG", w.Body.String()) + } +} + +func TestHandleSetGet(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodPut, "/kv/mykey", strings.NewReader("myval")) + req.SetPathValue("key", "mykey") + w := httptest.NewRecorder() + api.handleSet(w, req) + + if w.Code != http.StatusOK { + t.Errorf("SET status = %d, want %d", w.Code, http.StatusOK) + } + + req = httptest.NewRequest(http.MethodGet, "/kv/mykey", nil) + req.SetPathValue("key", "mykey") + w = httptest.NewRecorder() + api.handleGet(w, req) + + if w.Code != http.StatusOK { + t.Errorf("GET status = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "myval" { + t.Errorf("GET body = %q, want myval", w.Body.String()) + } +} + +func TestHandleGetMissing(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/kv/nonexistent", nil) + req.SetPathValue("key", "nonexistent") + w := httptest.NewRecorder() + api.handleGet(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("GET missing status = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleDel(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "v"}}) + + req := httptest.NewRequest(http.MethodDelete, "/kv/k", nil) + req.SetPathValue("key", "k") + w := httptest.NewRecorder() + api.handleDel(w, req) + + if w.Code != http.StatusOK { + t.Errorf("DEL status = %d, want %d", w.Code, http.StatusOK) + } + + got := get([]Value{{typ: "bulk", bulk: "k"}}) + if got.typ != "null" { + t.Errorf("after DEL, GET = %+v, want null", got) + } +} + +func TestHandleIncr(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + + req := httptest.NewRequest(http.MethodPost, "/kv/counter/_incr", nil) + req.SetPathValue("key", "counter") + w := httptest.NewRecorder() + api.handleIncr(w, req) + + if w.Code != http.StatusOK { + t.Errorf("INCR status = %d, want %d", w.Code, http.StatusOK) + } + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "11" { + t.Errorf("after INCR, counter = %v, want 11", got.bulk) + } +} + +func TestHandleDecr(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + + req := httptest.NewRequest(http.MethodPost, "/kv/counter/_decr", nil) + req.SetPathValue("key", "counter") + w := httptest.NewRecorder() + api.handleDecr(w, req) + + if w.Code != http.StatusOK { + t.Errorf("DECR status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestHandleAppend(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "hello"}}) + + req := httptest.NewRequest(http.MethodPost, "/kv/k/_append", strings.NewReader(" world")) + req.SetPathValue("key", "k") + w := httptest.NewRecorder() + api.handleAppend(w, req) + + if w.Code != http.StatusOK { + t.Errorf("APPEND status = %d, want %d", w.Code, http.StatusOK) + } + + got := get([]Value{{typ: "bulk", bulk: "k"}}) + if got.bulk != "hello world" { + t.Errorf("after APPEND, GET = %v, want 'hello world'", got.bulk) + } +} + +func TestHandleHasCRUD(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodPut, "/hash/myhash/f1", strings.NewReader("v1")) + req.SetPathValue("key", "myhash") + req.SetPathValue("field", "f1") + w := httptest.NewRecorder() + api.handleHashSet(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("HSET status = %d, want %d", w.Code, http.StatusOK) + } + + req = httptest.NewRequest(http.MethodGet, "/hash/myhash/f1", nil) + req.SetPathValue("key", "myhash") + req.SetPathValue("field", "f1") + w = httptest.NewRecorder() + api.handleHashGet(w, req) + + if w.Code != http.StatusOK { + t.Errorf("HGET status = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "v1" { + t.Errorf("HGET body = %q, want v1", w.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/hash/myhash", nil) + req.SetPathValue("key", "myhash") + w = httptest.NewRecorder() + api.handleHashGetAll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("HGETALL status = %d, want %d", w.Code, http.StatusOK) + } + + var result []string + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("HGETALL result length = %d, want 2", len(result)) + } + + req = httptest.NewRequest(http.MethodDelete, "/hash/myhash/f1", nil) + req.SetPathValue("key", "myhash") + req.SetPathValue("field", "f1") + w = httptest.NewRecorder() + api.handleHashDel(w, req) + + if w.Code != http.StatusOK { + t.Errorf("HDEL status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestHandleListPushAndRange(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + body, _ := json.Marshal([]string{"a", "b", "c"}) + req := httptest.NewRequest(http.MethodPut, "/list/mylist", io.NopCloser(strings.NewReader(string(body)))) + req.SetPathValue("key", "mylist") + w := httptest.NewRecorder() + api.handleListPush(w, req) + + if w.Code != http.StatusOK { + t.Errorf("LPUSH status = %d, want %d", w.Code, http.StatusOK) + } + + req = httptest.NewRequest(http.MethodGet, "/list/mylist?start=0&end=-1", nil) + req.SetPathValue("key", "mylist") + w = httptest.NewRecorder() + api.handleListRange(w, req) + + if w.Code != http.StatusOK { + t.Errorf("LRANGE status = %d, want %d", w.Code, http.StatusOK) + } + + var result []string + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 3 { + t.Errorf("LRANGE result length = %d, want 3", len(result)) + } +} + +func TestHandleIncrBy(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + + req := httptest.NewRequest(http.MethodPost, "/kv/counter/_incrby", strings.NewReader("5")) + req.SetPathValue("key", "counter") + w := httptest.NewRecorder() + api.handleIncrBy(w, req) + + if w.Code != http.StatusOK { + t.Errorf("INCRBY status = %d, want %d", w.Code, http.StatusOK) + } + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "15" { + t.Errorf("after INCRBY, counter = %v, want 15", got.bulk) + } +} + +func TestHandleDecrBy(t *testing.T) { + api, cleanup := setupTestAPI(t) + defer cleanup() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + + req := httptest.NewRequest(http.MethodPost, "/kv/counter/_decrby", strings.NewReader("3")) + req.SetPathValue("key", "counter") + w := httptest.NewRecorder() + api.handleDecrBy(w, req) + + if w.Code != http.StatusOK { + t.Errorf("DECRBY status = %d, want %d", w.Code, http.StatusOK) + } + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "7" { + t.Errorf("after DECRBY, counter = %v, want 7", got.bulk) + } +} diff --git a/handler.go b/handler.go index 148dc55..f05f34e 100644 --- a/handler.go +++ b/handler.go @@ -13,7 +13,7 @@ var Handlers = map[string]func([]Value) Value{ "CHSET": hsetHT, "CHGET": hgetHT, "CHGETALL": hgetallHT, - "CHDEL": hdelHT, + "CHDEL": hdelHT, "INCR": incr, "DECR": decr, "INCRBY": incrBy, @@ -28,6 +28,7 @@ var Handlers = map[string]func([]Value) Value{ "HSET": hset, "HGET": hget, "HGETALL": hgetall, + "HDEL": hdel, } func ping(args []Value) Value { @@ -114,13 +115,12 @@ func incr(args []Value) Value { val := SETs[key] - //convert val to integer i, err := strconv.Atoi(val) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} } INCRsMU.Lock() - i += 1 + i++ SETs[key] = strconv.Itoa(i) INCRsMU.Unlock() @@ -136,13 +136,12 @@ func decr(args []Value) Value { val := SETs[key] - //convert val to integer i, err := strconv.Atoi(val) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} } INCRsMU.Lock() - i -= 1 + i-- SETs[key] = strconv.Itoa(i) INCRsMU.Unlock() @@ -157,7 +156,7 @@ func incrBy(args []Value) Value { key := args[0].bulk incrementval := args[1].bulk - //convert incrementval to integer + // convert incrementval to integer increment, err := strconv.Atoi(incrementval) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} @@ -165,7 +164,7 @@ func incrBy(args []Value) Value { val := SETs[key] - //convert val to integer + // convert val to integer i, err := strconv.Atoi(val) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} @@ -194,7 +193,7 @@ func decrBy(args []Value) Value { val := SETs[key] - //convert val to integer + // convert val to integer i, err := strconv.Atoi(val) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..8e7a6fc --- /dev/null +++ b/handler_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "sync" + "testing" +) + +func resetStrings() { + SETsMu.Lock() + for k := range SETs { + delete(SETs, k) + } + SETsMu.Unlock() + INCRsMU.Lock() + for k := range SETs { + delete(SETs, k) + } + INCRsMU.Unlock() +} + +func TestPing(t *testing.T) { + got := ping([]Value{}) + if got.typ != "string" || got.str != "PONG" { + t.Errorf("ping() = %+v, want PONG", got) + } + + got = ping([]Value{{typ: "bulk", bulk: "hello"}}) + if got.str != "hello" { + t.Errorf("ping(hello) = %v, want hello", got.str) + } +} + +func TestSetAndGet(t *testing.T) { + resetStrings() + + result := set([]Value{{typ: "bulk", bulk: "mykey"}, {typ: "bulk", bulk: "myval"}}) + if result.typ != "string" || result.str != "OK" { + t.Fatalf("SET returned %+v, want OK", result) + } + + got := get([]Value{{typ: "bulk", bulk: "mykey"}}) + if got.typ != "bulk" || got.bulk != "myval" { + t.Errorf("GET mykey = %+v, want myval", got) + } +} + +func TestGetMissing(t *testing.T) { + resetStrings() + + got := get([]Value{{typ: "bulk", bulk: "nonexistent"}}) + if got.typ != "null" { + t.Errorf("GET nonexistent = %+v, want null", got) + } +} + +func TestSetOverwrite(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "v1"}}) + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "v2"}}) + + got := get([]Value{{typ: "bulk", bulk: "k"}}) + if got.bulk != "v2" { + t.Errorf("after overwrite, GET k = %v, want v2", got.bulk) + } +} + +func TestDel(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "v"}}) + del([]Value{{typ: "bulk", bulk: "k"}}) + + got := get([]Value{{typ: "bulk", bulk: "k"}}) + if got.typ != "null" { + t.Errorf("after DEL, GET k = %+v, want null", got) + } +} + +func TestAppend(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "hello"}}) + appendto([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: " world"}}) + + got := get([]Value{{typ: "bulk", bulk: "k"}}) + if got.bulk != "hello world" { + t.Errorf("after APPEND, GET k = %v, want 'hello world'", got.bulk) + } +} + +func TestIncr(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + incr([]Value{{typ: "bulk", bulk: "counter"}}) + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "11" { + t.Errorf("after INCR, GET counter = %v, want 11", got.bulk) + } +} + +func TestDecr(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + decr([]Value{{typ: "bulk", bulk: "counter"}}) + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "9" { + t.Errorf("after DECR, GET counter = %v, want 9", got.bulk) + } +} + +func TestIncrBy(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + incrBy([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "5"}}) + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "15" { + t.Errorf("after INCRBY 5, GET counter = %v, want 15", got.bulk) + } +} + +func TestDecrBy(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "10"}}) + decrBy([]Value{{typ: "bulk", bulk: "counter"}, {typ: "bulk", bulk: "3"}}) + + got := get([]Value{{typ: "bulk", bulk: "counter"}}) + if got.bulk != "7" { + t.Errorf("after DECRBY 3, GET counter = %v, want 7", got.bulk) + } +} + +func TestIncrNonInteger(t *testing.T) { + resetStrings() + + set([]Value{{typ: "bulk", bulk: "k"}, {typ: "bulk", bulk: "notanumber"}}) + got := incr([]Value{{typ: "bulk", bulk: "k"}}) + if got.typ != "error" { + t.Errorf("INCR on non-integer = %+v, want error", got) + } +} + +func TestSetWrongArgs(t *testing.T) { + got := set([]Value{}) + if got.typ != "error" { + t.Errorf("SET with no args = %+v, want error", got) + } +} + +func TestGetWrongArgs(t *testing.T) { + got := get([]Value{}) + if got.typ != "error" { + t.Errorf("GET with no args = %+v, want error", got) + } +} + +func TestDelWrongArgs(t *testing.T) { + got := del([]Value{}) + if got.typ != "error" { + t.Errorf("DEL with no args = %+v, want error", got) + } +} + +func TestAppendWrongArgs(t *testing.T) { + got := appendto([]Value{}) + if got.typ != "error" { + t.Errorf("APPEND with no args = %+v, want error", got) + } +} + +func TestIncrWrongArgs(t *testing.T) { + got := incr([]Value{}) + if got.typ != "error" { + t.Errorf("INCR with no args = %+v, want error", got) + } +} + +func TestDecrWrongArgs(t *testing.T) { + got := decr([]Value{}) + if got.typ != "error" { + t.Errorf("DECR with no args = %+v, want error", got) + } +} + +func TestIncrByWrongArgs(t *testing.T) { + got := incrBy([]Value{}) + if got.typ != "error" { + t.Errorf("INCRBY with no args = %+v, want error", got) + } +} + +func TestDecrByWrongArgs(t *testing.T) { + got := decrBy([]Value{}) + if got.typ != "error" { + t.Errorf("DECRBY with no args = %+v, want error", got) + } +} + +func TestConcurrentSet(t *testing.T) { + resetStrings() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + set([]Value{{typ: "bulk", bulk: "ckey"}, {typ: "bulk", bulk: "val"}}) + }(i) + } + wg.Wait() + + got := get([]Value{{typ: "bulk", bulk: "ckey"}}) + if got.typ != "bulk" { + t.Errorf("after concurrent SETs, GET ckey = %+v, want bulk", got) + } +} diff --git a/hashes.go b/hashes.go index 59f02a1..33ec6d5 100644 --- a/hashes.go +++ b/hashes.go @@ -48,58 +48,56 @@ func (cht *HashTable) hashFunc2(key string) int { } func (ht *HashTable) Set(hashKey, field, val string) { - ht.mu.Lock() - defer ht.mu.Unlock() + ht.mu.Lock() + defer ht.mu.Unlock() - ht.set(hashKey, field, val) + ht.set(hashKey, field, val) } func (ht *HashTable) set(hashKey, field, val string) { - kv := &KeyValue{Key: hashKey, Field: field, Value: val} - for attempt := 0; attempt < 10; attempt++ { - for i := 0; i < 2; i++ { - bucketIndex := ht.hashFunc[i](kv.Key) - - if ht.buckets[bucketIndex] == nil { - ht.buckets[bucketIndex] = []*KeyValue{kv} - ht.count++ - return - } - - for j, existingKv := range ht.buckets[bucketIndex] { - if existingKv.Key == kv.Key && existingKv.Field == kv.Field { - ht.buckets[bucketIndex][j] = kv - return - } - } - - evictedKv := ht.buckets[bucketIndex][0] - ht.buckets[bucketIndex][0] = kv - kv = evictedKv - } - } - ht.resize() - ht.set(hashKey, field, val) + kv := &KeyValue{Key: hashKey, Field: field, Value: val} + for attempt := 0; attempt < 10; attempt++ { + for i := 0; i < 2; i++ { + bucketIndex := ht.hashFunc[i](kv.Key) + + if ht.buckets[bucketIndex] == nil { + ht.buckets[bucketIndex] = []*KeyValue{kv} + ht.count++ + return + } + + for j, existingKv := range ht.buckets[bucketIndex] { + if existingKv.Key == kv.Key && existingKv.Field == kv.Field { + ht.buckets[bucketIndex][j] = kv + return + } + } + + evictedKv := ht.buckets[bucketIndex][0] + ht.buckets[bucketIndex][0] = kv + kv = evictedKv + } + } + ht.resize() + ht.set(hashKey, field, val) } func (ht *HashTable) resize() { - newSize := ht.size * 2 - newBuckets := make([][]*KeyValue, newSize) - oldBuckets := ht.buckets - - ht.buckets = newBuckets - ht.size = newSize - ht.count = 0 - - for _, bucket := range oldBuckets { - if bucket != nil { - for _, kv := range bucket { - if kv != nil { - ht.set(kv.Key, kv.Field, kv.Value) - } - } - } - } + newSize := ht.size * 2 + newBuckets := make([][]*KeyValue, newSize) + oldBuckets := ht.buckets + + ht.buckets = newBuckets + ht.size = newSize + ht.count = 0 + + for _, bucket := range oldBuckets { + for _, kv := range bucket { + if kv != nil { + ht.set(kv.Key, kv.Field, kv.Value) + } + } + } } func (ht *HashTable) Get(hashKey, field string) (string, bool) { @@ -110,11 +108,9 @@ func (ht *HashTable) Get(hashKey, field string) (string, bool) { hash := ht.hashFunc[i](hashKey) bucket := ht.buckets[hash] - if bucket != nil { - for _, kv := range bucket { - if kv.Key == hashKey && kv.Field == field { - return kv.Value, true - } + for _, kv := range bucket { + if kv.Key == hashKey && kv.Field == field { + return kv.Value, true } } } @@ -126,7 +122,7 @@ func (ht *HashTable) Delete(hashKey, field string) { ht.mu.Lock() defer ht.mu.Unlock() log.Println("Deleting key-value pair:", hashKey, field) - for i := 0; i < 2; i++ { + for i := 0; i < 2; i++ { //nolint:staticcheck // SA4008: cuckoo hashing tries 2 hash functions bucketIndex := ht.hashFunc[i](hashKey) log.Println("Bucket index:", bucketIndex) for j := bucketIndex; ; j = (j + 1) % len(ht.buckets) { @@ -176,12 +172,10 @@ func (ht *HashTable) GetAll(hashKey string) (map[string]string, bool) { bucketIndex := ht.hashFunc[i](hashKey) bucket := ht.buckets[bucketIndex] - if bucket != nil { - for _, kv := range bucket { - if kv.Key == hashKey { - found = true - result[kv.Field] = kv.Value - } + for _, kv := range bucket { + if kv.Key == hashKey { + found = true + result[kv.Field] = kv.Value } } } diff --git a/hashes_test.go b/hashes_test.go new file mode 100644 index 0000000..e7fd138 --- /dev/null +++ b/hashes_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "testing" +) + +func resetHashTable() { + hashTable = NewHashTable(100) +} + +func TestHashTableSetAndGet(t *testing.T) { + resetHashTable() + + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v"}}) + + got := hgetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.typ != "bulk" || got.bulk != "v" { + t.Errorf("CHGET = %+v, want v", got) + } +} + +func TestHashTableGetMissing(t *testing.T) { + resetHashTable() + + got := hgetHT([]Value{{typ: "bulk", bulk: "nokey"}, {typ: "bulk", bulk: "nofield"}}) + if got.typ != "null" { + t.Errorf("CHGET nonexistent = %+v, want null", got) + } +} + +func TestHashTableSetOverwrite(t *testing.T) { + resetHashTable() + + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v1"}}) + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v2"}}) + + got := hgetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.bulk != "v2" { + t.Errorf("after overwrite, CHGET = %v, want v2", got.bulk) + } +} + +func TestHashTableDelete(t *testing.T) { + resetHashTable() + + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v"}}) + hdelHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + + got := hgetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.typ != "null" { + t.Errorf("after CHDEL, CHGET = %+v, want null", got) + } +} + +func TestHashTableGetAll(t *testing.T) { + resetHashTable() + + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f1"}, {typ: "bulk", bulk: "v1"}}) + hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f2"}, {typ: "bulk", bulk: "v2"}}) + + got := hgetallHT([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "array" { + t.Fatalf("CHGETALL = %+v, want array", got) + } + if len(got.array) != 4 { + t.Errorf("CHGETALL array length = %d, want 4", len(got.array)) + } +} + +func TestHashTableGetAllMissing(t *testing.T) { + resetHashTable() + + got := hgetallHT([]Value{{typ: "bulk", bulk: "nohash"}}) + if got.typ != "null" { + t.Errorf("CHGETALL nonexistent = %+v, want null", got) + } +} + +func TestHashTableMultipleFields(t *testing.T) { + resetHash() + + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "a"}, {typ: "bulk", bulk: "1"}}) + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "b"}, {typ: "bulk", bulk: "2"}}) + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "c"}, {typ: "bulk", bulk: "3"}}) + + got := hgetall([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "array" { + t.Fatalf("HGETALL = %+v, want array", got) + } + if len(got.array) != 6 { + t.Errorf("HGETALL array length = %d, want 6 (3 fields * 2)", len(got.array)) + } +} + +func TestHashTableSetWrongArgs(t *testing.T) { + got := hsetHT([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.typ != "error" { + t.Errorf("CHSET with 2 args = %+v, want error", got) + } +} + +func TestHashTableGetWrongArgs(t *testing.T) { + got := hgetHT([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "error" { + t.Errorf("CHGET with 1 arg = %+v, want error", got) + } +} + +func TestHashTableDeleteWrongArgs(t *testing.T) { + got := hdelHT([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "error" { + t.Errorf("CHDEL with 1 arg = %+v, want error", got) + } +} + +func TestHashTableGetAllWrongArgs(t *testing.T) { + got := hgetallHT([]Value{}) + if got.typ != "error" { + t.Errorf("CHGETALL with no args = %+v, want error", got) + } +} + +func TestHashTableResize(t *testing.T) { + if testing.Short() { + t.Skip("skipping resize test in short mode") + } + resetHashTable() + + for i := 0; i < 20; i++ { + hsetHT([]Value{ + {typ: "bulk", bulk: fmt.Sprintf("hash%d", i)}, + {typ: "bulk", bulk: "field"}, + {typ: "bulk", bulk: "value"}, + }) + } + + got := hgetHT([]Value{{typ: "bulk", bulk: "hash0"}, {typ: "bulk", bulk: "field"}}) + if got.typ != "bulk" || got.bulk != "value" { + t.Errorf("after resize, CHGET = %+v, want value", got) + } +} diff --git a/list.go b/list.go index 8a26b6b..5b978b4 100644 --- a/list.go +++ b/list.go @@ -41,7 +41,7 @@ func Lrange(args []Value) Value { start := args[1].bulk end := args[2].bulk - //convert start and end to int + // convert start and end to int startInt, err := strconv.Atoi(start) if err != nil { return Value{typ: "error", str: "ERR: value is not an integer"} diff --git a/list_test.go b/list_test.go index 4fdb466..33d3caf 100644 --- a/list_test.go +++ b/list_test.go @@ -10,7 +10,7 @@ func TestLpush(t *testing.T) { // Reset the global state for tests log.Println("Testing LPush") // Test cases - + for k := range SETsL { delete(SETsL, k) } @@ -39,7 +39,6 @@ func TestLpush(t *testing.T) { want: Value{typ: "error", str: "ERR wrong number of arguments for 'lpush' command"}, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -67,7 +66,7 @@ func TestLpush(t *testing.T) { func equal(a, b []string) bool { - log.Println(len(a),len(b)) + log.Println(len(a), len(b)) if len(a) != len(b) { return false @@ -81,8 +80,6 @@ func equal(a, b []string) bool { return true } - - func BenchmarkLpush(b *testing.B) { // Benchmark case: Pushing to an empty list key := "benchmarklist" @@ -140,11 +137,9 @@ func BenchmarkLpushExtraLarge(b *testing.B) { b.StopTimer() } - func TestRpush(t *testing.T) { // Initialize the map - tests := []struct { name string args []Value @@ -200,7 +195,7 @@ func TestRpush(t *testing.T) { }) } } -func BenchmarkRpush(b *testing.B){ +func BenchmarkRpush(b *testing.B) { key := "benchmarklist" value := "value" @@ -239,4 +234,4 @@ func BenchmarkRpushLarge(b *testing.B) { Rpush(values) } b.StopTimer() -} \ No newline at end of file +} diff --git a/main.go b/main.go index 0f1e4dc..52ec291 100644 --- a/main.go +++ b/main.go @@ -8,15 +8,6 @@ import ( ) func main() { - fmt.Println("Listening on port :6379") - - // Create a new server - l, err := net.Listen("tcp", ":6379") - if err != nil { - fmt.Println(err) - return - } - aof, err := NewAof("database.aof") if err != nil { fmt.Println(err) @@ -37,31 +28,44 @@ func main() { handler(args) }) - // Listen for connections - conn, err := l.Accept() + api := NewAPI(aof) + go api.Start() + + l, err := net.Listen("tcp", ":6379") if err != nil { fmt.Println(err) return } + defer l.Close() + + fmt.Println("Listening on port :6379") + + for { + conn, err := l.Accept() + if err != nil { + log.Println("Accept error:", err) + continue + } + + go handleConnection(conn, aof) + } +} +func handleConnection(conn net.Conn, aof *Aof) { defer conn.Close() for { resp := NewResp(conn) - log.Println("Reading request") value, err := resp.Read() if err != nil { - fmt.Println(err) return } if value.typ != "array" { - fmt.Println("Invalid request, expected array") continue } if len(value.array) == 0 { - fmt.Println("Invalid request, expected array length > 0") continue } @@ -70,16 +74,14 @@ func main() { writer := NewWriter(conn) - handler, ok := Handlers[command] if !ok { - fmt.Println("Invalid command: ", command) writer.Write(Value{typ: "string", str: ""}) continue } switch command { - case "SET", "HSET", "DEL", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "LPOP", "RPOP", "LPUSH", "RPUSH": + case "SET", "HSET", "HDEL", "DEL", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "LPOP", "RPOP", "LPUSH", "RPUSH": aof.Write(value) } result := handler(args) diff --git a/resp.go b/resp.go index 251abb7..c917b7a 100644 --- a/resp.go +++ b/resp.go @@ -18,7 +18,6 @@ const ( type Value struct { typ string str string - num int bulk string array []Value } @@ -37,13 +36,13 @@ func (r *Resp) readLine() (line []byte, n int, err error) { if err != nil { return nil, 0, err } - n += 1 + n++ line = append(line, b) if len(line) >= 2 && line[len(line)-2] == '\r' { break } } - //discard the \r\n->CRLF => \r - Carriage return \n - Line feed + // discard the \r\n->CRLF => \r - Carriage return \n - Line feed return line[:len(line)-2], n, nil } @@ -138,12 +137,15 @@ func (r *Resp) readBulk() (Value, error) { bulk := make([]byte, len) - r.reader.Read(bulk) + if _, err := r.reader.Read(bulk); err != nil { + return v, err + } v.bulk = string(bulk) - // Read the trailing CRLF - r.readLine() + if _, _, err := r.readLine(); err != nil { + return v, err + } return v, nil } diff --git a/resp_test.go b/resp_test.go new file mode 100644 index 0000000..75ff0cd --- /dev/null +++ b/resp_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "testing" +) + +func TestMarshalString(t *testing.T) { + v := Value{typ: "string", str: "OK"} + expected := "+OK\r\n" + if string(v.Marshal()) != expected { + t.Errorf("Marshal string = %q, want %q", string(v.Marshal()), expected) + } +} + +func TestMarshalBulk(t *testing.T) { + v := Value{typ: "bulk", bulk: "hello"} + expected := "$5\r\nhello\r\n" + if string(v.Marshal()) != expected { + t.Errorf("Marshal bulk = %q, want %q", string(v.Marshal()), expected) + } +} + +func TestMarshalError(t *testing.T) { + v := Value{typ: "error", str: "ERR unknown"} + expected := "-ERR unknown\r\n" + if string(v.Marshal()) != expected { + t.Errorf("Marshal error = %q, want %q", string(v.Marshal()), expected) + } +} + +func TestMarshalNull(t *testing.T) { + v := Value{typ: "null"} + expected := "$-1\r\n" + if string(v.Marshal()) != expected { + t.Errorf("Marshal null = %q, want %q", string(v.Marshal()), expected) + } +} + +func TestMarshalArray(t *testing.T) { + v := Value{ + typ: "array", + array: []Value{ + {typ: "bulk", bulk: "SET"}, + {typ: "bulk", bulk: "key"}, + {typ: "bulk", bulk: "val"}, + }, + } + expected := "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$3\r\nval\r\n" + if string(v.Marshal()) != expected { + t.Errorf("Marshal array = %q, want %q", string(v.Marshal()), expected) + } +} + +func TestReadBulk(t *testing.T) { + input := "$5\r\nhello\r\n" + resp := NewResp(bytes.NewBufferString(input)) + + v, err := resp.Read() + if err != nil { + t.Fatalf("Read bulk: %v", err) + } + if v.typ != "bulk" || v.bulk != "hello" { + t.Errorf("Read bulk = %+v, want {typ:bulk, bulk:hello}", v) + } +} + +func TestReadArray(t *testing.T) { + input := "*2\r\n$3\r\nSET\r\n$3\r\nkey\r\n" + resp := NewResp(bytes.NewBufferString(input)) + + v, err := resp.Read() + if err != nil { + t.Fatalf("Read array: %v", err) + } + if v.typ != "array" { + t.Fatalf("Read array type = %v, want array", v.typ) + } + if len(v.array) != 2 { + t.Errorf("Read array length = %d, want 2", len(v.array)) + } + if v.array[0].bulk != "SET" { + t.Errorf("Read array[0] = %v, want SET", v.array[0].bulk) + } + if v.array[1].bulk != "key" { + t.Errorf("Read array[1] = %v, want key", v.array[1].bulk) + } +} + +func TestRoundTrip(t *testing.T) { + original := Value{ + typ: "array", + array: []Value{ + {typ: "bulk", bulk: "SET"}, + {typ: "bulk", bulk: "mykey"}, + {typ: "bulk", bulk: "myval"}, + }, + } + + marshalled := original.Marshal() + resp := NewResp(bytes.NewBuffer(marshalled)) + + v, err := resp.Read() + if err != nil { + t.Fatalf("RoundTrip Read: %v", err) + } + + if v.typ != "array" { + t.Fatalf("RoundTrip type = %v, want array", v.typ) + } + if len(v.array) != 3 { + t.Errorf("RoundTrip length = %d, want 3", len(v.array)) + } + if v.array[0].bulk != "SET" { + t.Errorf("RoundTrip array[0] = %v, want SET", v.array[0].bulk) + } + if v.array[1].bulk != "mykey" { + t.Errorf("RoundTrip array[1] = %v, want mykey", v.array[1].bulk) + } + if v.array[2].bulk != "myval" { + t.Errorf("RoundTrip array[2] = %v, want myval", v.array[2].bulk) + } +} + +func TestWriter(t *testing.T) { + var buf bytes.Buffer + w := NewWriter(&buf) + + v := Value{typ: "string", str: "OK"} + err := w.Write(v) + if err != nil { + t.Fatalf("Writer.Write: %v", err) + } + + if buf.String() != "+OK\r\n" { + t.Errorf("Writer output = %q, want \"+OK\\r\\n\"", buf.String()) + } +} + +func TestReadEmptyBulk(t *testing.T) { + input := "$0\r\n\r\n" + resp := NewResp(bytes.NewBufferString(input)) + + v, err := resp.Read() + if err != nil { + t.Fatalf("Read empty bulk: %v", err) + } + if v.typ != "bulk" || v.bulk != "" { + t.Errorf("Read empty bulk = %+v, want {typ:bulk, bulk:''}", v) + } +} diff --git a/stdhash.go b/stdhash.go index 5e83f44..776b673 100644 --- a/stdhash.go +++ b/stdhash.go @@ -66,3 +66,23 @@ func hgetall(args []Value) Value { return Value{typ: "array", array: values} } + +func hdel(args []Value) Value { + if len(args) != 2 { + return Value{typ: "error", str: "ERR wrong number of arguments for 'hdel' command"} + } + + hash := args[0].bulk + key := args[1].bulk + + HSETsMu.Lock() + if m, ok := HSETs[hash]; ok { + delete(m, key) + if len(m) == 0 { + delete(HSETs, hash) + } + } + HSETsMu.Unlock() + + return Value{typ: "string", str: "OK"} +} diff --git a/stdhash_test.go b/stdhash_test.go new file mode 100644 index 0000000..72bd1e9 --- /dev/null +++ b/stdhash_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "testing" +) + +func resetHash() { + HSETsMu.Lock() + for k := range HSETs { + delete(HSETs, k) + } + HSETsMu.Unlock() +} + +func TestHsetAndHget(t *testing.T) { + resetHash() + + result := hset([]Value{{typ: "bulk", bulk: "myhash"}, {typ: "bulk", bulk: "field1"}, {typ: "bulk", bulk: "value1"}}) + if result.typ != "string" || result.str != "OK" { + t.Fatalf("HSET returned %+v, want OK", result) + } + + got := hget([]Value{{typ: "bulk", bulk: "myhash"}, {typ: "bulk", bulk: "field1"}}) + if got.typ != "bulk" || got.bulk != "value1" { + t.Errorf("HGET myhash field1 = %+v, want value1", got) + } +} + +func TestHgetMissing(t *testing.T) { + resetHash() + + got := hget([]Value{{typ: "bulk", bulk: "nohash"}, {typ: "bulk", bulk: "nofield"}}) + if got.typ != "null" { + t.Errorf("HGET nonexistent = %+v, want null", got) + } +} + +func TestHsetOverwrite(t *testing.T) { + resetHash() + + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v1"}}) + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}, {typ: "bulk", bulk: "v2"}}) + + got := hget([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.bulk != "v2" { + t.Errorf("after overwrite, HGET = %v, want v2", got.bulk) + } +} + +func TestHgetall(t *testing.T) { + resetHash() + + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f1"}, {typ: "bulk", bulk: "v1"}}) + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f2"}, {typ: "bulk", bulk: "v2"}}) + + got := hgetall([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "array" { + t.Fatalf("HGETALL = %+v, want array", got) + } + if len(got.array) != 4 { + t.Errorf("HGETALL array length = %d, want 4", len(got.array)) + } +} + +func TestHgetallMissing(t *testing.T) { + resetHash() + + got := hgetall([]Value{{typ: "bulk", bulk: "nohash"}}) + if got.typ != "null" { + t.Errorf("HGETALL nonexistent = %+v, want null", got) + } +} + +func TestHsetWrongArgs(t *testing.T) { + got := hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f"}}) + if got.typ != "error" { + t.Errorf("HSET with 2 args = %+v, want error", got) + } +} + +func TestHgetWrongArgs(t *testing.T) { + got := hget([]Value{{typ: "bulk", bulk: "h"}}) + if got.typ != "error" { + t.Errorf("HGET with 1 arg = %+v, want error", got) + } +} + +func TestHgetallWrongArgs(t *testing.T) { + got := hgetall([]Value{}) + if got.typ != "error" { + t.Errorf("HGETALL with no args = %+v, want error", got) + } +} + +func TestHdel(t *testing.T) { + resetHash() + + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f1"}, {typ: "bulk", bulk: "v1"}}) + hset([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f2"}, {typ: "bulk", bulk: "v2"}}) + + result := hdel([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f1"}}) + if result.typ != "string" || result.str != "OK" { + t.Fatalf("HDEL returned %+v, want OK", result) + } + + got := hget([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f1"}}) + if got.typ != "null" { + t.Errorf("after HDEL, HGET f1 = %+v, want null", got) + } + + got = hget([]Value{{typ: "bulk", bulk: "h"}, {typ: "bulk", bulk: "f2"}}) + if got.typ != "bulk" || got.bulk != "v2" { + t.Errorf("after HDEL f1, HGET f2 = %+v, want v2", got) + } +} + +func TestHdelMissingHash(t *testing.T) { + resetHash() + + result := hdel([]Value{{typ: "bulk", bulk: "nohash"}, {typ: "bulk", bulk: "nofield"}}) + if result.typ != "string" || result.str != "OK" { + t.Errorf("HDEL on nonexistent hash = %+v, want OK", result) + } +} + +func TestHdelWrongArgs(t *testing.T) { + got := hdel([]Value{}) + if got.typ != "error" { + t.Errorf("HDEL with no args = %+v, want error", got) + } +}