diff --git a/src/commands/cmd_string.cc b/src/commands/cmd_string.cc index a13ca38b213..25ac408ce73 100644 --- a/src/commands/cmd_string.cc +++ b/src/commands/cmd_string.cc @@ -721,10 +721,29 @@ class CommandLCS : public Commander { int64_t min_match_len_ = 0; }; +class CommandDigest : public Commander { + public: + Status Execute(engine::Context &ctx, Server *srv, Connection *conn, std::string *output) override { + redis::String string_db(srv->storage, conn->GetNamespace()); + std::string digest; + auto s = string_db.Digest(ctx, args_[1], &digest); + if (!s.ok() && !s.IsNotFound()) { + return {Status::RedisExecErr, s.ToString()}; + } + if (s.IsNotFound()) { + *output = conn->NilString(); + return Status::OK(); + } + *output = redis::BulkString(digest); + return Status::OK(); + } +}; + REDIS_REGISTER_COMMANDS( String, MakeCmdAttr("get", 2, "read-only", 1, 1, 1), MakeCmdAttr("getex", -2, "write", 1, 1, 1), MakeCmdAttr("strlen", 2, "read-only", 1, 1, 1), + MakeCmdAttr("digest", 2, "read-only", 1, 1, 1), MakeCmdAttr("getset", 3, "write", 1, 1, 1), MakeCmdAttr("getrange", 4, "read-only", 1, 1, 1), MakeCmdAttr("substr", 4, "read-only", 1, 1, 1), diff --git a/src/common/string_util.cc b/src/common/string_util.cc index 2342db323fc..c3cbc511e31 100644 --- a/src/common/string_util.cc +++ b/src/common/string_util.cc @@ -26,6 +26,7 @@ #include #include "parse_util.h" +#include "xxh3.h" namespace util { @@ -556,4 +557,9 @@ std::string StringNext(std::string s) { return s; } +std::string StringDigest(std::string_view data) { + XXH64_hash_t hash = XXH3_64bits(data.data(), data.size()); + return fmt::format("{:016x}", hash); +} + } // namespace util diff --git a/src/common/string_util.h b/src/common/string_util.h index 88232873ad5..8375f027fa6 100644 --- a/src/common/string_util.h +++ b/src/common/string_util.h @@ -62,6 +62,7 @@ std::string StringToHex(std::string_view input); std::vector TokenizeRedisProtocol(const std::string &value); std::string EscapeString(std::string_view s); std::string StringNext(std::string s); +std::string StringDigest(std::string_view data); template , int> = 0> std::string StringJoin(const T &con, F &&f, std::string_view sep = ", ") { diff --git a/src/types/redis_string.cc b/src/types/redis_string.cc index a3d27492e0f..00cb099abed 100644 --- a/src/types/redis_string.cc +++ b/src/types/redis_string.cc @@ -26,6 +26,7 @@ #include #include +#include "common/string_util.h" #include "parse_util.h" #include "storage/redis_metadata.h" #include "time_util.h" @@ -657,4 +658,15 @@ rocksdb::Status String::LCS(engine::Context &ctx, const std::string &user_key1, return rocksdb::Status::OK(); } +rocksdb::Status String::Digest(engine::Context &ctx, const std::string &user_key, std::string *digest) { + std::string value; + auto s = Get(ctx, user_key, &value); + if (!s.ok()) { + return s; + } + + *digest = util::StringDigest(value); + return rocksdb::Status::OK(); +} + } // namespace redis diff --git a/src/types/redis_string.h b/src/types/redis_string.h index e5025d64fc6..a37f6354441 100644 --- a/src/types/redis_string.h +++ b/src/types/redis_string.h @@ -107,6 +107,7 @@ class String : public Database { rocksdb::Status CAD(engine::Context &ctx, const std::string &user_key, const std::string &value, int *flag); rocksdb::Status LCS(engine::Context &ctx, const std::string &user_key1, const std::string &user_key2, StringLCSArgs args, StringLCSResult *rst); + rocksdb::Status Digest(engine::Context &ctx, const std::string &user_key, std::string *digest); private: rocksdb::Status getValue(engine::Context &ctx, const std::string &ns_key, std::string *value); diff --git a/tests/gocase/unit/type/string/digest_test.go b/tests/gocase/unit/type/string/digest_test.go new file mode 100644 index 00000000000..2ee9084ec07 --- /dev/null +++ b/tests/gocase/unit/type/string/digest_test.go @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package string + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apache/kvrocks/tests/gocase/util" +) + +func TestDigest(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()) }() + + t.Run("DIGEST with existing string key", func(t *testing.T) { + require.NoError(t, rdb.Set(ctx, "key1", "Hello world", 0).Err()) + + digest := rdb.Do(ctx, "DIGEST", "key1").Val().(string) + require.Equal(t, "b6acb9d84a38ff74", digest) + }) + + t.Run("DIGEST with non-existent key", func(t *testing.T) { + digest := rdb.Do(ctx, "DIGEST", "nonexistent").Val() + require.Nil(t, digest) + }) + + t.Run("DIGEST with different string values produces different hashes", func(t *testing.T) { + require.NoError(t, rdb.Set(ctx, "key1", "Hello", 0).Err()) + require.NoError(t, rdb.Set(ctx, "key2", "World", 0).Err()) + + digest1 := rdb.Do(ctx, "DIGEST", "key1").Val().(string) + digest2 := rdb.Do(ctx, "DIGEST", "key2").Val().(string) + + require.NotEqual(t, digest1, digest2) + }) + + t.Run("DIGEST with same string value produces same hash", func(t *testing.T) { + require.NoError(t, rdb.Set(ctx, "key1", "consistent", 0).Err()) + require.NoError(t, rdb.Set(ctx, "key2", "consistent", 0).Err()) + + digest1 := rdb.Do(ctx, "DIGEST", "key1").Val().(string) + digest2 := rdb.Do(ctx, "DIGEST", "key2").Val().(string) + + require.Equal(t, digest1, digest2) + }) + + t.Run("DIGEST with empty string", func(t *testing.T) { + require.NoError(t, rdb.Set(ctx, "empty", "", 0).Err()) + + digest := rdb.Do(ctx, "DIGEST", "empty").Val().(string) + require.NotEmpty(t, digest) + require.Len(t, digest, 16) + }) + + t.Run("DIGEST with binary data", func(t *testing.T) { + binaryData := "\x00\x01\x02\xff\xfe\xfd" + require.NoError(t, rdb.Set(ctx, "binary", binaryData, 0).Err()) + + digest := rdb.Do(ctx, "DIGEST", "binary").Val().(string) + require.NotEmpty(t, digest) + require.Len(t, digest, 16) + }) + + t.Run("DIGEST with large string", func(t *testing.T) { + largeString := make([]byte, 10240) + for i := range largeString { + largeString[i] = byte(i % 256) + } + + require.NoError(t, rdb.Set(ctx, "large", string(largeString), 0).Err()) + + digest := rdb.Do(ctx, "DIGEST", "large").Val().(string) + require.NotEmpty(t, digest) + require.Len(t, digest, 16) + }) + + t.Run("DIGEST wrong number of arguments", func(t *testing.T) { + err := rdb.Do(ctx, "DIGEST").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "wrong number of arguments") + + err = rdb.Do(ctx, "DIGEST", "key1", "extra").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "wrong number of arguments") + }) + + t.Run("DIGEST with wrong key type should fail", func(t *testing.T) { + require.NoError(t, rdb.LPush(ctx, "list_key", "value").Err()) + + err := rdb.Do(ctx, "DIGEST", "list_key").Err() + require.Error(t, err) + require.Contains(t, err.Error(), "WRONGTYPE") + }) +} + +func TestDigestCompatibility(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()) }() + + testCases := []struct { + name string + value string + expected string + }{ + {"simple string", "hello", "9555e8555c62dcfd"}, + {"number as string", "123", "404a763b3f4c8c9a"}, + {"special chars", "!@#$%^&*()", "078a90faff0bf161"}, + {"unicode", "こんにちは", "37267692105b8cbf"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, rdb.Set(ctx, "test_key", tc.value, 0).Err()) + + digest := rdb.Do(ctx, "DIGEST", "test_key").Val().(string) + require.Equal(t, tc.expected, digest) + }) + } +} +