feat(command): add support for INCREX (#3816)

* feat(command): add increx

* add one additional test

* update increx

* use custom 8.8 image with new increx

* address pr comments

* always use by in float
This commit is contained in:
Nedyalko Dyakov
2026-05-21 14:08:15 +03:00
committed by GitHub
parent b88fd9b372
commit c5f08a5847
9 changed files with 668 additions and 19 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ runs:
elif [[ -n "$REDIS_VERSION" ]]; then
# Mapping of redis version to redis testing containers
declare -A redis_version_mapping=(
["8.8.x"]="8.8-rc1"
["8.8.x"]="custom-26172898734-debian"
["8.6.x"]="8.6.1"
["8.4.x"]="8.4.0"
["8.2.x"]="8.2.1-pre"
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# Mapping of redis version to redis testing containers
declare -A redis_version_mapping=(
["8.8.x"]="8.8-rc1"
["8.8.x"]="custom-26172898734-debian"
["8.6.x"]="8.6.1"
["8.4.x"]="8.4.0"
["8.2.x"]="8.2.1-pre"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
services:
redis-stack:
image: redislabs/client-libs-test:8.8-rc1
image: redislabs/client-libs-test:custom-26172898734-debian
env:
TLS_ENABLED: no
REDIS_CLUSTER: no
+1 -1
View File
@@ -2,7 +2,7 @@ GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort)
REDIS_VERSION ?= 8.8
RE_CLUSTER ?= false
RCE_DOCKER ?= true
CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.8-rc1
CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:custom-26172898734-debian
docker.start:
export RE_CLUSTER=$(RE_CLUSTER) && \
+133 -2
View File
@@ -156,6 +156,8 @@ const (
CmdTypeTSTimestampValue
CmdTypeTSTimestampValueSlice
CmdTypeHotKeys
CmdTypeIncrEXInt
CmdTypeIncrEXFloat
)
type (
@@ -4937,8 +4939,10 @@ func (c *cmdsInfoCache) Refresh() {
}
// ------------------------------------------------------------------------------
const requestPolicy = "request_policy"
const responsePolicy = "response_policy"
const (
requestPolicy = "request_policy"
responsePolicy = "response_policy"
)
func parseCommandPolicies(commandInfoTips map[string]string, firstKeyPos int8) *routing.CommandPolicy {
req := routing.ReqDefault
@@ -8259,6 +8263,20 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) {
}); ok {
return hotKeysCmd.Val(), hotKeysCmd.Err()
}
case CmdTypeIncrEXInt:
if incrEXCmd, ok := cmd.(interface {
Val() IncrEXIntResult
Err() error
}); ok {
return incrEXCmd.Val(), incrEXCmd.Err()
}
case CmdTypeIncrEXFloat:
if incrEXCmd, ok := cmd.(interface {
Val() IncrEXFloatResult
Err() error
}); ok {
return incrEXCmd.Val(), incrEXCmd.Err()
}
case CmdTypeKeyValues:
if keyValuesCmd, ok := cmd.(interface {
Val() (string, []string)
@@ -8557,3 +8575,116 @@ func ExtractCommandValue(cmd interface{}) (interface{}, error) {
// If we can't get the command type, return nil
return nil, nil
}
//------------------------------------------------------------------------------
// IncrEXIntResult is the reply of an INCREX command issued via IncrEXInt.
// Value is the new value of the key; AppliedIncrement is the increment that
// the server actually applied (0 when an out-of-bounds operation was
// rejected, clamped when SATURATE was set).
type IncrEXIntResult struct {
Value int64
AppliedIncrement int64
}
type IncrEXIntCmd struct {
baseCmd
val IncrEXIntResult
}
var _ Cmder = (*IncrEXIntCmd)(nil)
func NewIncrEXIntCmd(ctx context.Context, args ...interface{}) *IncrEXIntCmd {
return &IncrEXIntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIncrEXInt,
},
}
}
func (cmd *IncrEXIntCmd) SetVal(val IncrEXIntResult) { cmd.val = val }
func (cmd *IncrEXIntCmd) Val() IncrEXIntResult { return cmd.val }
func (cmd *IncrEXIntCmd) Result() (IncrEXIntResult, error) {
return cmd.val, cmd.err
}
func (cmd *IncrEXIntCmd) String() string { return cmdString(cmd, cmd.val) }
func (cmd *IncrEXIntCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
value, err := rd.ReadInt()
if err != nil {
return err
}
applied, err := rd.ReadInt()
if err != nil {
return err
}
cmd.val = IncrEXIntResult{Value: value, AppliedIncrement: applied}
return nil
}
func (cmd *IncrEXIntCmd) Clone() Cmder {
return &IncrEXIntCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
// IncrEXFloatResult is the reply of an INCREX command issued via IncrEXFloat.
type IncrEXFloatResult struct {
Value float64
AppliedIncrement float64
}
type IncrEXFloatCmd struct {
baseCmd
val IncrEXFloatResult
}
var _ Cmder = (*IncrEXFloatCmd)(nil)
func NewIncrEXFloatCmd(ctx context.Context, args ...interface{}) *IncrEXFloatCmd {
return &IncrEXFloatCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeIncrEXFloat,
},
}
}
func (cmd *IncrEXFloatCmd) SetVal(val IncrEXFloatResult) { cmd.val = val }
func (cmd *IncrEXFloatCmd) Val() IncrEXFloatResult { return cmd.val }
func (cmd *IncrEXFloatCmd) Result() (IncrEXFloatResult, error) {
return cmd.val, cmd.err
}
func (cmd *IncrEXFloatCmd) String() string { return cmdString(cmd, cmd.val) }
func (cmd *IncrEXFloatCmd) readReply(rd *proto.Reader) error {
if err := rd.ReadFixedArrayLen(2); err != nil {
return err
}
value, err := rd.ReadFloat()
if err != nil {
return err
}
applied, err := rd.ReadFloat()
if err != nil {
return err
}
cmd.val = IncrEXFloatResult{Value: value, AppliedIncrement: applied}
return nil
}
func (cmd *IncrEXFloatCmd) Clone() Cmder {
return &IncrEXFloatCmd{
baseCmd: cmd.cloneBaseCmd(),
val: cmd.val,
}
}
+224 -12
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha1"
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"time"
@@ -368,7 +369,6 @@ var _ = Describe("Commands", func() {
defer client2.Close()
clientInfo = client2.ClientInfo(ctx).Val()
Expect(clientInfo.LibName).To(ContainSubstring("go-redis(suffix,"))
})
It("should ConfigGet", func() {
@@ -2067,6 +2067,209 @@ var _ = Describe("Commands", func() {
Expect(incrByFloat.Val()).To(Equal(float64(996945661)))
})
It("should IncrEXInt default", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "10", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(11)))
Expect(res.AppliedIncrement).To(Equal(int64(1)))
Expect(client.Get(ctx, "key").Val()).To(Equal("11"))
})
It("should IncrEXInt BYINT", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "20", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{By: 4, HasBy: true}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(24)))
Expect(res.AppliedIncrement).To(Equal(int64(4)))
})
It("should IncrEXFloat with bounds", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "1.5", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXFloat(ctx, "key", redis.IncrEXFloatArgs{
By: 0.25,
LBound: 0,
HasLBound: true,
UBound: 2,
HasUBound: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(BeNumerically("~", 1.75, 1e-9))
Expect(res.AppliedIncrement).To(BeNumerically("~", 0.25, 1e-9))
})
It("should IncrEXFloat saturating overflow", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "1.8", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXFloat(ctx, "key", redis.IncrEXFloatArgs{
By: 0.7,
UBound: 2,
HasUBound: true,
Saturate: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(BeNumerically("~", 2.0, 1e-9))
Expect(res.AppliedIncrement).To(BeNumerically("~", 0.2, 1e-9))
})
It("should IncrEXInt out-of-bounds default rejects", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "10", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 5, HasBy: true,
UBound: 12, HasUBound: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(10)))
Expect(res.AppliedIncrement).To(Equal(int64(0)))
Expect(client.Get(ctx, "key").Val()).To(Equal("10"))
})
It("should IncrEXInt out-of-bounds with LBOUND default rejects", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "5", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 1, HasBy: true,
LBound: 10, HasLBound: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(5)))
Expect(res.AppliedIncrement).To(Equal(int64(0)))
Expect(client.Get(ctx, "key").Val()).To(Equal("5"))
})
It("should IncrEXInt with EX expiration and ENX", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "40", 0).Err()).NotTo(HaveOccurred())
Expect(client.TTL(ctx, "key").Val()).To(Equal(time.Duration(-1)))
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 2, HasBy: true,
Expiration: &redis.ExpirationOption{Mode: redis.EX, Value: 60},
ENX: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(42)))
Expect(res.AppliedIncrement).To(Equal(int64(2)))
ttl1 := client.TTL(ctx, "key").Val()
Expect(ttl1).To(BeNumerically(">", 0))
Expect(ttl1).To(BeNumerically("<=", 60*time.Second))
// ENX should NOT replace an existing TTL.
res, err = client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 3, HasBy: true,
Expiration: &redis.ExpirationOption{Mode: redis.EX, Value: 600},
ENX: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(45)))
ttl2 := client.TTL(ctx, "key").Val()
Expect(ttl2).To(BeNumerically(">", 0))
Expect(ttl2).To(BeNumerically("<=", ttl1))
})
It("should IncrEXInt SATURATE clamps to LBOUND with negative increment", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "credits", "50", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "credits", redis.IncrEXIntArgs{
By: -1, HasBy: true,
LBound: 0, HasLBound: true,
Saturate: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(49)))
Expect(res.AppliedIncrement).To(Equal(int64(-1)))
})
It("should IncrEXInt PERSIST removes TTL", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "1", 60*time.Second).Err()).NotTo(HaveOccurred())
Expect(client.TTL(ctx, "key").Val()).To(BeNumerically(">", 0))
_, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 1, HasBy: true,
Expiration: &redis.ExpirationOption{Mode: redis.PERSIST},
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(client.TTL(ctx, "key").Val()).To(Equal(time.Duration(-1)))
})
It("should IncrEXInt SATURATE without bounds clamps to LLONG_MAX", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
// Start near LLONG_MAX; with no UBOUND the server should clamp to LLONG_MAX.
Expect(client.Set(ctx, "key", "9223372036854775800", 0).Err()).NotTo(HaveOccurred())
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 1000, HasBy: true,
Saturate: true,
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(math.MaxInt64)))
Expect(res.AppliedIncrement).To(Equal(int64(math.MaxInt64 - 9223372036854775800)))
})
It("should IncrEXInt default reject leaves TTL untouched", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "10", 60*time.Second).Err()).NotTo(HaveOccurred())
ttlBefore := client.TTL(ctx, "key").Val()
Expect(ttlBefore).To(BeNumerically(">", 0))
// Out-of-bounds increment with an EX clause — server should reject
// and NOT apply the new TTL.
res, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 5, HasBy: true,
UBound: 12, HasUBound: true,
Expiration: &redis.ExpirationOption{Mode: redis.EX, Value: 6000},
}).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res.Value).To(Equal(int64(10)))
Expect(res.AppliedIncrement).To(Equal(int64(0)))
ttlAfter := client.TTL(ctx, "key").Val()
// Original TTL was ~60s; rejected EX 6000s must not have been applied.
Expect(ttlAfter).To(BeNumerically("<=", ttlBefore))
Expect(ttlAfter).To(BeNumerically(">", 0))
Expect(ttlAfter).To(BeNumerically("<=", 60*time.Second))
})
It("should IncrEXInt on non-numeric key returns error", func() {
SkipBeforeRedisVersion(8.8, "IncrEX is available since Redis 8.8")
Expect(client.Set(ctx, "key", "not-a-number", 0).Err()).NotTo(HaveOccurred())
_, err := client.IncrEXInt(ctx, "key", redis.IncrEXIntArgs{
By: 1, HasBy: true,
}).Result()
Expect(err).To(HaveOccurred())
Expect(client.Get(ctx, "key").Val()).To(Equal("not-a-number"))
})
It("should MSetMGet", func() {
mSet := client.MSet(ctx, "key1", "hello1", "key2", "hello2")
Expect(mSet.Err()).NotTo(HaveOccurred())
@@ -6541,17 +6744,20 @@ var _ = Describe("Commands", func() {
Expect(err).NotTo(HaveOccurred())
vals, err := client.ZRevRangeByScore(
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{"three", "two", "one"}))
vals, err = client.ZRevRangeByScore(
ctx, "zset", &redis.ZRangeBy{Max: "2", Min: "(1"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "2", Min: "(1"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{"two"}))
vals, err = client.ZRevRangeByScore(
ctx, "zset", &redis.ZRangeBy{Max: "(2", Min: "(1"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "(2", Min: "(1"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{}))
})
@@ -6565,17 +6771,20 @@ var _ = Describe("Commands", func() {
Expect(err).NotTo(HaveOccurred())
vals, err := client.ZRevRangeByLex(
ctx, "zset", &redis.ZRangeBy{Max: "+", Min: "-"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "+", Min: "-"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{"c", "b", "a"}))
vals, err = client.ZRevRangeByLex(
ctx, "zset", &redis.ZRangeBy{Max: "[b", Min: "(a"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "[b", Min: "(a"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{"b"}))
vals, err = client.ZRevRangeByLex(
ctx, "zset", &redis.ZRangeBy{Max: "(b", Min: "(a"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "(b", Min: "(a"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]string{}))
})
@@ -6589,7 +6798,8 @@ var _ = Describe("Commands", func() {
Expect(err).NotTo(HaveOccurred())
vals, err := client.ZRevRangeByScoreWithScores(
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]redis.Z{{
Score: 3,
@@ -6612,7 +6822,8 @@ var _ = Describe("Commands", func() {
Expect(err).NotTo(HaveOccurred())
vals, err := client.ZRevRangeByScoreWithScores(
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "+inf", Min: "-inf"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]redis.Z{{
Score: 3,
@@ -6626,12 +6837,14 @@ var _ = Describe("Commands", func() {
}}))
vals, err = client.ZRevRangeByScoreWithScores(
ctx, "zset", &redis.ZRangeBy{Max: "2", Min: "(1"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "2", Min: "(1"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]redis.Z{{Score: 2, Member: "two"}}))
vals, err = client.ZRevRangeByScoreWithScores(
ctx, "zset", &redis.ZRangeBy{Max: "(2", Min: "(1"}).Result()
ctx, "zset", &redis.ZRangeBy{Max: "(2", Min: "(1"},
).Result()
Expect(err).NotTo(HaveOccurred())
Expect(vals).To(Equal([]redis.Z{}))
})
@@ -7723,7 +7936,6 @@ var _ = Describe("Commands", func() {
Values: map[string]interface{}{"tres": "troix"},
}}))
Expect(ids).To(Equal([]string{"2-0"}))
})
It("should XClaim", func() {
+1 -1
View File
@@ -1,6 +1,6 @@
---
x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-rc1}
x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:custom-26172898734-debian}
services:
redis:
+128
View File
@@ -3,6 +3,7 @@ package redis
import (
"context"
"fmt"
"strings"
"time"
)
@@ -20,6 +21,8 @@ type StringCmdable interface {
Incr(ctx context.Context, key string) *IntCmd
IncrBy(ctx context.Context, key string, value int64) *IntCmd
IncrByFloat(ctx context.Context, key string, value float64) *FloatCmd
IncrEXInt(ctx context.Context, key string, args IncrEXIntArgs) *IncrEXIntCmd
IncrEXFloat(ctx context.Context, key string, args IncrEXFloatArgs) *IncrEXFloatCmd
LCS(ctx context.Context, q *LCSQuery) *LCSCmd
MGet(ctx context.Context, keys ...string) *SliceCmd
MSet(ctx context.Context, values ...interface{}) *StatusCmd
@@ -197,6 +200,129 @@ func (c cmdable) IncrByFloat(ctx context.Context, key string, value float64) *Fl
return cmd
}
// IncrEXIntArgs are the arguments to IncrEXInt (the BYINT variant of INCREX).
//
// If By is zero and HasBy is false, the server increments by 1.
// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is a
// valid bound. Expiration is shared with the SET command via ExpirationOption.
type IncrEXIntArgs struct {
By int64
HasBy bool
LBound, UBound int64
HasLBound, HasUBound bool
// Saturate clamps the result to LBOUND/UBOUND (or LLONG_MAX/MIN when no
// explicit bound is given) when the increment would exceed it. Without
// this flag, out-of-bounds operations are rejected: the key and TTL are
// left unchanged and the reply is [current_value, 0].
Saturate bool
// Expiration sets the TTL semantics: EX, PX, EXAT, PXAT, or PERSIST.
Expiration *ExpirationOption
// ENX applies the expiration only when the key does not already have an
// expiration. Requires Expiration to set one of EX/PX/EXAT/PXAT.
ENX bool
}
// IncrEXFloatArgs are the arguments to IncrEXFloat (the BYFLOAT variant of
// INCREX). BYFLOAT is always sent — even when By is zero — to keep the
// operation in float mode on the server side; omitting BYFLOAT would cause
// the server to treat the call as an integer increment by 1.
// HasLBound/HasUBound gate the optional LBOUND/UBOUND clauses so that 0 is
// a valid bound.
type IncrEXFloatArgs struct {
By float64
LBound, UBound float64
HasLBound, HasUBound bool
// Saturate clamps the result to LBOUND/UBOUND (or ±LDBL_MAX when no
// explicit bound is given) when the increment would exceed it. Without
// this flag, out-of-bounds operations are rejected: the key and TTL are
// left unchanged and the reply is [current_value, 0].
Saturate bool
Expiration *ExpirationOption
ENX bool
}
// IncrEXInt Redis `INCREX key [BYINT amount] [LBOUND value] [UBOUND value]
// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]`
// command.
//
// Atomically increments the integer value stored at key, optionally
// constraining the result to a range and applying expiration semantics.
// Returns the new value and the increment that was actually applied. When
// the increment would exceed LBOUND/UBOUND and SATURATE is not set, the key
// and TTL are left unchanged and the reply is [current_value, 0].
//
// Available since Redis 8.8.
// For more information, see https://redis.io/commands/increx
func (c cmdable) IncrEXInt(ctx context.Context, key string, a IncrEXIntArgs) *IncrEXIntCmd {
args := make([]interface{}, 0, 14)
args = append(args, "increx", key)
if a.HasBy {
args = append(args, "byint", a.By)
}
if a.HasLBound {
args = append(args, "lbound", a.LBound)
}
if a.HasUBound {
args = append(args, "ubound", a.UBound)
}
if a.Saturate {
args = append(args, "saturate")
}
args = appendIncrEXTail(args, a.Expiration, a.ENX)
cmd := NewIncrEXIntCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
// IncrEXFloat Redis `INCREX key [BYFLOAT amount] [LBOUND value] [UBOUND value]
// [SATURATE] [EX seconds | PX ms | EXAT ts | PXAT ts | PERSIST] [ENX]`
// command.
//
// Available since Redis 8.8.
// For more information, see https://redis.io/commands/increx
func (c cmdable) IncrEXFloat(ctx context.Context, key string, a IncrEXFloatArgs) *IncrEXFloatCmd {
args := make([]interface{}, 0, 14)
args = append(args, "increx", key, "byfloat", a.By)
if a.HasLBound {
args = append(args, "lbound", a.LBound)
}
if a.HasUBound {
args = append(args, "ubound", a.UBound)
}
if a.Saturate {
args = append(args, "saturate")
}
args = appendIncrEXTail(args, a.Expiration, a.ENX)
cmd := NewIncrEXFloatCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
func appendIncrEXTail(args []interface{}, exp *ExpirationOption, enx bool) []interface{} {
if exp != nil {
switch exp.Mode {
case EX, PX, EXAT, PXAT:
args = append(args, strings.ToLower(string(exp.Mode)), exp.Value)
case PERSIST:
args = append(args, "persist")
}
}
if enx {
args = append(args, "enx")
}
return args
}
type SetCondition string
const (
@@ -219,6 +345,8 @@ const (
PXAT ExpirationMode = "PXAT"
// KEEPTTL keeps the existing TTL
KEEPTTL ExpirationMode = "KEEPTTL"
// PERSIST removes the existing TTL. Used by INCREX.
PERSIST ExpirationMode = "PERSIST"
)
type ExpirationOption struct {
+178
View File
@@ -0,0 +1,178 @@
package redis
import (
"context"
"reflect"
"testing"
)
// captureCmdable returns a cmdable that records the Cmder passed to it without
// dispatching the command. Used to assert that command builders produce the
// expected argument list.
func captureCmdable(out *Cmder) cmdable {
return func(_ context.Context, cmd Cmder) error {
*out = cmd
return nil
}
}
func TestIncrEXInt_Args(t *testing.T) {
tests := []struct {
name string
args IncrEXIntArgs
want []interface{}
}{
{
name: "default",
args: IncrEXIntArgs{},
want: []interface{}{"increx", "key"},
},
{
name: "byint",
args: IncrEXIntArgs{By: 4, HasBy: true},
want: []interface{}{"increx", "key", "byint", int64(4)},
},
{
name: "byint_zero_explicit",
args: IncrEXIntArgs{By: 0, HasBy: true},
want: []interface{}{"increx", "key", "byint", int64(0)},
},
{
name: "lbound_ubound",
args: IncrEXIntArgs{
By: 2, HasBy: true,
LBound: 0, HasLBound: true,
UBound: 20, HasUBound: true,
},
want: []interface{}{"increx", "key", "byint", int64(2), "lbound", int64(0), "ubound", int64(20)},
},
{
name: "lbound_zero_is_sent",
args: IncrEXIntArgs{
LBound: 0, HasLBound: true,
},
want: []interface{}{"increx", "key", "lbound", int64(0)},
},
{
name: "ex_and_enx",
args: IncrEXIntArgs{
By: 2, HasBy: true,
Expiration: &ExpirationOption{Mode: EX, Value: 60},
ENX: true,
},
want: []interface{}{"increx", "key", "byint", int64(2), "ex", int64(60), "enx"},
},
{
name: "px",
args: IncrEXIntArgs{Expiration: &ExpirationOption{Mode: PX, Value: 1500}},
want: []interface{}{"increx", "key", "px", int64(1500)},
},
{
name: "exat",
args: IncrEXIntArgs{Expiration: &ExpirationOption{Mode: EXAT, Value: 1753265054}},
want: []interface{}{"increx", "key", "exat", int64(1753265054)},
},
{
name: "pxat",
args: IncrEXIntArgs{Expiration: &ExpirationOption{Mode: PXAT, Value: 1753265054014}},
want: []interface{}{"increx", "key", "pxat", int64(1753265054014)},
},
{
name: "persist",
args: IncrEXIntArgs{Expiration: &ExpirationOption{Mode: PERSIST}},
want: []interface{}{"increx", "key", "persist"},
},
{
name: "saturate_with_lbound",
args: IncrEXIntArgs{
By: 1, HasBy: true,
LBound: 10, HasLBound: true,
Saturate: true,
},
want: []interface{}{"increx", "key", "byint", int64(1), "lbound", int64(10), "saturate"},
},
{
name: "saturate_with_ubound",
args: IncrEXIntArgs{
By: 5, HasBy: true,
UBound: 12, HasUBound: true,
Saturate: true,
},
want: []interface{}{"increx", "key", "byint", int64(5), "ubound", int64(12), "saturate"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var captured Cmder
c := captureCmdable(&captured)
cmd := c.IncrEXInt(context.Background(), "key", tt.args)
if cmd == nil {
t.Fatalf("IncrEXInt returned nil")
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}
func TestIncrEXFloat_Args(t *testing.T) {
tests := []struct {
name string
args IncrEXFloatArgs
want []interface{}
}{
{
name: "default_zero_by",
args: IncrEXFloatArgs{},
want: []interface{}{"increx", "key", "byfloat", float64(0)},
},
{
name: "byfloat",
args: IncrEXFloatArgs{By: 0.25},
want: []interface{}{"increx", "key", "byfloat", 0.25},
},
{
name: "bounds_and_saturate",
args: IncrEXFloatArgs{
By: 0.7,
UBound: 2, HasUBound: true,
Saturate: true,
},
want: []interface{}{"increx", "key", "byfloat", 0.7, "ubound", float64(2), "saturate"},
},
{
name: "lbound_ubound",
args: IncrEXFloatArgs{
By: 0.5,
LBound: -1.5, HasLBound: true,
UBound: 1.5, HasUBound: true,
},
want: []interface{}{"increx", "key", "byfloat", 0.5, "lbound", -1.5, "ubound", 1.5},
},
{
name: "ex_and_enx",
args: IncrEXFloatArgs{
By: 1.5,
Expiration: &ExpirationOption{Mode: EX, Value: 30},
ENX: true,
},
want: []interface{}{"increx", "key", "byfloat", 1.5, "ex", int64(30), "enx"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var captured Cmder
c := captureCmdable(&captured)
cmd := c.IncrEXFloat(context.Background(), "key", tt.args)
if cmd == nil {
t.Fatalf("IncrEXFloat returned nil")
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}