diff --git a/README.md b/README.md index ae9ecf3..bd3c9b2 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,4 @@ See the [Usage Guide](./docs/usage.md) for installation, connecting, and a walkt | [EXPIRE](./docs/expire.md) | Set a TTL on an existing key (in seconds) | | [KEYS](./docs/keys.md) | Find keys matching a pattern | | [CONFIG](./docs/config.md) | Read and modify server configuration | +| [TYPE](./docs/type.md) | Return the type of the value stored at a key | diff --git a/cmd/get.go b/cmd/get.go index c9b2056..6001b8a 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -32,5 +32,5 @@ func GetValueFromMemory(key string) (string, error) { if !ok { return "", fmt.Errorf("key not found") } - return data.(string), nil + return data.(db.Value).Raw, nil } diff --git a/cmd/set.go b/cmd/set.go index 4d9a31d..ec72951 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -188,7 +188,7 @@ func processSetArgs(key string, args map[string]any) error { return nil } -// setValueInMemory stores the value in the in-memory DB against the key. +// setValueInMemory detects the type of value and stores it in the in-memory DB. func setValueInMemory(key string, value string) { - db.DB.Store(key, value) + db.DB.Store(key, db.NewValue(value)) } diff --git a/cmd/type.go b/cmd/type.go new file mode 100644 index 0000000..b942146 --- /dev/null +++ b/cmd/type.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "HelixDB/common" + "HelixDB/db" + "time" +) + +// Type returns the type of the value stored at key. +// Returns "none" if the key does not exist or has expired. +// Possible return values: string, integer, float, boolean, json, none. +func Type(command common.Cmd) ([]byte, error) { + if len(command.Args) != 1 { + err := common.WrongNumberOfArgsError(command.Name) + return common.RespError(err.Error()), err + } + + key := command.Args[0] + + expirationTime, ok := db.KeyTTL.Load(key) + if !ok { + return common.RespSimpleString("none"), nil + } + if expirationTime != nil && expirationTime.(int64) < time.Now().UnixMilli() { + return common.RespSimpleString("none"), nil + } + + data, ok := db.DB.Load(key) + if !ok { + return common.RespSimpleString("none"), nil + } + + return common.RespSimpleString(string(data.(db.Value).Type)), nil +} diff --git a/cmd/type_test.go b/cmd/type_test.go new file mode 100644 index 0000000..8d4c5fd --- /dev/null +++ b/cmd/type_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "HelixDB/common" + "HelixDB/db" + "errors" + "reflect" + "testing" +) + +// TestType tests the Type command for all possible inputs. +func TestType(t *testing.T) { + // Seed keys of each type + db.DB.Store("type_string", db.NewValue("hello")) + db.KeyTTL.Store("type_string", nil) + + db.DB.Store("type_integer", db.NewValue("42")) + db.KeyTTL.Store("type_integer", nil) + + db.DB.Store("type_float", db.NewValue("3.14")) + db.KeyTTL.Store("type_float", nil) + + db.DB.Store("type_boolean", db.NewValue("true")) + db.KeyTTL.Store("type_boolean", nil) + + db.DB.Store("type_json", db.NewValue(`{"a":1}`)) + db.KeyTTL.Store("type_json", nil) + + tests := []struct { + command common.Cmd + want []byte + wantErr error + }{ + {common.Cmd{Name: "TYPE", Args: []string{"type_string"}}, common.RespSimpleString("string"), nil}, + {common.Cmd{Name: "TYPE", Args: []string{"type_integer"}}, common.RespSimpleString("integer"), nil}, + {common.Cmd{Name: "TYPE", Args: []string{"type_float"}}, common.RespSimpleString("float"), nil}, + {common.Cmd{Name: "TYPE", Args: []string{"type_boolean"}}, common.RespSimpleString("boolean"), nil}, + {common.Cmd{Name: "TYPE", Args: []string{"type_json"}}, common.RespSimpleString("json"), nil}, + // Non-existent key + {common.Cmd{Name: "TYPE", Args: []string{"no_such_key"}}, common.RespSimpleString("none"), nil}, + // Wrong number of arguments + {common.Cmd{Name: "TYPE"}, common.RespError("wrong number of arguments for 'type' command"), common.ErrWrongNumberOfArgs}, + {common.Cmd{Name: "TYPE", Args: []string{"a", "b"}}, common.RespError("wrong number of arguments for 'type' command"), common.ErrWrongNumberOfArgs}, + } + for _, test := range tests { + got, gotErr := Type(test.command) + if !reflect.DeepEqual(got, test.want) || !errors.Is(gotErr, test.wantErr) { + t.Errorf("Type(%v) = %v, %v; want %v, %v", test.command, got, gotErr, test.want, test.wantErr) + } + } +} diff --git a/common/constants.go b/common/constants.go index 8fec306..54654e7 100644 --- a/common/constants.go +++ b/common/constants.go @@ -13,6 +13,7 @@ const Expire = "EXPIRE" const Del = "DEL" const Keys = "KEYS" const Config = "CONFIG" +const Type = "TYPE" // Command Args // Expiration Args diff --git a/common/resp.go b/common/resp.go index 2c38879..15fedb0 100644 --- a/common/resp.go +++ b/common/resp.go @@ -37,6 +37,11 @@ func RespBulkStringArray(items []string) []byte { return buffer.Bytes() } +// RespSimpleString formats a value as a RESP simple string: +value\r\n +func RespSimpleString(value string) []byte { + return []byte("+" + value + Terminator) +} + func RespInteger(n int) []byte { var buffer bytes.Buffer buffer.WriteString(":") diff --git a/db/value.go b/db/value.go new file mode 100644 index 0000000..784a734 --- /dev/null +++ b/db/value.go @@ -0,0 +1,44 @@ +package db + +import ( + "encoding/json" + "strconv" +) + +// ValueType represents the detected type of a stored value. +type ValueType string + +const ( + TypeString ValueType = "string" + TypeInteger ValueType = "integer" + TypeFloat ValueType = "float" + TypeBoolean ValueType = "boolean" + TypeJSON ValueType = "json" +) + +// Value wraps a stored string with its detected type. +// Raw always holds the original string representation so GET can return it +// unchanged, regardless of the detected type. +type Value struct { + Type ValueType + Raw string +} + +// NewValue detects the type of raw and returns a Value. +// Detection order: integer → float → boolean → JSON → string. +func NewValue(raw string) Value { + if _, err := strconv.ParseInt(raw, 10, 64); err == nil { + return Value{Type: TypeInteger, Raw: raw} + } + if _, err := strconv.ParseFloat(raw, 64); err == nil { + return Value{Type: TypeFloat, Raw: raw} + } + if _, err := strconv.ParseBool(raw); err == nil { + return Value{Type: TypeBoolean, Raw: raw} + } + var js json.RawMessage + if err := json.Unmarshal([]byte(raw), &js); err == nil { + return Value{Type: TypeJSON, Raw: raw} + } + return Value{Type: TypeString, Raw: raw} +} diff --git a/db/value_test.go b/db/value_test.go new file mode 100644 index 0000000..6377ae3 --- /dev/null +++ b/db/value_test.go @@ -0,0 +1,40 @@ +package db + +import ( + "testing" +) + +// TestNewValue tests that NewValue correctly detects the type of a raw string. +func TestNewValue(t *testing.T) { + tests := []struct { + raw string + wantType ValueType + }{ + // Integers + {"42", TypeInteger}, + {"-1", TypeInteger}, + {"0", TypeInteger}, + // Floats + {"3.14", TypeFloat}, + {"-0.5", TypeFloat}, + // Booleans + {"true", TypeBoolean}, + {"false", TypeBoolean}, + // JSON objects and arrays + {`{"key":"value"}`, TypeJSON}, + {`[1,2,3]`, TypeJSON}, + // Plain strings + {"hello", TypeString}, + {"", TypeString}, + {"hello world", TypeString}, + } + for _, test := range tests { + got := NewValue(test.raw) + if got.Type != test.wantType { + t.Errorf("NewValue(%q).Type = %q; want %q", test.raw, got.Type, test.wantType) + } + if got.Raw != test.raw { + t.Errorf("NewValue(%q).Raw = %q; want %q", test.raw, got.Raw, test.raw) + } + } +} diff --git a/docs/README.md b/docs/README.md index 55b0587..0f78db6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,3 +16,4 @@ | [EXPIRE](./expire.md) | Set a TTL on an existing key (in seconds) | | [KEYS](./keys.md) | Find keys matching a pattern | | [CONFIG](./config.md) | Read and modify server configuration | +| [TYPE](./type.md) | Return the type of the value stored at a key | diff --git a/docs/type.md b/docs/type.md new file mode 100644 index 0000000..8869e51 --- /dev/null +++ b/docs/type.md @@ -0,0 +1,65 @@ +# TYPE + +Returns the type of the value stored at a key. + +> **Note:** HelixDB's `TYPE` command returns the *value type* (the detected type of the stored string), not the *data structure type* as Redis does. In Redis, `TYPE` always returns `string` for any string key. HelixDB extends this to distinguish integers, floats, booleans, and JSON. When data structure types (List, Hash, Set, Sorted Set) are added in future, `TYPE` will return their names as Redis does. + +## Syntax + +``` +TYPE key +``` + +## Return value + +One of the following strings: + +| Value | Meaning | +|-----------|------------------------------------------| +| `string` | A plain string value | +| `integer` | A whole number (e.g. `42`, `-1`) | +| `float` | A decimal number (e.g. `3.14`) | +| `boolean` | `true` or `false` | +| `json` | A valid JSON object or array | +| `none` | Key does not exist or has expired | + +## Errors + +- `wrong number of arguments for 'type' command` — incorrect number of arguments. + +## Examples + +``` +> SET counter 42 +OK + +> TYPE counter +integer + +> SET ratio 3.14 +OK + +> TYPE ratio +float + +> SET active true +OK + +> TYPE active +boolean + +> SET payload {"user":"alice","age":30} +OK + +> TYPE payload +json + +> SET greeting hello +OK + +> TYPE greeting +string + +> TYPE missing +none +``` diff --git a/exc/command_executor.go b/exc/command_executor.go index f374de5..449915c 100644 --- a/exc/command_executor.go +++ b/exc/command_executor.go @@ -28,6 +28,8 @@ func ExecuteCommand(command common.Cmd) ([]byte, error) { return cmd.Keys(command) case common.Config: return cmd.ConfigCmd(command) + case common.Type: + return cmd.Type(command) } return []byte("-unsupported command\r\n"), UnsupportedCommand }