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)
+ }
+ })
+ }
+ })
}