From a94e7d8dfd10786cc1e95197c9c8902a1c199f45 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Wed, 5 Nov 2025 13:14:31 +0200 Subject: [PATCH 1/5] add cas/cad commands --- commands_test.go | 351 +++++++++++++++++++++++++++++++++++++++++++++ string_commands.go | 141 +++++++++++++++++- 2 files changed, 491 insertions(+), 1 deletion(-) diff --git a/commands_test.go b/commands_test.go index 17b4dd0306..9f3e59fcd7 100644 --- a/commands_test.go +++ b/commands_test.go @@ -1773,6 +1773,191 @@ var _ = Describe("Commands", func() { Expect(get.Err()).To(Equal(redis.Nil)) }) + It("should DelEx when value matches", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "lock", "token-123", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Delete only if value matches + deleted := client.DelEx(ctx, "lock", "token-123") + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(1))) + + // Verify key was deleted + get := client.Get(ctx, "lock") + Expect(get.Err()).To(Equal(redis.Nil)) + }) + + It("should DelEx fail when value does not match", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "lock", "token-123", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Try to delete with wrong value + deleted := client.DelEx(ctx, "lock", "wrong-token") + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(0))) + + // Verify key was NOT deleted + val, err := client.Get(ctx, "lock").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("token-123")) + }) + + It("should DelEx on non-existent key", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Try to delete non-existent key + deleted := client.DelEx(ctx, "nonexistent", "any-value") + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(0))) + }) + + It("should DelExArgs with IFEQ", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "temp-key", "temp-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Delete with IFEQ + args := redis.DelExArgs{ + Mode: "IFEQ", + MatchValue: "temp-value", + } + deleted := client.DelExArgs(ctx, "temp-key", args) + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(1))) + + // Verify key was deleted + get := client.Get(ctx, "temp-key") + Expect(get.Err()).To(Equal(redis.Nil)) + }) + + It("should DelExArgs with IFNE", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "temporary", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Delete only if value is NOT "permanent" + args := redis.DelExArgs{ + Mode: "IFNE", + MatchValue: "permanent", + } + deleted := client.DelExArgs(ctx, "key", args) + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(1))) + + // Verify key was deleted + get := client.Get(ctx, "key") + Expect(get.Err()).To(Equal(redis.Nil)) + }) + + It("should DelExArgs with IFNE fail when value matches", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "permanent", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Try to delete but value matches (should fail) + args := redis.DelExArgs{ + Mode: "IFNE", + MatchValue: "permanent", + } + deleted := client.DelExArgs(ctx, "key", args) + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(0))) + + // Verify key was NOT deleted + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("permanent")) + }) + + It("should Digest", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set a value + err := client.Set(ctx, "my-key", "my-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "my-key") + Expect(digest.Err()).NotTo(HaveOccurred()) + Expect(digest.Val()).NotTo(BeEmpty()) + + // Digest should be consistent + digest2 := client.Digest(ctx, "my-key") + Expect(digest2.Err()).NotTo(HaveOccurred()) + Expect(digest2.Val()).To(Equal(digest.Val())) + }) + + It("should Digest on non-existent key", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Get digest of non-existent key + digest := client.Digest(ctx, "nonexistent") + Expect(digest.Err()).To(Equal(redis.Nil)) + }) + + It("should use Digest with SetArgs IFDEQ", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "key") + Expect(digest.Err()).NotTo(HaveOccurred()) + + // Update using digest + args := redis.SetArgs{ + Mode: "IFDEQ", + MatchDigest: digest.Val(), + } + result := client.SetArgs(ctx, "key", "value2", args) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value2")) + }) + + It("should use Digest with DelExArgs IFDEQ", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "key") + Expect(digest.Err()).NotTo(HaveOccurred()) + + // Delete using digest + args := redis.DelExArgs{ + Mode: "IFDEQ", + MatchDigest: digest.Val(), + } + deleted := client.DelExArgs(ctx, "key", args) + Expect(deleted.Err()).NotTo(HaveOccurred()) + Expect(deleted.Val()).To(Equal(int64(1))) + + // Verify key was deleted + get := client.Get(ctx, "key") + Expect(get.Err()).To(Equal(redis.Nil)) + }) + It("should Incr", func() { set := client.Set(ctx, "key", "10", 0) Expect(set.Err()).NotTo(HaveOccurred()) @@ -2320,6 +2505,172 @@ var _ = Describe("Commands", func() { Expect(ttl).NotTo(Equal(-1)) }) + It("should SetIFEQ when value matches", func() { + if RedisVersion < 8.4 { + Skip("CAS/CAD commands require Redis >= 8.4") + } + + // Set initial value + err := client.Set(ctx, "key", "old-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update only if current value is "old-value" + result := client.SetIFEQ(ctx, "key", "new-value", "old-value", 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("new-value")) + }) + + It("should SetIFEQ fail when value does not match", func() { + if RedisVersion < 8.4 { + Skip("CAS/CAD commands require Redis >= 8.4") + } + + // Set initial value + err := client.Set(ctx, "key", "current-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Try to update with wrong match value + result := client.SetIFEQ(ctx, "key", "new-value", "wrong-value", 0) + Expect(result.Err()).To(Equal(redis.Nil)) + + // Verify value was NOT updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("current-value")) + }) + + It("should SetIFEQ with expiration", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "token-123", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update with expiration + result := client.SetIFEQ(ctx, "key", "token-456", "token-123", 500*time.Millisecond) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("token-456")) + + // Wait for expiration + Eventually(func() error { + return client.Get(ctx, "key").Err() + }, "1s", "100ms").Should(Equal(redis.Nil)) + }) + + It("should SetIFNE when value does not match", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "pending", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update only if current value is NOT "completed" + result := client.SetIFNE(ctx, "key", "processing", "completed", 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("processing")) + }) + + It("should SetIFNE fail when value matches", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "completed", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Try to update but value matches (should fail) + result := client.SetIFNE(ctx, "key", "processing", "completed", 0) + Expect(result.Err()).To(Equal(redis.Nil)) + + // Verify value was NOT updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("completed")) + }) + + It("should SetArgs with IFEQ", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "counter", "100", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update with IFEQ + args := redis.SetArgs{ + Mode: "IFEQ", + MatchValue: "100", + TTL: 1 * time.Hour, + } + result := client.SetArgs(ctx, "counter", "200", args) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "counter").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("200")) + }) + + It("should SetArgs with IFEQ and GET", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "old", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update with IFEQ and GET old value + args := redis.SetArgs{ + Mode: "IFEQ", + MatchValue: "old", + Get: true, + } + result := client.SetArgs(ctx, "key", "new", args) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("old")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("new")) + }) + + It("should SetArgs with IFNE", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "status", "pending", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update with IFNE + args := redis.SetArgs{ + Mode: "IFNE", + MatchValue: "completed", + TTL: 30 * time.Minute, + } + result := client.SetArgs(ctx, "status", "processing", args) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "status").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("processing")) + }) + It("should SetRange", func() { set := client.Set(ctx, "key", "Hello World", 0) Expect(set.Err()).NotTo(HaveOccurred()) diff --git a/string_commands.go b/string_commands.go index eff5880dcd..ee8376e699 100644 --- a/string_commands.go +++ b/string_commands.go @@ -9,6 +9,9 @@ type StringCmdable interface { Append(ctx context.Context, key, value string) *IntCmd Decr(ctx context.Context, key string) *IntCmd DecrBy(ctx context.Context, key string, decrement int64) *IntCmd + DelEx(ctx context.Context, key string, matchValue string) *IntCmd + DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd + Digest(ctx context.Context, key string) *StringCmd Get(ctx context.Context, key string) *StringCmd GetRange(ctx context.Context, key string, start, end int64) *StringCmd GetSet(ctx context.Context, key string, value interface{}) *StringCmd @@ -24,6 +27,8 @@ type StringCmdable interface { Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd + SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd + SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetXX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd @@ -48,6 +53,68 @@ func (c cmdable) DecrBy(ctx context.Context, key string, decrement int64) *IntCm return cmd } +// DelExArgs provides arguments for the DelExArgs function. +type DelExArgs struct { + // Mode can be `IFEQ`, `IFNE`, `IFDEQ`, or `IFDNE`. + Mode string + + // MatchValue is used with IFEQ/IFNE modes for compare-and-delete operations. + // - IFEQ: only delete if current value equals MatchValue + // - IFNE: only delete if current value does not equal MatchValue + MatchValue string + + // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-delete. + // - IFDEQ: only delete if current value's digest equals MatchDigest + // - IFDNE: only delete if current value's digest does not equal MatchDigest + MatchDigest string +} + +// DelEx Redis `DELEX key IFEQ match-value` command. +// Compare-and-delete: only deletes the key if the current value equals matchValue. +// +// Returns the number of keys that were removed (0 or 1). +func (c cmdable) DelEx(ctx context.Context, key string, matchValue string) *IntCmd { + cmd := NewIntCmd(ctx, "delex", key, "ifeq", matchValue) + _ = c(ctx, cmd) + return cmd +} + +// DelExArgs Redis `DELEX key [IFEQ|IFNE|IFDEQ|IFDNE] match-value` command. +// Compare-and-delete with flexible conditions. +// +// Returns the number of keys that were removed (0 or 1). +func (c cmdable) DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd { + args := []interface{}{"delex", key} + + if a.Mode != "" { + args = append(args, a.Mode) + + // Add match value/digest based on mode + switch a.Mode { + case "ifeq", "IFEQ", "ifne", "IFNE": + if a.MatchValue != "" { + args = append(args, a.MatchValue) + } + case "ifdeq", "IFDEQ", "ifdne", "IFDNE": + if a.MatchDigest != "" { + args = append(args, a.MatchDigest) + } + } + } + + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// Digest Redis `DIGEST key` command. +// Returns the hex digest of the specified key's value. +func (c cmdable) Digest(ctx context.Context, key string) *StringCmd { + cmd := NewStringCmd(ctx, "digest", key) + _ = c(ctx, cmd) + return cmd +} + // Get Redis `GET key` command. It returns redis.Nil error when key does not exist. func (c cmdable) Get(ctx context.Context, key string) *StringCmd { cmd := NewStringCmd(ctx, "get", key) @@ -185,9 +252,19 @@ func (c cmdable) Set(ctx context.Context, key string, value interface{}, expirat // SetArgs provides arguments for the SetArgs function. type SetArgs struct { - // Mode can be `NX` or `XX` or empty. + // Mode can be `NX`, `XX`, `IFEQ`, `IFNE`, `IFDEQ`, `IFDNE` or empty. Mode string + // MatchValue is used with IFEQ/IFNE modes for compare-and-set operations. + // - IFEQ: only set if current value equals MatchValue + // - IFNE: only set if current value does not equal MatchValue + MatchValue string + + // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-set. + // - IFDEQ: only set if current value's digest equals MatchDigest + // - IFDNE: only set if current value's digest does not equal MatchDigest + MatchDigest string + // Zero `TTL` or `Expiration` means that the key has no expiration time. TTL time.Duration ExpireAt time.Time @@ -223,6 +300,18 @@ func (c cmdable) SetArgs(ctx context.Context, key string, value interface{}, a S if a.Mode != "" { args = append(args, a.Mode) + + // Add match value/digest for CAS modes + switch a.Mode { + case "ifeq", "IFEQ", "ifne", "IFNE": + if a.MatchValue != "" { + args = append(args, a.MatchValue) + } + case "ifdeq", "IFDEQ", "ifdne", "IFDNE": + if a.MatchDigest != "" { + args = append(args, a.MatchDigest) + } + } } if a.Get { @@ -290,6 +379,56 @@ func (c cmdable) SetXX(ctx context.Context, key string, value interface{}, expir return cmd } +// SetIFEQ Redis `SET key value [expiration] IFEQ match-value` command. +// Compare-and-set: only sets the value if the current value equals matchValue. +// +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifeq", matchValue) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFNE Redis `SET key value [expiration] IFNE match-value` command. +// Compare-and-set: only sets the value if the current value does not equal matchValue. +// +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifne", matchValue) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd { cmd := NewIntCmd(ctx, "setrange", key, offset, value) _ = c(ctx, cmd) From a9017a7344e8c53f340b7ef0ba40404c2bed87ac Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Wed, 5 Nov 2025 16:26:27 +0200 Subject: [PATCH 2/5] feat(command): Add SetIFDEQ, SetIFDNE and *Get cmds Decided to move the *Get argument as a separate methods, since the response will be always the previous value, but in the case where the previous value is `OK` there result may be ambiguous. --- commands_test.go | 136 ++++++++++++++++++++++++++++++++++++ string_commands.go | 168 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/commands_test.go b/commands_test.go index ed084efcdf..b2b7904333 100644 --- a/commands_test.go +++ b/commands_test.go @@ -2825,6 +2825,142 @@ var _ = Describe("Commands", func() { Expect(val).To(Equal("processing")) }) + It("should SetIFEQGet return previous value", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "old-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update and get previous value + result := client.SetIFEQGet(ctx, "key", "new-value", "old-value", 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("old-value")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("new-value")) + }) + + It("should SetIFNEGet return previous value", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "pending", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update and get previous value + result := client.SetIFNEGet(ctx, "key", "processing", "completed", 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("pending")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("processing")) + }) + + It("should SetIFDEQ when digest matches", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "key") + Expect(digest.Err()).NotTo(HaveOccurred()) + + // Update using digest + result := client.SetIFDEQ(ctx, "key", "value2", digest.Val(), 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value2")) + }) + + It("should SetIFDEQ fail when digest does not match", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Try to update with wrong digest + result := client.SetIFDEQ(ctx, "key", "value2", "wrong-digest", 0) + Expect(result.Err()).To(Equal(redis.Nil)) + + // Verify value was NOT updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value1")) + }) + + It("should SetIFDEQGet return previous value", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "key") + Expect(digest.Err()).NotTo(HaveOccurred()) + + // Update using digest and get previous value + result := client.SetIFDEQGet(ctx, "key", "value2", digest.Val(), 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("value1")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value2")) + }) + + It("should SetIFDNE when digest does not match", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Update with wrong digest (should succeed because digest doesn't match) + result := client.SetIFDNE(ctx, "key", "value2", "wrong-digest", 0) + Expect(result.Err()).NotTo(HaveOccurred()) + Expect(result.Val()).To(Equal("OK")) + + // Verify value was updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value2")) + }) + + It("should SetIFDNE fail when digest matches", func() { + SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") + + // Set initial value + err := client.Set(ctx, "key", "value1", 0).Err() + Expect(err).NotTo(HaveOccurred()) + + // Get digest + digest := client.Digest(ctx, "key") + Expect(digest.Err()).NotTo(HaveOccurred()) + + // Try to update but digest matches (should fail) + result := client.SetIFDNE(ctx, "key", "value2", digest.Val(), 0) + Expect(result.Err()).To(Equal(redis.Nil)) + + // Verify value was NOT updated + val, err := client.Get(ctx, "key").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("value1")) + }) + It("should SetRange", func() { set := client.Set(ctx, "key", "Hello World", 0) Expect(set.Err()).NotTo(HaveOccurred()) diff --git a/string_commands.go b/string_commands.go index 97aa7b0dab..36c87b1034 100644 --- a/string_commands.go +++ b/string_commands.go @@ -29,7 +29,13 @@ type StringCmdable interface { SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd + SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd + SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd + SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd + SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StringCmd + SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd + SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StringCmd SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetXX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd @@ -455,6 +461,7 @@ func (c cmdable) SetXX(ctx context.Context, key string, value interface{}, expir // SetIFEQ Redis `SET key value [expiration] IFEQ match-value` command. // Compare-and-set: only sets the value if the current value equals matchValue. // +// Returns "OK" on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { @@ -477,9 +484,37 @@ func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, mat return cmd } +// SetIFEQGet Redis `SET key value [expiration] IFEQ match-value GET` command. +// Compare-and-set with GET: only sets the value if the current value equals matchValue, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifeq", matchValue, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + // SetIFNE Redis `SET key value [expiration] IFNE match-value` command. // Compare-and-set: only sets the value if the current value does not equal matchValue. // +// Returns "OK" on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { @@ -502,6 +537,139 @@ func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, mat return cmd } +// SetIFNEGet Redis `SET key value [expiration] IFNE match-value GET` command. +// Compare-and-set with GET: only sets the value if the current value does not equal matchValue, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifne", matchValue, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDEQ Redis `SET key value [expiration] IFDEQ match-digest` command. +// Compare-and-set using digest: only sets the value if the current value's digest equals matchDigest. +// +// Returns "OK" on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdeq", matchDigest) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDEQGet Redis `SET key value [expiration] IFDEQ match-digest GET` command. +// Compare-and-set using digest with GET: only sets the value if the current value's digest equals matchDigest, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdeq", matchDigest, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDNE Redis `SET key value [expiration] IFDNE match-digest` command. +// Compare-and-set using digest: only sets the value if the current value's digest does not equal matchDigest. +// +// Returns "OK" on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdne", matchDigest) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDNEGet Redis `SET key value [expiration] IFDNE match-digest GET` command. +// Compare-and-set using digest with GET: only sets the value if the current value's digest does not equal matchDigest, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +func (c cmdable) SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdne", matchDigest, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd { cmd := NewIntCmd(ctx, "setrange", key, offset, value) _ = c(ctx, cmd) From 2fe4c49ebd0e8d17b82c041f4d096d23d293b2e5 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Wed, 5 Nov 2025 16:42:04 +0200 Subject: [PATCH 3/5] fix tests --- commands_test.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/commands_test.go b/commands_test.go index b2b7904333..8d566cd4d0 100644 --- a/commands_test.go +++ b/commands_test.go @@ -2890,8 +2890,14 @@ var _ = Describe("Commands", func() { err := client.Set(ctx, "key", "value1", 0).Err() Expect(err).NotTo(HaveOccurred()) + // Get digest of a different value to use as wrong digest + err = client.Set(ctx, "temp-key", "different-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + wrongDigest := client.Digest(ctx, "temp-key") + Expect(wrongDigest.Err()).NotTo(HaveOccurred()) + // Try to update with wrong digest - result := client.SetIFDEQ(ctx, "key", "value2", "wrong-digest", 0) + result := client.SetIFDEQ(ctx, "key", "value2", wrongDigest.Val(), 0) Expect(result.Err()).To(Equal(redis.Nil)) // Verify value was NOT updated @@ -2929,8 +2935,14 @@ var _ = Describe("Commands", func() { err := client.Set(ctx, "key", "value1", 0).Err() Expect(err).NotTo(HaveOccurred()) - // Update with wrong digest (should succeed because digest doesn't match) - result := client.SetIFDNE(ctx, "key", "value2", "wrong-digest", 0) + // Get digest of a different value + err = client.Set(ctx, "temp-key", "different-value", 0).Err() + Expect(err).NotTo(HaveOccurred()) + differentDigest := client.Digest(ctx, "temp-key") + Expect(differentDigest.Err()).NotTo(HaveOccurred()) + + // Update with different digest (should succeed because digest doesn't match) + result := client.SetIFDNE(ctx, "key", "value2", differentDigest.Val(), 0) Expect(result.Err()).NotTo(HaveOccurred()) Expect(result.Val()).To(Equal("OK")) From 0b169f5a44d39470574614ac1689f43fea310aa0 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Wed, 5 Nov 2025 21:22:57 +0200 Subject: [PATCH 4/5] matchValue to be interface{} --- string_commands.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/string_commands.go b/string_commands.go index 36c87b1034..bf571df055 100644 --- a/string_commands.go +++ b/string_commands.go @@ -9,7 +9,7 @@ type StringCmdable interface { Append(ctx context.Context, key, value string) *IntCmd Decr(ctx context.Context, key string) *IntCmd DecrBy(ctx context.Context, key string, decrement int64) *IntCmd - DelEx(ctx context.Context, key string, matchValue string) *IntCmd + DelEx(ctx context.Context, key string, matchValue interface{}) *IntCmd DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd Digest(ctx context.Context, key string) *StringCmd Get(ctx context.Context, key string) *StringCmd @@ -28,10 +28,10 @@ type StringCmdable interface { Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd - SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd - SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd - SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd - SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd + SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd + SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd + SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd + SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StringCmd SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest string, expiration time.Duration) *StatusCmd @@ -68,7 +68,7 @@ type DelExArgs struct { // MatchValue is used with IFEQ/IFNE modes for compare-and-delete operations. // - IFEQ: only delete if current value equals MatchValue // - IFNE: only delete if current value does not equal MatchValue - MatchValue string + MatchValue interface{} // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-delete. // - IFDEQ: only delete if current value's digest equals MatchDigest @@ -80,7 +80,7 @@ type DelExArgs struct { // Compare-and-delete: only deletes the key if the current value equals matchValue. // // Returns the number of keys that were removed (0 or 1). -func (c cmdable) DelEx(ctx context.Context, key string, matchValue string) *IntCmd { +func (c cmdable) DelEx(ctx context.Context, key string, matchValue interface{}) *IntCmd { cmd := NewIntCmd(ctx, "delex", key, "ifeq", matchValue) _ = c(ctx, cmd) return cmd @@ -99,7 +99,7 @@ func (c cmdable) DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd // Add match value/digest based on mode switch a.Mode { case "ifeq", "IFEQ", "ifne", "IFNE": - if a.MatchValue != "" { + if a.MatchValue != nil { args = append(args, a.MatchValue) } case "ifdeq", "IFDEQ", "ifdne", "IFDNE": @@ -337,7 +337,7 @@ type SetArgs struct { // MatchValue is used with IFEQ/IFNE modes for compare-and-set operations. // - IFEQ: only set if current value equals MatchValue // - IFNE: only set if current value does not equal MatchValue - MatchValue string + MatchValue interface{} // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-set. // - IFDEQ: only set if current value's digest equals MatchDigest @@ -383,7 +383,7 @@ func (c cmdable) SetArgs(ctx context.Context, key string, value interface{}, a S // Add match value/digest for CAS modes switch a.Mode { case "ifeq", "IFEQ", "ifne", "IFNE": - if a.MatchValue != "" { + if a.MatchValue != nil { args = append(args, a.MatchValue) } case "ifdeq", "IFDEQ", "ifdne", "IFDNE": @@ -464,7 +464,7 @@ func (c cmdable) SetXX(ctx context.Context, key string, value interface{}, expir // Returns "OK" on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. -func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { +func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd { args := []interface{}{"set", key, value} if expiration > 0 { @@ -491,7 +491,7 @@ func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, mat // Returns the previous value on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. -func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd { +func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd { args := []interface{}{"set", key, value} if expiration > 0 { @@ -517,7 +517,7 @@ func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, // Returns "OK" on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. -func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StatusCmd { +func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd { args := []interface{}{"set", key, value} if expiration > 0 { @@ -544,7 +544,7 @@ func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, mat // Returns the previous value on success. // Returns nil if the operation was aborted due to condition not matching. // Zero expiration means the key has no expiration time. -func (c cmdable) SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue string, expiration time.Duration) *StringCmd { +func (c cmdable) SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd { args := []interface{}{"set", key, value} if expiration > 0 { From 72ec67ef0b2258c8c55ad454cf7c6ff9eb38aec7 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov Date: Wed, 5 Nov 2025 21:30:29 +0200 Subject: [PATCH 5/5] Only Args approach for DelEx --- commands_test.go | 21 +++++++++++++++------ string_commands.go | 11 ----------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/commands_test.go b/commands_test.go index 8d566cd4d0..ad8667d864 100644 --- a/commands_test.go +++ b/commands_test.go @@ -1796,7 +1796,7 @@ var _ = Describe("Commands", func() { Expect(get.Err()).To(Equal(redis.Nil)) }) - It("should DelEx when value matches", func() { + It("should DelExArgs when value matches", func() { SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") // Set initial value @@ -1804,7 +1804,10 @@ var _ = Describe("Commands", func() { Expect(err).NotTo(HaveOccurred()) // Delete only if value matches - deleted := client.DelEx(ctx, "lock", "token-123") + deleted := client.DelExArgs(ctx, "lock", redis.DelExArgs{ + Mode: "IFEQ", + MatchValue: "token-123", + }) Expect(deleted.Err()).NotTo(HaveOccurred()) Expect(deleted.Val()).To(Equal(int64(1))) @@ -1813,7 +1816,7 @@ var _ = Describe("Commands", func() { Expect(get.Err()).To(Equal(redis.Nil)) }) - It("should DelEx fail when value does not match", func() { + It("should DelExArgs fail when value does not match", func() { SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") // Set initial value @@ -1821,7 +1824,10 @@ var _ = Describe("Commands", func() { Expect(err).NotTo(HaveOccurred()) // Try to delete with wrong value - deleted := client.DelEx(ctx, "lock", "wrong-token") + deleted := client.DelExArgs(ctx, "lock", redis.DelExArgs{ + Mode: "IFEQ", + MatchValue: "wrong-token", + }) Expect(deleted.Err()).NotTo(HaveOccurred()) Expect(deleted.Val()).To(Equal(int64(0))) @@ -1831,11 +1837,14 @@ var _ = Describe("Commands", func() { Expect(val).To(Equal("token-123")) }) - It("should DelEx on non-existent key", func() { + It("should DelExArgs on non-existent key", func() { SkipBeforeRedisVersion(8.4, "CAS/CAD commands require Redis >= 8.4") // Try to delete non-existent key - deleted := client.DelEx(ctx, "nonexistent", "any-value") + deleted := client.DelExArgs(ctx, "nonexistent", redis.DelExArgs{ + Mode: "IFEQ", + MatchValue: "any-value", + }) Expect(deleted.Err()).NotTo(HaveOccurred()) Expect(deleted.Val()).To(Equal(int64(0))) }) diff --git a/string_commands.go b/string_commands.go index bf571df055..3a62e62758 100644 --- a/string_commands.go +++ b/string_commands.go @@ -9,7 +9,6 @@ type StringCmdable interface { Append(ctx context.Context, key, value string) *IntCmd Decr(ctx context.Context, key string) *IntCmd DecrBy(ctx context.Context, key string, decrement int64) *IntCmd - DelEx(ctx context.Context, key string, matchValue interface{}) *IntCmd DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd Digest(ctx context.Context, key string) *StringCmd Get(ctx context.Context, key string) *StringCmd @@ -76,16 +75,6 @@ type DelExArgs struct { MatchDigest string } -// DelEx Redis `DELEX key IFEQ match-value` command. -// Compare-and-delete: only deletes the key if the current value equals matchValue. -// -// Returns the number of keys that were removed (0 or 1). -func (c cmdable) DelEx(ctx context.Context, key string, matchValue interface{}) *IntCmd { - cmd := NewIntCmd(ctx, "delex", key, "ifeq", matchValue) - _ = c(ctx, cmd) - return cmd -} - // DelExArgs Redis `DELEX key [IFEQ|IFNE|IFDEQ|IFDNE] match-value` command. // Compare-and-delete with flexible conditions. //