From 0ba199056753cea2fbe09fb0bd91d9b727461e10 Mon Sep 17 00:00:00 2001 From: kirito632 Date: Thu, 16 Apr 2026 20:31:24 +0800 Subject: [PATCH] feat(string): support IFEQ/IFNE/IFDEQ/IFDNE in SET command --- src/commands/cmd_string.cc | 15 +- src/types/redis_string.cc | 49 ++- src/types/redis_string.h | 7 +- tests/cppunit/types/string_test.cc | 43 +++ .../gocase/unit/type/strings/strings_test.go | 348 ++++++++++++++++++ 5 files changed, 457 insertions(+), 5 deletions(-) diff --git a/src/commands/cmd_string.cc b/src/commands/cmd_string.cc index d9ff39770de..829b3c55520 100644 --- a/src/commands/cmd_string.cc +++ b/src/commands/cmd_string.cc @@ -344,6 +344,18 @@ 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()); + } else if (parser.EatEqICaseFlag("IFDNE", set_flag)) { + set_flag_ = StringSetType::IFDNE; + cmp_value_ = GET_OR_RET(parser.TakeStr()); } else if (parser.EatEqICase("GET")) { get_ = true; } else { @@ -358,7 +370,7 @@ class CommandSet : public Commander { std::optional 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()}; @@ -385,6 +397,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 e056d341355..8f2233cc1ab 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 &ret) { + const StringSetArgs &args, std::optional &ret) { uint64_t expire = 0; std::string ns_key = AppendNamespacePrefix(user_key); @@ -249,6 +249,21 @@ 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 (!args.get) 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 +286,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 not_matched = s.ok() && (old_value == args.cmp_value); + if (not_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 + bool matched = s.ok() && (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; key-not-found counts as met + bool not_matched = s.ok() && (util::StringDigest(old_value) == args.cmp_value); + if (not_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 b160d3d6da3..57fb309da5c 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 &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 &ret); + rocksdb::Status Set(engine::Context &ctx, const std::string &user_key, const std::string &value, + const StringSetArgs &args, std::optional &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 d7f79eab0f6..aa07b787e83 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(rst)); } + +TEST_F(RedisStringTest, SetIFDEQ) { + std::string key = "ifdeq-key"; + std::string value = "hello"; + std::optional 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 8f6648a51ef..ac73bf1b172 100644 --- a/tests/gocase/unit/type/strings/strings_test.go +++ b/tests/gocase/unit/type/strings/strings_test.go @@ -1205,3 +1205,351 @@ func testString(t *testing.T, configs util.KvrocksServerConfigs) { require.Equal(t, []redis.LCSMatchedPosition{}, rdb.LCS(ctx, &redis.LCSQuery{Key1: "virus1", Key2: "virus2", Idx: true, WithMatchLen: true}).Val().Matches) }) } + +func TestSetConditional(t *testing.T) { + srv := util.StartServer(t, map[string]string{}) + defer srv.Close() + ctx := context.Background() + rdb := srv.NewClient() + defer func() { require.NoError(t, rdb.Close()) }() + + // ── 6.1 Syntax / parse error cases ────────────────────────────────────── + + 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()) + }) + + // ── 6.2 Basic conditional behaviour ───────────────────────────────────── + + 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) + }) + + // ── Property tests (using testing/quick via subtests) ─────────────────── + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 1: IFEQ writes when value matches + t.Run("Property 1: IFEQ writes when value matches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 2: IFEQ does not write when value mismatches + t.Run("Property 2: IFEQ does not write when value mismatches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 3: IFNE writes when value mismatches + t.Run("Property 3: IFNE writes when value mismatches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 4: IFNE does not write when value matches + t.Run("Property 4: IFNE does not write when value matches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 5: IFDEQ writes when digest matches + t.Run("Property 5: IFDEQ writes when digest matches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 6: IFDEQ does not write when digest mismatches + t.Run("Property 6: IFDEQ does not write when digest mismatches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 7: IFDNE writes when digest mismatches + t.Run("Property 7: IFDNE writes when digest mismatches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 8: IFDNE does not write when digest matches + t.Run("Property 8: IFDNE does not write when digest matches", 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 9: TTL unchanged when condition not met + t.Run("Property 9: TTL unchanged when condition not met", 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()) + // IFEQ with wrong value: condition not met + 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()) + } + }) + + // Feature: set-ifeq-ifne-ifdeq-ifdne, Property 10: TTL correctly set when condition met + t.Run("Property 10: TTL correctly set when condition met", 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()) + // IFEQ with correct value + EX + 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") + }) +}