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