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 e2d43130327..9f1c05e7078 100644 --- a/src/types/redis_string.cc +++ b/src/types/redis_string.cc @@ -271,6 +271,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..1323b61e8e2 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 { diff --git a/tests/cppunit/types/string_test.cc b/tests/cppunit/types/string_test.cc index f30ef162222..e5916e37292 100644 --- a/tests/cppunit/types/string_test.cc +++ b/tests/cppunit/types/string_test.cc @@ -580,3 +580,222 @@ TEST_F(RedisStringTest, LCS) { 4}, std::get(rst)); } + +TEST_F(RedisStringTest, SetIFEQ) { + std::string key = "ifeq-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::IFEQ, false, false, 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); + + // value matches: write succeeds + ret = std::nullopt; + s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFEQ, false, false, value}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("new", got); + + // value mismatches: no write + ret = std::nullopt; + s = string_->Set(*ctx_, key, "newer", {0, StringSetType::IFEQ, false, false, "wrong"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_FALSE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("new", got); + + EXPECT_TRUE(string_->Del(*ctx_, key).ok()); +} + +TEST_F(RedisStringTest, SetIFNE) { + std::string key = "ifne-key"; + std::string value = "hello"; + std::optional ret; + + // key not found: condition met (creates key) + auto s = string_->Set(*ctx_, key, "created", {0, StringSetType::IFNE, false, false, "anything"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + std::string got; + string_->Get(*ctx_, key, &got); + EXPECT_EQ("created", got); + + // value matches: condition not met, no write + ret = std::nullopt; + s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFNE, false, false, "created"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_FALSE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("created", got); + + // value mismatches: write succeeds + ret = std::nullopt; + s = string_->Set(*ctx_, key, "updated", {0, StringSetType::IFNE, false, false, "wrong"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("updated", got); + + EXPECT_TRUE(string_->Del(*ctx_, key).ok()); +} + +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()); +} + +TEST_F(RedisStringTest, SetIFDNE) { + std::string key = "ifdne-key"; + std::string value = "hello"; + std::optional ret; + + // key not found: condition met (creates key) + auto s = string_->Set(*ctx_, key, "created", {0, StringSetType::IFDNE, false, false, "xxxxxxxxxxxxxxxx"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + std::string got; + string_->Get(*ctx_, key, &got); + EXPECT_EQ("created", got); + + // digest matches: condition not met, no write + ret = std::nullopt; + s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFDNE, false, false, util::StringDigest("created")}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_FALSE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("created", got); + + // digest mismatches: write succeeds + ret = std::nullopt; + s = string_->Set(*ctx_, key, "updated", {0, StringSetType::IFDNE, false, false, "xxxxxxxxxxxxxxxx"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + string_->Get(*ctx_, key, &got); + EXPECT_EQ("updated", got); + + EXPECT_TRUE(string_->Del(*ctx_, key).ok()); +} + +TEST_F(RedisStringTest, SetConditionalWithGET) { + std::string key = "cond-get-key"; + std::string value = "original"; + std::optional ret; + + string_->Set(*ctx_, key, value); + + // IFEQ + GET, condition met: returns old value + ret = std::nullopt; + auto s = string_->Set(*ctx_, key, "new", {0, StringSetType::IFEQ, true, false, value}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + EXPECT_EQ(value, ret.value()); + + // IFEQ + GET, condition not met: returns old value (GET always returns prev value) + ret = std::nullopt; + s = string_->Set(*ctx_, key, "newer", {0, StringSetType::IFEQ, true, false, "wrong"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + EXPECT_EQ("new", ret.value()); // old value returned even though condition not met + + // IFNE + GET, condition met (value mismatches): returns old value + ret = std::nullopt; + s = string_->Set(*ctx_, key, "ifne-new", {0, StringSetType::IFNE, true, false, "wrong"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + EXPECT_EQ("new", ret.value()); // key was "new" before this SET + + // key not found + IFEQ + GET: returns nullopt + EXPECT_TRUE(string_->Del(*ctx_, key).ok()); + ret = std::nullopt; + s = string_->Set(*ctx_, key, "val", {0, StringSetType::IFEQ, true, false, "anything"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_FALSE(ret.has_value()); +} + +TEST_F(RedisStringTest, SetConditionalWithTTL) { + std::string key = "cond-ttl-key"; + std::string value = "hello"; + std::optional ret; + + string_->Set(*ctx_, key, value); + + // condition met + EX: TTL is set + uint64_t future_ms = util::GetTimeStampMS() + 5000; + ret = std::nullopt; + auto s = string_->Set(*ctx_, key, "new", {future_ms, StringSetType::IFEQ, false, false, value}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + int64_t ttl = 0; + EXPECT_TRUE(string_->TTL(*ctx_, key, &ttl).ok()); + EXPECT_GT(ttl, 3000); + EXPECT_LE(ttl, 6000); + + // condition met + KEEPTTL: original TTL preserved + ret = std::nullopt; + s = string_->Set(*ctx_, key, "newer", {0, StringSetType::IFEQ, false, true, "new"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_TRUE(ret.has_value()); + int64_t ttl2 = 0; + EXPECT_TRUE(string_->TTL(*ctx_, key, &ttl2).ok()); + EXPECT_GT(ttl2, 0); + + // condition not met: TTL unchanged + int64_t ttl_before = 0; + EXPECT_TRUE(string_->TTL(*ctx_, key, &ttl_before).ok()); + ret = std::nullopt; + s = string_->Set(*ctx_, key, "fail", {0, StringSetType::IFEQ, false, false, "wrong"}, ret); + EXPECT_TRUE(s.ok()); + EXPECT_FALSE(ret.has_value()); + int64_t ttl_after = 0; + EXPECT_TRUE(string_->TTL(*ctx_, key, &ttl_after).ok()); + // TTL should still be positive and roughly the same + EXPECT_GT(ttl_after, 0); + + 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 3c2515210ae..24a51cb6e4d 100644 --- a/tests/gocase/unit/type/strings/strings_test.go +++ b/tests/gocase/unit/type/strings/strings_test.go @@ -1206,3 +1206,369 @@ 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 syntax error", func(t *testing.T) { + err := rdb.Do(ctx, "SET", "k", "v", "IFEQ").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "syntax") + }) + + t.Run("IFNE missing cmp_value returns syntax error", func(t *testing.T) { + err := rdb.Do(ctx, "SET", "k", "v", "IFNE").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "syntax") + }) + + t.Run("IFDEQ missing cmp_value returns syntax error", func(t *testing.T) { + err := rdb.Do(ctx, "SET", "k", "v", "IFDEQ").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "syntax") + }) + + t.Run("IFDNE missing cmp_value returns syntax error", func(t *testing.T) { + err := rdb.Do(ctx, "SET", "k", "v", "IFDNE").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "syntax") + }) + + 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, err := rdb.Do(ctx, "SET", "ifeq1", "new", "IFEQ", "anything").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifeq2", "world", "IFEQ", "hello").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifeq3", "world", "IFEQ", "wrong").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifne1", "created", "IFNE", "anything").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifne2", "world", "IFNE", "hello").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifne3", "world", "IFNE", "wrong").Result() + require.NoError(t, err) + 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()) + digest := rdb.Do(ctx, "DIGEST", "ifdeq1").Val() // will be error/nil, use a dummy + _ = digest + res, err := rdb.Do(ctx, "SET", "ifdeq1", "new", "IFDEQ", "xxxxxxxxxxxxxxxx").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifdeq2", "world", "IFDEQ", digest).Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifdeq3", "world", "IFDEQ", "xxxxxxxxxxxxxxxx").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifdne1", "created", "IFDNE", "xxxxxxxxxxxxxxxx").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifdne2", "world", "IFDNE", digest).Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifdne3", "world", "IFDNE", "xxxxxxxxxxxxxxxx").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", "ifeq-ex2", "world", "IFEQ", "wrong", "EX", "100").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", key, "new", "IFEQ", wrong).Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", key, "new", "IFNE", val).Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", key, "new", "IFDEQ", "xxxxxxxxxxxxxxxx").Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", key, "new", "IFDNE", digest).Result() + require.NoError(t, err) + 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, err := rdb.Do(ctx, "SET", key, "new", "IFEQ", "wrong", "EX", "9999").Result() + require.NoError(t, err) + 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()) + } + }) +}