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 cbafb7a91 feat(bit): add BYTE/BIT option support for BITPOS command 
(#3460)
cbafb7a91 is described below

commit cbafb7a9142d234759fbdd9db1938757cb28f95f
Author: gongna-au <[email protected]>
AuthorDate: Wed May 6 11:45:15 2026 +0800

    feat(bit): add BYTE/BIT option support for BITPOS command (#3460)
    
    ## Summary
    This PR adds support for the `BYTE`/`BIT` unit option to the `BITPOS`
    command, aligning it with the Redis 7.0 specification.
    
    The previous implementation only checked for `BIT` at a fixed argument
    position (`args[5]`) and ignored `BYTE` entirely. This PR refactors the
    parser to correctly handle the full Redis 7.0 syntax:
    
    ```
    BITPOS key bit [start [end [BYTE | BIT]]]
    ```
    
    The `BYTE|BIT` unit is strictly nested: it can only appear after both
    `start` and `end` are provided, matching Redis behavior exactly.
    
    ## Changes
    ### 1. Command Parser Refactor (`src/commands/cmd_bit.cc`)
    - Refactored `CommandBitPos::Parse` to cleanly separate argument parsing
    by count.
    - `args[4]` (the 5th argument) is always parsed as the `end` integer —
    never as a unit keyword. This matches Redis, which rejects `BITPOS key 1
    0 BIT` with a "not an integer" error.
    - `args[5]` (the 6th argument) is checked for `BIT`/`BYTE` keyword, with
    `errInvalidSyntax` on anything else.
    - Added explicit `BYTE` recognition (previously only `BIT` was partially
    handled).
    - Moved `bit` argument validation to the top for clarity.
    - Replaced verbose `ParseInt` + manual error check with `GET_OR_RET`
    macro for consistency.
    
    ### 2. Storage Layer — No Changes Needed
    - The `CHECK(stop_given)` assertion in `Bitmap::BitPos` is preserved as
    a defensive guard. Since the parser guarantees `stop_given=true`
    whenever `is_bit_index=true` (unit keyword requires `end` to be
    present), this assertion is correct and protects against future
    regressions.
    
    ## Compatibility
    
    | Command Form | Redis 7.0 | Kvrocks (after PR) |
    | --- | --- | --- |
    | `BITPOS key bit` | OK | OK |
    | `BITPOS key bit start` | OK | OK |
    | `BITPOS key bit start end` | OK | OK |
    | `BITPOS key bit start end BYTE` | OK (since 7.0) | **OK (new)** |
    | `BITPOS key bit start end BIT` | OK (since 7.0) | **OK (new)** |
    | `BITPOS key bit start BIT` | ERR not integer | ERR not integer |
    
    ## Verification
    - Argument parsing logic verified against Redis 7.0 syntax definition:
    `BITPOS key bit [start [end [BYTE | BIT]]]`.
    - `is_bit_index` is only set when `args.size() == 6`, guaranteeing
    `stop_given_ == true`, so the `CHECK(stop_given)` assertion in the
    storage layer is always satisfied.
    - `BITCOUNT` already has correct `BYTE/BIT` support and is not affected
    by this PR.
    
    Co-authored-by: gongna-au <[email protected]>
    Co-authored-by: Sisyphus <[email protected]>
    Co-authored-by: 纪华裕 <[email protected]>
---
 src/commands/cmd_bit.cc                      |  44 +++---
 tests/gocase/unit/type/bitmap/bitmap_test.go | 217 +++++++++++++++++++++++++++
 2 files changed, 235 insertions(+), 26 deletions(-)

diff --git a/src/commands/cmd_bit.cc b/src/commands/cmd_bit.cc
index eb829d31c..ba9f721e9 100644
--- a/src/commands/cmd_bit.cc
+++ b/src/commands/cmd_bit.cc
@@ -158,39 +158,31 @@ class CommandBitPos : public Commander {
   using Commander::Parse;
 
   Status Parse(const std::vector<std::string> &args) override {
-    if (args.size() >= 4) {
-      auto parse_start = ParseInt<int64_t>(args[3], 10);
-      if (!parse_start) {
-        return {Status::RedisParseErr, errValueNotInteger};
-      }
+    auto parse_bit = ParseInt<int>(args[2], 10);
+    if (!parse_bit || (*parse_bit != 0 && *parse_bit != 1)) {
+      return {Status::RedisParseErr, "The bit argument must be 1 or 0."};
+    }
+    bit_ = (*parse_bit == 1);
 
-      start_ = *parse_start;
+    if (args.size() >= 4) {
+      start_ = GET_OR_RET(ParseInt<int64_t>(args[3], 10));
     }
 
     if (args.size() >= 5) {
-      auto parse_stop = ParseInt<int64_t>(args[4], 10);
-      if (!parse_stop) {
-        return {Status::RedisParseErr, errValueNotInteger};
-      }
-
+      stop_ = GET_OR_RET(ParseInt<int64_t>(args[4], 10));
       stop_given_ = true;
-      stop_ = *parse_stop;
     }
 
-    if (args.size() >= 6 && util::EqualICase(args[5], "BIT")) {
-      is_bit_index_ = true;
-    }
-
-    auto parse_arg = ParseInt<int64_t>(args[2], 10);
-    if (!parse_arg) {
-      return {Status::RedisParseErr, errValueNotInteger};
-    }
-    if (*parse_arg == 0) {
-      bit_ = false;
-    } else if (*parse_arg == 1) {
-      bit_ = true;
-    } else {
-      return {Status::RedisParseErr, "The bit argument must be 1 or 0."};
+    if (args.size() == 6) {
+      if (util::EqualICase(args[5], "BIT")) {
+        is_bit_index_ = true;
+      } else if (util::EqualICase(args[5], "BYTE")) {
+        is_bit_index_ = false;
+      } else {
+        return {Status::RedisParseErr, errInvalidSyntax};
+      }
+    } else if (args.size() > 6) {
+      return {Status::RedisParseErr, errInvalidSyntax};
     }
 
     return Commander::Parse(args);
diff --git a/tests/gocase/unit/type/bitmap/bitmap_test.go 
b/tests/gocase/unit/type/bitmap/bitmap_test.go
index ede1a7d43..0f3399316 100644
--- a/tests/gocase/unit/type/bitmap/bitmap_test.go
+++ b/tests/gocase/unit/type/bitmap/bitmap_test.go
@@ -541,6 +541,223 @@ func TestBitmap(t *testing.T) {
                require.EqualValues(t, 2, cmd.Val())
        })
 
+       t.Run("BITPOS BYTE option produces same result as default byte-indexed 
mode", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\xff\xf0", 
0).Err())
+               byteResult := rdb.BitPos(ctx, "mykey", 1, 1)
+               require.NoError(t, byteResult.Err())
+               explicitByte := rdb.BitPosSpan(ctx, "mykey", 1, 1, -1, "byte")
+               require.NoError(t, explicitByte.Err())
+               require.EqualValues(t, byteResult.Val(), explicitByte.Val())
+       })
+
+       t.Run("BITPOS BYTE option is case-insensitive", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\xff\xf0", 
0).Err())
+               lower, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, -1, 
"byte").Int64()
+               require.NoError(t, err)
+               upper, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, -1, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, lower, upper)
+       })
+
+       t.Run("BITPOS BIT vs BYTE gives different results for same range", 
func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\xff\xf0", 
0).Err())
+               bitResult := rdb.BitPosSpan(ctx, "mykey", 1, 0, 15, "bit")
+               require.NoError(t, bitResult.Err())
+               require.EqualValues(t, 8, bitResult.Val())
+               byteResult := rdb.BitPosSpan(ctx, "mykey", 1, 0, 15, "byte")
+               require.NoError(t, byteResult.Err())
+               require.EqualValues(t, 8, byteResult.Val())
+               bitResult2 := rdb.BitPosSpan(ctx, "mykey", 1, 0, 7, "bit")
+               require.NoError(t, bitResult2.Err())
+               require.EqualValues(t, -1, bitResult2.Val())
+               byteResult2 := rdb.BitPosSpan(ctx, "mykey", 1, 0, 7, "byte")
+               require.NoError(t, byteResult2.Err())
+               require.EqualValues(t, 8, byteResult2.Val())
+       })
+
+       t.Run("BITPOS rejects invalid option", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, -1, "INVALID").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("BITPOS rejects extra arguments after BYTE/BIT", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, -1, "BIT", 
"extra").Err()
+               require.Error(t, err)
+       })
+
+       t.Run("BITPOS rejects BIT unit without end offset", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x80", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, "BIT").Err()
+               require.ErrorContains(t, err, "not started as an integer")
+       })
+
+       t.Run("BITPOS rejects BYTE unit without end offset", func(t *testing.T) 
{
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x80", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, "BYTE").Err()
+               require.ErrorContains(t, err, "not started as an integer")
+       })
+
+       t.Run("BITPOS rejects non-integer bit argument", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x80", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", "x").Err()
+               require.ErrorContains(t, err, "The bit argument must be 1 or 0")
+       })
+
+       t.Run("BITPOS rejects non-integer bit argument with BIT unit", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x80", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", "x", 0, 0, "BIT").Err()
+               require.ErrorContains(t, err, "The bit argument must be 1 or 0")
+       })
+
+       t.Run("BITPOS rejects bit argument of 2", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 2).Err()
+               require.ErrorContains(t, err, "The bit argument must be 1 or 0")
+       })
+
+       t.Run("BITPOS rejects bit argument of -1", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", -1).Err()
+               require.ErrorContains(t, err, "The bit argument must be 1 or 0")
+       })
+
+       t.Run("BITPOS rejects non-integer start offset", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, "abc").Err()
+               require.ErrorContains(t, err, "not started as an integer")
+       })
+
+       t.Run("BITPOS rejects non-integer end offset", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, "abc").Err()
+               require.ErrorContains(t, err, "not started as an integer")
+       })
+
+       t.Run("BITPOS bit=1 with nonexistent key returns -1", func(t 
*testing.T) {
+               require.NoError(t, rdb.Del(ctx, "nosuchkey").Err())
+               val, err := rdb.Do(ctx, "BITPOS", "nosuchkey", 1).Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS bit=0 with nonexistent key returns 0", func(t *testing.T) 
{
+               require.NoError(t, rdb.Del(ctx, "nosuchkey").Err())
+               val, err := rdb.Do(ctx, "BITPOS", "nosuchkey", 0).Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 0, val)
+       })
+
+       t.Run("BITPOS BYTE with negative start", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x00\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, -2, -1, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS BIT with negative start and end", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x00\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, -16, -9, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS returns -1 when start > end after normalization", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x00\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 2, 1, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS BIT returns -1 when start > end after normalization", 
func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x00\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 16, 8, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS BYTE bit=0 with all-ones string and explicit end returns 
-1", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\xff\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, 0, 2, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS BIT bit=0 with all-ones string and explicit end returns 
-1", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\xff\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, 0, 23, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS BYTE bit=0 without end extends past string (finds 
trailing zero)", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\xff\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0).Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 24, val)
+       })
+
+       t.Run("BITPOS BYTE with end beyond string length clamps correctly", 
func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\xff\x00", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, 100, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS BIT with end beyond string length clamps correctly", 
func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\xff\x00", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 0, 999, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS BYTE with only start argument", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\x00\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 2).Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 16, val)
+       })
+
+       t.Run("BITPOS BYTE with start past string returns -1 for bit=1", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff", 0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 5).Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
+       t.Run("BITPOS on wrong type returns WRONGTYPE error", func(t 
*testing.T) {
+               require.NoError(t, rdb.Del(ctx, "mylist").Err())
+               require.NoError(t, rdb.LPush(ctx, "mylist", "a").Err())
+               err := rdb.Do(ctx, "BITPOS", "mylist", 1).Err()
+               require.ErrorContains(t, err, "WRONGTYPE")
+       })
+
+       t.Run("BITPOS BYTE bit=0 finds first zero in middle byte", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x0f\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, 0, 2, 
"BYTE").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS BIT bit=0 finds first zero within bit range", func(t 
*testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\xff\x0f\xff", 
0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 0, 8, 15, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+       })
+
+       t.Run("BITPOS BIT single bit precision check", func(t *testing.T) {
+               require.NoError(t, rdb.Set(ctx, "mykey", "\x00\x80", 0).Err())
+               val, err := rdb.Do(ctx, "BITPOS", "mykey", 1, 8, 8, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, 8, val)
+
+               val, err = rdb.Do(ctx, "BITPOS", "mykey", 1, 9, 15, 
"BIT").Int64()
+               require.NoError(t, err)
+               require.EqualValues(t, -1, val)
+       })
+
        /* Test cases adapted from redis test cases : 
https://github.com/redis/redis/blob/unstable/tests/unit/bitops.tcl
         */
        t.Run("BITPOS bit=0 with empty key returns 0", func(t *testing.T) {

Reply via email to