diff --git a/docs/docs/commands/hash/hpexpiretime.mdx b/docs/docs/commands/hash/hpexpiretime.mdx new file mode 100644 index 0000000..290d7b9 --- /dev/null +++ b/docs/docs/commands/hash/hpexpiretime.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HPEXPIRETIME + +### Syntax +``` +HPEXPIRETIME key FIELDS numfields field [field...] +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Returns the remaining TTL (time to live) of a hash key's field(s) that have a set expiration. +This introspection capability allows you to check how many milliseconds a given hash field will continue to be part of the hash key. + +### Examples + + + + Get the expiration time in milliseconds for fields in the hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + TTLArray, err := db.HPEXPIRETIME("key", field1, field2) + ``` + + + Get the expiration time in milliseconds for fields in the hash: + ``` + > HPEXPIRETIME key FIELDS 2 field1 field2 + ``` + + \ No newline at end of file diff --git a/internal/modules/hash/commands.go b/internal/modules/hash/commands.go index 26a3ef2..4641a85 100644 --- a/internal/modules/hash/commands.go +++ b/internal/modules/hash/commands.go @@ -829,6 +829,57 @@ func handleHTTL(params internal.HandlerFuncParams) ([]byte, error) { return []byte(resp), nil } +func handleHPEXPIRETIME(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hpexpiretimeKeyFunc(params.Command) + if err != nil { + return nil, err + } + + cmdargs := keys.ReadKeys[2:] + numfields, err := strconv.ParseInt(cmdargs[0], 10, 64) + if err != nil { + return nil, errors.New(fmt.Sprintf("expire time must be integer, was provided %q", cmdargs[0])) + } + + fields := cmdargs[1 : numfields+1] + // init array response + resp := "*" + fmt.Sprintf("%v", len(fields)) + "\r\n" + + // handle bad key + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + if !keyExists { + return []byte("$-1\r\n"), nil + } + + // handle not a hash + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + for _, field := range fields { + f, ok := hash[field] + if !ok { + // Field doesn't exist + resp += ":-2\r\n" + continue + } + + if f.ExpireAt == (time.Time{}) { + // No expiration set + resp += ":-1\r\n" + continue + } + // Calculate milliseconds until expiration + millisUntilExpire := f.ExpireAt.Sub(params.GetClock().Now()).Milliseconds() + resp += fmt.Sprintf(":%d\r\n", millisUntilExpire) + } + + // build out response + return []byte(resp), nil +} + func Commands() []internal.Command { return []internal.Command{ { @@ -994,5 +1045,14 @@ Return the string length of the values stored at the specified fields. 0 if the KeyExtractionFunc: httlKeyFunc, HandlerFunc: handleHTTL, }, + { + Command: "hpexpiretime", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HPEXPIRETIME key FIELDS numfields field [field ...]) Returns the absolute Unix timestamp in milliseconds since Unix epoch at which the given key's field(s) will expire.`, + Sync: false, + KeyExtractionFunc: hpexpiretimeKeyFunc, + HandlerFunc: handleHPEXPIRETIME, + }, } } diff --git a/internal/modules/hash/commands_test.go b/internal/modules/hash/commands_test.go index e0adcc0..204021d 100644 --- a/internal/modules/hash/commands_test.go +++ b/internal/modules/hash/commands_test.go @@ -2492,4 +2492,219 @@ func Test_Hash(t *testing.T) { } }) + + t.Run("Test_HandleHPEXPIRETIME", 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 + command []string + presetValue interface{} + setExpire bool + expireSeconds string + expectedValue string + expectedError error + }{ + { + name: "1. Single field with regular number", + key: "HPExpireTimeKey1", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey1", "FIELDS", "1", "field1"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: true, + expireSeconds: "500", + expectedValue: "[500000]", // 500 seconds = 500000 milliseconds + expectedError: nil, + }, + { + name: "2. Single field with large number (1 year)", + key: "HPExpireTimeKey2", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey2", "FIELDS", "1", "field1"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: true, + expireSeconds: "31536000", // 1 year in seconds + expectedValue: "[31536000000]", // 1 year in milliseconds + expectedError: nil, + }, + { + name: "3. Single field with very large number (100 years)", + key: "HPExpireTimeKey3", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey3", "FIELDS", "1", "field1"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: true, + expireSeconds: "3153600000", // 100 years in seconds + expectedValue: "[3153600000000]", // 100 years in milliseconds + expectedError: nil, + }, + { + name: "4. Multiple fields with mixed numbers", + key: "HPExpireTimeKey4", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey4", "FIELDS", "3", "field1", "field2", "nonexist"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + "field2": hash.HashValue{ + Value: "default2", + }, + }, + setExpire: true, + expireSeconds: "31536000", // 1 year + expectedValue: "[31536000000 31536000000 -2]", // 1 year in ms, 1 year in ms, non-existent field + expectedError: nil, + }, + { + name: "5. Multiple fields with max allowed number", + key: "HPExpireTimeKey5", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey5", "FIELDS", "3", "field1", "field2", "field3"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + "field2": hash.HashValue{ + Value: "default2", + }, + "field3": hash.HashValue{ + Value: "default3", + }, + }, + setExpire: true, + expireSeconds: "9223372036", // Close to max int64 divided by 1000 for milliseconds + expectedValue: "[9223372036000 9223372036000 9223372036000]", + expectedError: nil, + }, + { + name: "7. Key is not a hash", + key: "HPExpireTimeKey7", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey7", "FIELDS", "1", "field1"}, + presetValue: "string value", + setExpire: false, + expectedError: errors.New("value at HPExpireTimeKey7 is not a hash"), + }, + { + name: "8. Invalid numfields format", + key: "HPExpireTimeKey8", + command: []string{"HPEXPIRETIME", "HPExpireTimeKey8", "FIELDS", "notanumber", "field1"}, + presetValue: hash.Hash{ + "field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedError: errors.New("expire time must be integer, was provided \"notanumber\""), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case hash.Hash: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(hash.Hash) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value.Value.(string))}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(hash.Hash))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be %q, got %q", expected, res.String()) + } + } + + // Set expiration if needed + if test.setExpire && test.presetValue != nil { + if hash, ok := test.presetValue.(hash.Hash); ok { + command := make([]resp.Value, len(hash)+5) + command[0] = resp.StringValue("HEXPIRE") + command[1] = resp.StringValue(test.key) + command[2] = resp.StringValue(test.expireSeconds) + command[3] = resp.StringValue("FIELDS") + command[4] = resp.StringValue(fmt.Sprintf("%v", len(hash))) + + i := 0 + for k := range hash { + command[5+i] = resp.StringValue(k) + i++ + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + _, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + } + } + + // Execute HPEXPIRETIME command + command := make([]resp.Value, len(test.command)) + for i, v := range test.command { + command[i] = resp.StringValue(v) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + resp, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(resp.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error %q, got %q", test.expectedError.Error(), resp.Error()) + } + return + } + + if resp.String() != test.expectedValue { + t.Errorf("Expected value %q but got %q", test.expectedValue, resp.String()) + } + }) + } + }) } diff --git a/internal/modules/hash/key_funcs.go b/internal/modules/hash/key_funcs.go index 3f3b5e5..095f3e1 100644 --- a/internal/modules/hash/key_funcs.go +++ b/internal/modules/hash/key_funcs.go @@ -198,3 +198,19 @@ func httlKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { WriteKeys: make([]string, 0), }, nil } + +func hpexpiretimeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 5 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + if cmd[2] != "FIELDS" { + return internal.KeyExtractionFuncResult{}, errors.New(constants.InvalidCmdResponse) + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} diff --git a/internal/utils.go b/internal/utils.go index 4becbb8..273b881 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -494,3 +494,28 @@ func GetTLSConnection(addr string, port int, config *tls.Config) (net.Conn, erro return conn, err } } + +func ParseInteger64ArrayResponse(b []byte) ([]int64, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + if v.IsNull() { + return []int64{}, nil + } + arr := make([]int64, len(v.Array())) + for i, e := range v.Array() { + if e.IsNull() { + arr[i] = 0 + continue + } + + val, err := strconv.ParseInt(e.String(), 10, 64) + if err != nil { + return nil, err + } + arr[i] = val + } + return arr, nil +} diff --git a/sugardb/api_hash.go b/sugardb/api_hash.go index 05977c1..7bdc554 100644 --- a/sugardb/api_hash.go +++ b/sugardb/api_hash.go @@ -423,3 +423,27 @@ func (server *SugarDB) HTTL(key string, fields ...string) ([]int, error) { } return internal.ParseIntegerArrayResponse(b) } + +// HPExpireTime returns the absolute Unix timestamp in milliseconds for the given field(s) expiration time. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - a list of fields to check expiration time. +// +// Returns: an integer array representing the expiration timestamp in milliseconds for each field. +// If a field doesn't exist or has no expiry set, -1 is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key is not a hash. +func (server *SugarDB) HPExpireTime(key string, fields ...string) ([]int64, error) { + numFields := fmt.Sprintf("%v", len(fields)) + cmd := append([]string{"HPEXPIRETIME", key, "FIELDS", numFields}, fields...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseInteger64ArrayResponse(b) +} diff --git a/sugardb/api_hash_test.go b/sugardb/api_hash_test.go index 028e5e0..aa93036 100644 --- a/sugardb/api_hash_test.go +++ b/sugardb/api_hash_test.go @@ -1163,4 +1163,107 @@ func TestSugarDB_Hash(t *testing.T) { }) } }) + + t.Run("TestSugarDB_HPExpireTime", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want []int64 + wantErr bool + }{ + { + name: "1. Get expiration time for one field", + key: "HPExpireTime_Key1", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + }, + fields: []string{"field1"}, + want: []int64{500000}, + wantErr: false, + }, + { + name: "2. Get expiration time for multiple fields with large expiry", + key: "HPExpireTime_Key2", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(31536000) * time.Second)}, + "field2": {Value: "value2", ExpireAt: server.clock.Now().Add(time.Duration(31536000) * time.Second)}, + "field3": {Value: "value3", ExpireAt: server.clock.Now().Add(time.Duration(31536000) * time.Second)}, + }, + fields: []string{"field1", "field2", "field3"}, // Just field names + want: []int64{31536000000, 31536000000, 31536000000}, + wantErr: false, + }, + { + name: "3. Mix of existing and non-existing fields", + key: "HPExpireTime_Key3", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + "field2": {Value: "value2", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + }, + fields: []string{"field1", "nonexistent", "field2"}, + want: []int64{500000, -2, 500000}, + wantErr: false, + }, + { + name: "4. Fields with no expiration set", + key: "HPExpireTime_Key4", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: "value2"}, + }, + fields: []string{"field1", "field2"}, + want: []int64{-1, -1}, + wantErr: false, + }, + { + name: "5. Test with maximum allowed expiration", + key: "HPExpireTime_Key5", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(9223372036) * time.Second)}, + }, + fields: []string{"field1"}, + want: []int64{9223372036000}, + wantErr: false, + }, + { + name: "6. Key doesn't exist", + key: "HPExpireTime_Key6", + presetValue: nil, + fields: []string{"field1"}, + want: []int64{}, + wantErr: false, + }, + { + name: "7. Key is not a hash", + key: "HPExpireTime_Key7", + presetValue: "not a hash", + fields: []string{"field1"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HPExpireTime(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HPExpireTime() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HPExpireTime() got = %v, want %v", got, tt.want) + } + }) + } + }) }