mirror of
https://github.com/EchoVault/SugarDB.git
synced 2025-09-26 20:11:15 +08:00
Implement HPEXPIRETIME command (#174)
This commit is contained in:
48
docs/docs/commands/hash/hpexpiretime.mdx
Normal file
48
docs/docs/commands/hash/hpexpiretime.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# HPEXPIRETIME
|
||||
|
||||
### Syntax
|
||||
```
|
||||
HPEXPIRETIME key FIELDS numfields field [field...]
|
||||
```
|
||||
|
||||
### Module
|
||||
<span className="acl-category">hash</span>
|
||||
|
||||
### Categories
|
||||
<span className="acl-category">fast</span>
|
||||
<span className="acl-category">hash</span>
|
||||
<span className="acl-category">read</span>
|
||||
|
||||
### 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
|
||||
|
||||
<Tabs
|
||||
defaultValue="go"
|
||||
values={[
|
||||
{ label: 'Go (Embedded)', value: 'go', },
|
||||
{ label: 'CLI', value: 'cli', },
|
||||
]}
|
||||
>
|
||||
<TabItem value="go">
|
||||
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)
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="cli">
|
||||
Get the expiration time in milliseconds for fields in the hash:
|
||||
```
|
||||
> HPEXPIRETIME key FIELDS 2 field1 field2
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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 <key> 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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user