This is an automated email from the ASF dual-hosted git repository.

jihuayu pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new 07a67bead feat(string): support IFEQ/IFNE/IFDEQ/IFDNE in SET command 
(#3475)
07a67bead is described below

commit 07a67beadd2efeaec6abfcb90f07b6c23e386e96
Author: kirito632 <[email protected]>
AuthorDate: Wed May 6 22:01:26 2026 +0800

    feat(string): support IFEQ/IFNE/IFDEQ/IFDNE in SET command (#3475)
    
    ## What
    Reintroduce conditional options IFEQ / IFNE / IFDEQ / IFDNE for the SET
    command.
    
    ## Context
    This is a clean, reworked version of the previously closed #3452.
    It addresses all previous feedback, reverts the accidental deletion of
    `CommandDelEX`, and is fully rebased onto the latest `unstable` branch.
    
    All CI checks have been successfully run and verified on my personal
    fork before submission.
    
    ## Behavior
    - IFEQ / IFNE: case-sensitive value comparison
    - IFDEQ / IFDNE: case-insensitive digest comparison
    - Return `WRONGTYPE` for non-string keys
    - Mutually exclusive with NX/XX
    - Fix: `SET key value NX GET` now correctly returns `WRONGTYPE` for
    non-string keys (matching Redis 8.x behavior)
    
    ## Testing
    - C++ unit test: Kept only the IFDEQ empty-string boundary coverage
    - Go integration tests: Syntax, behavior, edge cases (uppercase &
    malformed digests), and regression coverage
    
    ## AI-assisted Contribution Disclosure
    AI was used for code pattern suggestions, test scaffolding, and
    debugging assistance.
    Core logic, bug fixes, validation, and final implementation were written
    and verified manually.
    
    Co-authored-by: hulk <[email protected]>
    Co-authored-by: jihuayu <[email protected]>
---
 src/commands/cmd_string.cc                     |  21 +-
 src/types/redis_string.cc                      |  52 +++-
 src/types/redis_string.h                       |   7 +-
 tests/cppunit/types/string_test.cc             |  43 +++
 tests/gocase/unit/type/strings/strings_test.go | 381 ++++++++++++++++++++++++-
 5 files changed, 498 insertions(+), 6 deletions(-)

diff --git a/src/commands/cmd_string.cc b/src/commands/cmd_string.cc
index 275846a2b..71d538a1d 100644
--- a/src/commands/cmd_string.cc
+++ b/src/commands/cmd_string.cc
@@ -352,6 +352,24 @@ class CommandSet : public Commander {
         set_flag_ = StringSetType::NX;
       } else if (parser.EatEqICaseFlag("XX", set_flag)) {
         set_flag_ = StringSetType::XX;
+      } else if (parser.EatEqICaseFlag("IFEQ", set_flag)) {
+        set_flag_ = StringSetType::IFEQ;
+        cmp_value_ = GET_OR_RET(parser.TakeStr());
+      } else if (parser.EatEqICaseFlag("IFNE", set_flag)) {
+        set_flag_ = StringSetType::IFNE;
+        cmp_value_ = GET_OR_RET(parser.TakeStr());
+      } else if (parser.EatEqICaseFlag("IFDEQ", set_flag)) {
+        set_flag_ = StringSetType::IFDEQ;
+        cmp_value_ = GET_OR_RET(parser.TakeStr());
+        if (cmp_value_.size() != 16) {
+          return {Status::RedisParseErr, "ERR digest must be exactly 16 
hexadecimal characters"};
+        }
+      } else if (parser.EatEqICaseFlag("IFDNE", set_flag)) {
+        set_flag_ = StringSetType::IFDNE;
+        cmp_value_ = GET_OR_RET(parser.TakeStr());
+        if (cmp_value_.size() != 16) {
+          return {Status::RedisParseErr, "ERR digest must be exactly 16 
hexadecimal characters"};
+        }
       } else if (parser.EatEqICase("GET")) {
         get_ = true;
       } else {
@@ -366,7 +384,7 @@ class CommandSet : public Commander {
     std::optional<std::string> ret;
     redis::String string_db(srv->storage, conn->GetNamespace());
 
-    rocksdb::Status s = string_db.Set(ctx, args_[1], args_[2], {expire_, 
set_flag_, get_, keep_ttl_}, ret);
+    rocksdb::Status s = string_db.Set(ctx, args_[1], args_[2], {expire_, 
set_flag_, get_, keep_ttl_, cmp_value_}, ret);
 
     if (!s.ok()) {
       return {Status::RedisExecErr, s.ToString()};
@@ -393,6 +411,7 @@ class CommandSet : public Commander {
   bool get_ = false;
   bool keep_ttl_ = false;
   StringSetType set_flag_ = StringSetType::NONE;
+  std::string cmp_value_;
 };
 
 class CommandSetEX : public Commander {
diff --git a/src/types/redis_string.cc b/src/types/redis_string.cc
index a99eaac3d..8563275bf 100644
--- a/src/types/redis_string.cc
+++ b/src/types/redis_string.cc
@@ -239,7 +239,7 @@ rocksdb::Status String::Set(engine::Context &ctx, const 
std::string &user_key, c
 }
 
 rocksdb::Status String::Set(engine::Context &ctx, const std::string &user_key, 
const std::string &value,
-                            StringSetArgs args, std::optional<std::string> 
&ret) {
+                            const StringSetArgs &args, 
std::optional<std::string> &ret) {
   uint64_t expire = 0;
   std::string ns_key = AppendNamespacePrefix(user_key);
 
@@ -249,6 +249,24 @@ rocksdb::Status String::Set(engine::Context &ctx, const 
std::string &user_key, c
     uint64_t old_expire = 0;
     auto s = getValueAndExpire(ctx, ns_key, &old_value, &old_expire);
     if (!s.ok() && !s.IsNotFound() && !s.IsInvalidArgument()) return s;
+    // If the existing key is not a string type, enforce expected behaviors:
+    if (s.IsInvalidArgument()) {
+      // For conditional comparisons (IFEQ/IFNE/IFDEQ/IFDNE), reading the old 
value is required,
+      // so return the underlying WRONGTYPE (InvalidArgument) error.
+      if (args.type == StringSetType::IFEQ || args.type == StringSetType::IFNE 
|| args.type == StringSetType::IFDEQ ||
+          args.type == StringSetType::IFDNE) {
+        return s;
+      }
+      // For NX option, treat a wrong type as "key exists" so the condition is 
not met.
+      if (args.type == StringSetType::NX) {
+        // If GET is also specified, we need to return the WRONGTYPE error
+        // because GET requires reading the old value.
+        if (args.get) return s;
+        ret = std::nullopt;
+        return rocksdb::Status::OK();
+      }
+      // For other options, continue (e.g., XX may still proceed since key 
exists).
+    }
     // GET option
     if (args.get) {
       if (s.IsInvalidArgument()) {
@@ -271,6 +289,38 @@ rocksdb::Status String::Set(engine::Context &ctx, const 
std::string &user_key, c
       // if XX option given, the key didn't exist before: return nil
       if (!args.get) ret = std::nullopt;
       return rocksdb::Status::OK();
+    } else if (args.type == StringSetType::IFEQ) {
+      // condition met only when key exists AND value matches
+      bool matched = s.ok() && (old_value == args.cmp_value);
+      if (!matched) {
+        if (!args.get) ret = std::nullopt;
+        return rocksdb::Status::OK();
+      }
+      if (!args.get) ret = "";
+    } else if (args.type == StringSetType::IFNE) {
+      // condition not met when key exists AND value matches; key-not-found 
counts as met
+      bool matched = s.ok() && (old_value == args.cmp_value);
+      if (matched) {
+        if (!args.get) ret = std::nullopt;
+        return rocksdb::Status::OK();
+      }
+      if (!args.get) ret = "";
+    } else if (args.type == StringSetType::IFDEQ) {
+      // condition met only when key exists AND digest matches 
(case-insensitive)
+      bool matched = s.ok() && util::EqualICase(util::StringDigest(old_value), 
args.cmp_value);
+      if (!matched) {
+        if (!args.get) ret = std::nullopt;
+        return rocksdb::Status::OK();
+      }
+      if (!args.get) ret = "";
+    } else if (args.type == StringSetType::IFDNE) {
+      // condition not met when key exists AND digest matches 
(case-insensitive); key-not-found counts as met
+      bool matched = s.ok() && util::EqualICase(util::StringDigest(old_value), 
args.cmp_value);
+      if (matched) {
+        if (!args.get) ret = std::nullopt;
+        return rocksdb::Status::OK();
+      }
+      if (!args.get) ret = "";
     } else {
       // if GET option not given, make ret not nil
       if (!args.get) ret = "";
diff --git a/src/types/redis_string.h b/src/types/redis_string.h
index b160d3d6d..57fb309da 100644
--- a/src/types/redis_string.h
+++ b/src/types/redis_string.h
@@ -43,7 +43,7 @@ struct DelExOption {
   DelExOption(Type type, std::string value) : type(type), 
value(std::move(value)) {}
 };
 
-enum class StringSetType { NONE, NX, XX };
+enum class StringSetType { NONE, NX, XX, IFEQ, IFNE, IFDEQ, IFDNE };
 
 struct StringSetArgs {
   // Expire time in mill seconds.
@@ -51,6 +51,7 @@ struct StringSetArgs {
   StringSetType type;
   bool get;
   bool keep_ttl;
+  std::string cmp_value;  // valid only when type is IFEQ/IFNE/IFDEQ/IFDNE
 };
 
 struct StringMSetArgs {
@@ -103,8 +104,8 @@ class String : public Database {
                          std::optional<std::string> &old_value);
   rocksdb::Status GetDel(engine::Context &ctx, const std::string &user_key, 
std::string *value);
   rocksdb::Status Set(engine::Context &ctx, const std::string &user_key, const 
std::string &value);
-  rocksdb::Status Set(engine::Context &ctx, const std::string &user_key, const 
std::string &value, StringSetArgs args,
-                      std::optional<std::string> &ret);
+  rocksdb::Status Set(engine::Context &ctx, const std::string &user_key, const 
std::string &value,
+                      const StringSetArgs &args, std::optional<std::string> 
&ret);
   rocksdb::Status SetEX(engine::Context &ctx, const std::string &user_key, 
const std::string &value,
                         uint64_t expire_ms);
   rocksdb::Status SetNX(engine::Context &ctx, const std::string &user_key, 
const std::string &value, uint64_t expire_ms,
diff --git a/tests/cppunit/types/string_test.cc 
b/tests/cppunit/types/string_test.cc
index d7f79eab0..aa07b787e 100644
--- a/tests/cppunit/types/string_test.cc
+++ b/tests/cppunit/types/string_test.cc
@@ -613,3 +613,46 @@ TEST_F(RedisStringTest, LCS) {
                     4},
                    std::get<StringLCSIdxResult>(rst));
 }
+
+TEST_F(RedisStringTest, SetIFDEQ) {
+  std::string key = "ifdeq-key";
+  std::string value = "hello";
+  std::optional<std::string> ret;
+
+  // key not found → condition not met, no write
+  auto s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFDEQ, false, 
false, util::StringDigest(value)}, ret);
+  EXPECT_TRUE(s.ok());
+  EXPECT_FALSE(ret.has_value());
+  std::string got;
+  EXPECT_TRUE(string_->Get(*ctx_, key, &got).IsNotFound());
+
+  // set up the key
+  string_->Set(*ctx_, key, value);
+
+  // digest matches → write succeeds
+  ret = std::nullopt;
+  s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFDEQ, false, false, 
util::StringDigest(value)}, ret);
+  EXPECT_TRUE(s.ok());
+  EXPECT_TRUE(ret.has_value());
+  string_->Get(*ctx_, key, &got);
+  EXPECT_EQ("new", got);
+
+  // digest mismatches → no write
+  ret = std::nullopt;
+  s = string_->Set(*ctx_, key, "newer", {0, StringSetType::IFDEQ, false, 
false, "xxxxxxxxxxxxxxxx"}, ret);
+  EXPECT_TRUE(s.ok());
+  EXPECT_FALSE(ret.has_value());
+  string_->Get(*ctx_, key, &got);
+  EXPECT_EQ("new", got);
+
+  // empty string edge case: digest of "" is well-defined
+  string_->Set(*ctx_, key, "");
+  ret = std::nullopt;
+  s = string_->Set(*ctx_, key, "nonempty", {0, StringSetType::IFDEQ, false, 
false, util::StringDigest("")}, ret);
+  EXPECT_TRUE(s.ok());
+  EXPECT_TRUE(ret.has_value());
+  string_->Get(*ctx_, key, &got);
+  EXPECT_EQ("nonempty", got);
+
+  EXPECT_TRUE(string_->Del(*ctx_, key).ok());
+}
diff --git a/tests/gocase/unit/type/strings/strings_test.go 
b/tests/gocase/unit/type/strings/strings_test.go
index 204907a9f..128b5bd0a 100644
--- a/tests/gocase/unit/type/strings/strings_test.go
+++ b/tests/gocase/unit/type/strings/strings_test.go
@@ -50,7 +50,7 @@ func TestString(t *testing.T) {
        }
 }
 func testString(t *testing.T, configs util.KvrocksServerConfigs) {
-       srv := util.StartServer(t, map[string]string{})
+       srv := util.StartServer(t, configs)
        defer srv.Close()
        ctx := context.Background()
        rdb := srv.NewClient()
@@ -1251,4 +1251,383 @@ func testString(t *testing.T, configs 
util.KvrocksServerConfigs) {
                        "exactly 16 hexadecimal characters")
                require.Equal(t, value, rdb.Get(ctx, key).Val())
        })
+
+       t.Run("IFEQ missing cmp_value returns error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "IFEQ").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("IFNE missing cmp_value returns error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "IFNE").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("IFDEQ missing cmp_value returns error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "IFDEQ").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("IFDNE missing cmp_value returns error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "IFDNE").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("NX and IFEQ together returns syntax error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "NX", "IFEQ", "x").Err()
+               require.Error(t, err)
+               require.Contains(t, err.Error(), "syntax")
+       })
+
+       t.Run("XX and IFNE together returns syntax error", func(t *testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "XX", "IFNE", "x").Err()
+               require.Error(t, err)
+               require.Contains(t, err.Error(), "syntax")
+       })
+
+       t.Run("IFEQ and IFDEQ together returns syntax error", func(t 
*testing.T) {
+               err := rdb.Do(ctx, "SET", "k", "v", "IFEQ", "x", "IFDEQ", 
"y").Err()
+               require.Error(t, err)
+               require.Contains(t, err.Error(), "syntax")
+       })
+
+       t.Run("WRONGTYPE error when key is not a string", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "listkey").Err())
+               require.NoError(t, rdb.RPush(ctx, "listkey", "a").Err())
+               err := rdb.Do(ctx, "SET", "listkey", "v", "IFEQ", "a").Err()
+               require.Error(t, err)
+               require.Contains(t, err.Error(), "WRONGTYPE")
+               require.NoError(t, rdb.Del(ctx, "listkey").Err())
+       })
+
+       t.Run("IFEQ: key not found returns nil", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "ifeq1").Err())
+               res := rdb.Do(ctx, "SET", "ifeq1", "new", "IFEQ", 
"anything").Val()
+               require.Nil(t, res)
+       })
+
+       t.Run("IFEQ: value matches writes and returns OK", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq2", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifeq2", "world", "IFEQ", 
"hello").Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "world", rdb.Get(ctx, "ifeq2").Val())
+       })
+
+       t.Run("IFEQ: value mismatches returns nil and no write", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq3", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifeq3", "world", "IFEQ", 
"wrong").Val()
+               require.Nil(t, res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifeq3").Val())
+       })
+
+       t.Run("IFNE: key not found writes and returns OK", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "ifne1").Err())
+               res := rdb.Do(ctx, "SET", "ifne1", "created", "IFNE", 
"anything").Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "created", rdb.Get(ctx, "ifne1").Val())
+       })
+
+       t.Run("IFNE: value matches returns nil and no write", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifne2", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifne2", "world", "IFNE", 
"hello").Val()
+               require.Nil(t, res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifne2").Val())
+       })
+
+       t.Run("IFNE: value mismatches writes and returns OK", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifne3", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifne3", "world", "IFNE", 
"wrong").Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "world", rdb.Get(ctx, "ifne3").Val())
+       })
+
+       t.Run("IFDEQ: key not found returns nil", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "ifdeq1").Err())
+               res := rdb.Do(ctx, "SET", "ifdeq1", "new", "IFDEQ", 
"xxxxxxxxxxxxxxxx").Val()
+               require.Nil(t, res)
+       })
+
+       t.Run("IFDEQ: digest matches writes and returns OK", func(t *testing.T) 
{
+               require.NoError(t, rdb.Set(ctx, "ifdeq2", "hello", 0).Err())
+               digest, err := rdb.Do(ctx, "DIGEST", "ifdeq2").Result()
+               require.NoError(t, err)
+               res := rdb.Do(ctx, "SET", "ifdeq2", "world", "IFDEQ", 
digest).Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "world", rdb.Get(ctx, "ifdeq2").Val())
+       })
+
+       t.Run("IFDEQ: digest mismatches returns nil and no write", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifdeq3", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifdeq3", "world", "IFDEQ", 
"xxxxxxxxxxxxxxxx").Val()
+               require.Nil(t, res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifdeq3").Val())
+       })
+
+       t.Run("IFDNE: key not found writes and returns OK", func(t *testing.T) {
+               require.NoError(t, rdb.Del(ctx, "ifdne1").Err())
+               res := rdb.Do(ctx, "SET", "ifdne1", "created", "IFDNE", 
"xxxxxxxxxxxxxxxx").Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "created", rdb.Get(ctx, "ifdne1").Val())
+       })
+
+       t.Run("IFDNE: digest matches returns nil and no write", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifdne2", "hello", 0).Err())
+               digest, err := rdb.Do(ctx, "DIGEST", "ifdne2").Result()
+               require.NoError(t, err)
+               res := rdb.Do(ctx, "SET", "ifdne2", "world", "IFDNE", 
digest).Val()
+               require.Nil(t, res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifdne2").Val())
+       })
+
+       t.Run("IFDNE: digest mismatches writes and returns OK", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifdne3", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifdne3", "world", "IFDNE", 
"xxxxxxxxxxxxxxxx").Val()
+               require.Equal(t, "OK", res)
+               require.Equal(t, "world", rdb.Get(ctx, "ifdne3").Val())
+       })
+
+       t.Run("IFEQ with GET: condition met returns old value", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq-get1", "old", 0).Err())
+               res, err := rdb.Do(ctx, "SET", "ifeq-get1", "new", "IFEQ", 
"old", "GET").Result()
+               require.NoError(t, err)
+               require.Equal(t, "old", res)
+               require.Equal(t, "new", rdb.Get(ctx, "ifeq-get1").Val())
+       })
+
+       t.Run("IFEQ with GET: condition not met returns old value", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq-get2", "hello", 0).Err())
+               res, err := rdb.Do(ctx, "SET", "ifeq-get2", "new", "IFEQ", 
"wrong", "GET").Result()
+               require.NoError(t, err)
+               require.Equal(t, "hello", res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifeq-get2").Val())
+       })
+
+       t.Run("IFEQ with EX: condition met sets TTL", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq-ex1", "hello", 0).Err())
+               res, err := rdb.Do(ctx, "SET", "ifeq-ex1", "world", "IFEQ", 
"hello", "EX", "10").Result()
+               require.NoError(t, err)
+               require.Equal(t, "OK", res)
+               ttl := rdb.TTL(ctx, "ifeq-ex1").Val()
+               require.Greater(t, ttl, 8*time.Second)
+               require.LessOrEqual(t, ttl, 10*time.Second)
+       })
+
+       t.Run("IFEQ with EX: condition not met leaves TTL unchanged", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifeq-ex2", "hello", 
5*time.Second).Err())
+               res := rdb.Do(ctx, "SET", "ifeq-ex2", "world", "IFEQ", "wrong", 
"EX", "100").Val()
+               require.Nil(t, res)
+               ttl := rdb.TTL(ctx, "ifeq-ex2").Val()
+               require.Greater(t, ttl, time.Duration(0))
+               require.LessOrEqual(t, ttl, 5*time.Second)
+       })
+
+       t.Run("IFDEQ consistent with DIGEST command output", func(t *testing.T) 
{
+               require.NoError(t, rdb.Set(ctx, "digest-check", "somevalue", 
0).Err())
+               digest, err := rdb.Do(ctx, "DIGEST", "digest-check").Result()
+               require.NoError(t, err)
+               res, err := rdb.Do(ctx, "SET", "digest-check", "newvalue", 
"IFDEQ", digest).Result()
+               require.NoError(t, err)
+               require.Equal(t, "OK", res)
+       })
+
+       t.Run("IFDEQ accepts uppercase digest", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifdeq-upper", "hello", 
0).Err())
+               digest, err := rdb.Do(ctx, "DIGEST", "ifdeq-upper").Text()
+               require.NoError(t, err)
+
+               res, err := rdb.Do(ctx, "SET", "ifdeq-upper", "world", "IFDEQ", 
strings.ToUpper(digest)).Result()
+               require.NoError(t, err)
+               require.Equal(t, "OK", res)
+               require.Equal(t, "world", rdb.Get(ctx, "ifdeq-upper").Val())
+       })
+
+       t.Run("IFDNE treats uppercase digest as a match", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifdne-upper", "hello", 
0).Err())
+               digest, err := rdb.Do(ctx, "DIGEST", "ifdne-upper").Text()
+               require.NoError(t, err)
+
+               res := rdb.Do(ctx, "SET", "ifdne-upper", "world", "IFDNE", 
strings.ToUpper(digest)).Val()
+               require.Nil(t, res)
+               require.Equal(t, "hello", rdb.Get(ctx, "ifdne-upper").Val())
+       })
+
+       t.Run("IFDEQ and IFDNE reject malformed digest lengths", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifd-bad-digest", "hello", 
0).Err())
+
+               testCases := []struct {
+                       name   string
+                       option string
+                       digest string
+               }{
+                       {name: "IFDEQ short digest", option: "IFDEQ", digest: 
"1234567890abcde"},
+                       {name: "IFDNE short digest", option: "IFDNE", digest: 
"1234567890abcde"},
+                       {name: "IFDEQ long digest", option: "IFDEQ", digest: 
"01234567890abcdef"},
+                       {name: "IFDNE long digest", option: "IFDNE", digest: 
"01234567890abcdef"},
+                       {name: "IFDEQ empty digest", option: "IFDEQ", digest: 
""},
+                       {name: "IFDNE empty digest", option: "IFDNE", digest: 
""},
+               }
+
+               for _, tc := range testCases {
+                       t.Run(tc.name, func(t *testing.T) {
+                               err := rdb.Do(ctx, "SET", "ifd-bad-digest", 
"world", tc.option, tc.digest).Err()
+                               require.ErrorContains(t, err, "must be exactly 
16 hexadecimal characters")
+                               require.Equal(t, "hello", rdb.Get(ctx, 
"ifd-bad-digest").Val())
+                       })
+               }
+       })
+
+       t.Run("IFDEQ and IFDNE: non-hex 16-char digest treated as non-match", 
func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "ifd-nonhex", "hello", 0).Err())
+               res := rdb.Do(ctx, "SET", "ifd-nonhex", "world", "IFDEQ", 
"GGGGGGGGGGGGGGGG").Val()
+               require.Nil(t, res, "IFDEQ with non-hex 16-char digest should 
return nil (no match)")
+               require.Equal(t, "hello", rdb.Get(ctx, "ifd-nonhex").Val())
+
+               res = rdb.Do(ctx, "SET", "ifd-nonhex", "world", "IFDNE", 
"GGGGGGGGGGGGGGGG").Val()
+               require.Equal(t, "OK", res, "IFDNE with non-hex 16-char digest 
should write (no match)")
+               require.Equal(t, "world", rdb.Get(ctx, "ifd-nonhex").Val())
+       })
+
+       t.Run("IFEQ writes when value matches (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop1-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       newVal := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res, err := rdb.Do(ctx, "SET", key, newVal, "IFEQ", 
val).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "OK", res, "IFEQ should write when 
cmp_value matches current value")
+                       require.Equal(t, newVal, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFEQ does not write when value mismatches (100 iterations)", 
func(t *testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop2-" + strconv.Itoa(i)
+                       val := "value-" + strconv.Itoa(i)
+                       wrong := "wrong-" + strconv.Itoa(i)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res := rdb.Do(ctx, "SET", key, "new", "IFEQ", 
wrong).Val()
+                       require.Nil(t, res, "IFEQ should return nil when 
cmp_value does not match")
+                       require.Equal(t, val, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFNE writes when value mismatches (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop3-" + strconv.Itoa(i)
+                       val := "value-" + strconv.Itoa(i)
+                       wrong := "wrong-" + strconv.Itoa(i)
+                       newVal := "new-" + strconv.Itoa(i)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res, err := rdb.Do(ctx, "SET", key, newVal, "IFNE", 
wrong).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "OK", res, "IFNE should write when 
cmp_value does not match current value")
+                       require.Equal(t, newVal, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFNE does not write when value matches (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop4-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res := rdb.Do(ctx, "SET", key, "new", "IFNE", val).Val()
+                       require.Nil(t, res, "IFNE should return nil when 
cmp_value matches current value")
+                       require.Equal(t, val, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFDEQ writes when digest matches (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop5-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       newVal := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       digest, err := rdb.Do(ctx, "DIGEST", key).Result()
+                       require.NoError(t, err)
+                       res, err := rdb.Do(ctx, "SET", key, newVal, "IFDEQ", 
digest).Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "OK", res, "IFDEQ should write when 
digest matches")
+                       require.Equal(t, newVal, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFDEQ does not write when digest mismatches (100 iterations)", 
func(t *testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop6-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res := rdb.Do(ctx, "SET", key, "new", "IFDEQ", 
"xxxxxxxxxxxxxxxx").Val()
+                       require.Nil(t, res, "IFDEQ should return nil when 
digest does not match")
+                       require.Equal(t, val, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFDNE writes when digest mismatches (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop7-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       newVal := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res, err := rdb.Do(ctx, "SET", key, newVal, "IFDNE", 
"xxxxxxxxxxxxxxxx").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "OK", res, "IFDNE should write when 
digest does not match")
+                       require.Equal(t, newVal, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("IFDNE does not write when digest matches (100 iterations)", 
func(t *testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop8-" + strconv.Itoa(i)
+                       val := util.RandString(1, 20, util.Alpha)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       digest, err := rdb.Do(ctx, "DIGEST", key).Result()
+                       require.NoError(t, err)
+                       res := rdb.Do(ctx, "SET", key, "new", "IFDNE", 
digest).Val()
+                       require.Nil(t, res, "IFDNE should return nil when 
digest matches")
+                       require.Equal(t, val, rdb.Get(ctx, key).Val())
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("TTL unchanged when condition not met (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop9-" + strconv.Itoa(i)
+                       val := "value-" + strconv.Itoa(i)
+                       require.NoError(t, rdb.Set(ctx, key, val, 
10*time.Second).Err())
+                       res := rdb.Do(ctx, "SET", key, "new", "IFEQ", "wrong", 
"EX", "9999").Val()
+                       require.Nil(t, res)
+                       ttl := rdb.TTL(ctx, key).Val()
+                       require.Greater(t, ttl, time.Duration(0), "TTL should 
remain positive after failed conditional SET")
+                       require.LessOrEqual(t, ttl, 10*time.Second)
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("TTL correctly set when condition met (100 iterations)", func(t 
*testing.T) {
+               for i := 0; i < 100; i++ {
+                       key := "prop10-" + strconv.Itoa(i)
+                       val := "value-" + strconv.Itoa(i)
+                       require.NoError(t, rdb.Set(ctx, key, val, 0).Err())
+                       res, err := rdb.Do(ctx, "SET", key, "new", "IFEQ", val, 
"EX", "30").Result()
+                       require.NoError(t, err)
+                       require.Equal(t, "OK", res)
+                       ttl := rdb.TTL(ctx, key).Val()
+                       require.Greater(t, ttl, 28*time.Second)
+                       require.LessOrEqual(t, ttl, 30*time.Second)
+                       require.NoError(t, rdb.Del(ctx, key).Err())
+               }
+       })
+
+       t.Run("Extended SET GET and NX option on wrong type", func(t 
*testing.T) {
+               require.NoError(t, rdb.Del(ctx, "listkey").Err())
+               require.NoError(t, rdb.LPush(ctx, "listkey", "v1").Err())
+               require.ErrorContains(t, rdb.Do(ctx, "SET", "listkey", "v", 
"NX", "GET").Err(), "WRONGTYPE")
+       })
 }

Reply via email to