Repository: cunla/fakeredis-py

Issue:
Support for ZRANGE command with BYLEX arg
It looks like there is support for ZRANGE https://redis.io/commands/zrange/, but not with the BYLEX=True arg. What I'm trying to do is use ZRANGE by lex where start & stop are strings. I'm getting this error:

```
  File "/Users/kchonka/.pyenv/versions/new-test-lti1p3/lib/python3.9/site-packages/fakeredis/_server.py", line 82, in read_response
    raise response
redis.exceptions.ResponseError: min or max is not a float
```

On version 2.7.1


Discussion:
github-actions[bot]: <!-- add-pr-comment:coverage -->

Coverage report:
```
Name                                              Stmts   Miss Branch BrPart  Cover
-----------------------------------------------------------------------------------
fakeredis/__init__.py                                 7      2      0      0    71%
fakeredis/_basefakesocket.py                        213      6     84      7    96%
fakeredis/_command_args_parsing.py                   54      0     32      0   100%
fakeredis/_commands.py                              317      9    120      6    96%
fakeredis/_fakesocket.py                             18      0      0      0   100%
fakeredis/_helpers.py                               167      4     61      3    97%
fakeredis/_msgs.py                                   63      0      0      0   100%
fakeredis/_server.py                                122      6     42      5    93%
fakeredis/_stream.py                                 54      1     20      2    96%
fakeredis/_zset.py                                   64      0     16      0   100%
fakeredis/aioredis.py                               155      8     43      7    92%
fakeredis/commands_mixins/__init__.py                 0      0      0      0   100%
fakeredis/commands_mixins/bitmap_mixin.py           116      0     58      0   100%
fakeredis/commands_mixins/connection_mixin.py        21      0      4      0   100%
fakeredis/commands_mixins/generic_mixin.py          212     13     90      8    92%
fakeredis/commands_mixins/hash_mixin.py              80      1     12      1    98%
fakeredis/commands_mixins/list_mixin.py             176      4     76      4    97%
fakeredis/commands_mixins/pubsub_mixin.py            81      0     36      6    95%
fakeredis/commands_mixins/scripting_mixin.py        147      3     70      4    97%
fakeredis/commands_mixins/server_mixin.py            47      0     10      0   100%
fakeredis/commands_mixins/set_mixin.py              138     12     58      6    88%
fakeredis/commands_mixins/sortedset_mixin.py        315      9    121      8    96%
fakeredis/commands_mixins/streams_mixin.py           45      3     16      3    90%
fakeredis/commands_mixins/string_mixin.py           217      5    102      6    97%
fakeredis/commands_mixins/transactions_mixin.py      71      2     20      1    97%
fakeredis/stack/__init__.py                           9      5      2      0    36%
fakeredis/stack/_json_mixin.py                      292      9    129      6    96%
-----------------------------------------------------------------------------------
TOTAL                                              3201    102   1222     83    96%
```

Patch:
diff --git a/fakeredis/_msgs.py b/fakeredis/_msgs.py
index 10f6243..ab08960 100644
--- a/fakeredis/_msgs.py
+++ b/fakeredis/_msgs.py
@@ -1,6 +1,8 @@
 INVALID_EXPIRE_MSG = "ERR invalid expire time in {}"
 WRONGTYPE_MSG = "WRONGTYPE Operation against a key holding the wrong kind of value"
 SYNTAX_ERROR_MSG = "ERR syntax error"
+SYNTAX_ERROR_LIMIT_ONLY_WITH_MSG = (
+    "ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX")
 INVALID_INT_MSG = "ERR value is not an integer or out of range"
 INVALID_FLOAT_MSG = "ERR value is not a valid float"
 INVALID_OFFSET_MSG = "ERR offset is out of range"
diff --git a/fakeredis/commands_mixins/sortedset_mixin.py b/fakeredis/commands_mixins/sortedset_mixin.py
index 7a9f0cd..9b18b2d 100644
--- a/fakeredis/commands_mixins/sortedset_mixin.py
+++ b/fakeredis/commands_mixins/sortedset_mixin.py
@@ -156,17 +156,15 @@ class SortedSetCommandsMixin:
     def zlexcount(self, key, _min, _max):
         return key.value.zlexcount(_min.value, _min.exclusive, _max.value, _max.exclusive)
 
-    def _zrange(self, key, start, stop, reverse, *args):
+    def _zrangebyscore(self, key, _min, _max, reverse, withscores, offset, count):
+        zset = key.value
+        items = list(zset.irange_score(_min.lower_bound, _max.upper_bound, reverse=reverse))
+        items = self._limit_items(items, offset, count)
+        items = self._apply_withscores(items, withscores)
+        return items
+
+    def _zrange(self, key, start, stop, reverse, withscores, byscore):
         zset = key.value
-        withscores = False
-        byscore = False
-        for arg in args:
-            if casematch(arg, b'withscores'):
-                withscores = True
-            elif casematch(arg, b'byscore'):
-                byscore = True
-            else:
-                raise SimpleError(msgs.SYNTAX_ERROR_MSG)
         if byscore:
             items = zset.irange_score(start.lower_bound, stop.upper_bound, reverse=reverse)
         else:
@@ -178,55 +176,74 @@ class SortedSetCommandsMixin:
         items = self._apply_withscores(items, withscores)
         return items
 
-    @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,))
-    def zrange(self, key, start, stop, *args):
-        return self._zrange(key, start, stop, False, *args)
-
-    @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,))
-    def zrevrange(self, key, start, stop, *args):
-        return self._zrange(key, start, stop, True, *args)
-
-    def _zrangebylex(self, key, _min, _max, reverse, *args):
-        if args:
-            if len(args) != 3 or not casematch(args[0], b'limit'):
-                raise SimpleError(msgs.SYNTAX_ERROR_MSG)
-            offset = Int.decode(args[1])
-            count = Int.decode(args[2])
-        else:
-            offset = 0
-            count = -1
+    def _zrangebylex(self, key, _min, _max, reverse, offset, count):
         zset = key.value
+        if reverse:
+            _min, _max = _max, _min
         items = zset.irange_lex(_min.value, _max.value,
                                 inclusive=(not _min.exclusive, not _max.exclusive),
                                 reverse=reverse)
         items = self._limit_items(items, offset, count)
         return items
 
+    def _zrange_args(self, key, start, stop, *args):
+        (bylex, byscore, rev, (offset, count), withscores), _ = extract_args(
+            args, ('bylex', 'byscore', 'rev', '++limit', 'withscores'))
+        if offset is not None and not bylex and not byscore:
+            raise SimpleError(msgs.SYNTAX_ERROR_LIMIT_ONLY_WITH_MSG)
+        if bylex and byscore:
+            raise SimpleError(msgs.SYNTAX_ERROR_MSG)
+
+        offset = offset or 0
+        count = -1 if count is None else count
+
+        if bylex:
+            res = self._zrangebylex(
+                key, StringTest.decode(start), StringTest.decode(stop), rev, offset, count)
+        elif byscore:
+            res = self._zrangebyscore(
+                key, ScoreTest.decode(start), ScoreTest.decode(stop), rev, withscores, offset, count)
+        else:
+            res = self._zrange(
+                key, ScoreTest.decode(start), ScoreTest.decode(stop), rev, withscores, byscore)
+        return res
+
+    @command((Key(ZSet), bytes, bytes), (bytes,))
+    def zrange(self, key, start, stop, *args):
+        return self._zrange_args(key, start, stop, *args)
+
+    @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,))
+    def zrevrange(self, key, start, stop, *args):
+        (withscores, byscore), _ = extract_args(args, ('withscores', 'byscore'))
+        return self._zrange(key, start, stop, True, withscores, byscore)
+
     @command((Key(ZSet), StringTest, StringTest), (bytes,))
     def zrangebylex(self, key, _min, _max, *args):
-        return self._zrangebylex(key, _min, _max, False, *args)
+        ((offset, count),), _ = extract_args(args, ('++limit',))
+        offset = offset or 0
+        count = -1 if count is None else count
+        return self._zrangebylex(key, _min, _max, False, offset, count)
 
     @command((Key(ZSet), StringTest, StringTest), (bytes,))
-    def zrevrangebylex(self, key, _max, _min, *args):
-        return self._zrangebylex(key, _min, _max, True, *args)
-
-    def _zrangebyscore(self, key, _min, _max, reverse, *args):
-        (withscores, (offset, count)), _ = extract_args(args, ('withscores', '++limit'))
+    def zrevrangebylex(self, key, _min, _max, *args):
+        ((offset, count),), _ = extract_args(args, ('++limit',))
         offset = offset or 0
         count = -1 if count is None else count
-        zset = key.value
-        items = list(zset.irange_score(_min.lower_bound, _max.upper_bound, reverse=reverse))
-        items = self._limit_items(items, offset, count)
-        items = self._apply_withscores(items, withscores)
-        return items
+        return self._zrangebylex(key, _min, _max, True, offset, count)
 
     @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,))
     def zrangebyscore(self, key, _min, _max, *args):
-        return self._zrangebyscore(key, _min, _max, False, *args)
+        (withscores, (offset, count)), _ = extract_args(args, ('withscores', '++limit'))
+        offset = offset or 0
+        count = -1 if count is None else count
+        return self._zrangebyscore(key, _min, _max, False, withscores, offset, count)
 
     @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,))
     def zrevrangebyscore(self, key, _max, _min, *args):
-        return self._zrangebyscore(key, _min, _max, True, *args)
+        (withscores, (offset, count)), _ = extract_args(args, ('withscores', '++limit'))
+        offset = offset or 0
+        count = -1 if count is None else count
+        return self._zrangebyscore(key, _min, _max, True, withscores, offset, count)
 
     @command((Key(ZSet), bytes))
     def zrank(self, key, member):
diff --git a/fakeredis/commands_mixins/string_mixin.py b/fakeredis/commands_mixins/string_mixin.py
index 33caeed..931f8e2 100644
--- a/fakeredis/commands_mixins/string_mixin.py
+++ b/fakeredis/commands_mixins/string_mixin.py
@@ -89,7 +89,7 @@ class StringCommandsMixin:
         delete_keys(key)
         return res
 
-    @command((Key(bytes), Int, Int))
+    @command(name=['GETRANGE', 'SUBSTR'], fixed=(Key(bytes), Int, Int))
     def getrange(self, key, start, end):
         value = key.get(b'')
         start, end = fix_range_string(start, end, len(value))
@@ -216,11 +216,6 @@ class StringCommandsMixin:
     def strlen(self, key):
         return len(key.get(b''))
 
-    # substr is a deprecated alias for getrange
-    @command((Key(bytes), Int, Int))
-    def substr(self, key, start, end):
-        return self.getrange(key, start, end)
-
     @command((Key(bytes),), (bytes,))
     def getex(self, key, *args):
         i, count_options, expire_time, diff = 0, 0, None, None


Test patch:
diff --git a/test/test_extract_args.py b/test/test_extract_args.py
index 4fab356..ae091d8 100644
--- a/test/test_extract_args.py
+++ b/test/test_extract_args.py
@@ -54,6 +54,13 @@ def test_extract_args__multiple_numbers():
     assert limit == [324, 123]
     assert not keepttl
 
+    (xx, nx, limit, keepttl), _ = extract_args(
+        (b'nx', b'xx',),
+        ('nx', 'xx', '++limit', 'keepttl'))
+    assert xx
+    assert nx
+    assert not keepttl
+    assert limit == [None, None]
 
 def test_extract_args__extract_non_numbers():
     args = (b'by', b'dd', b'nx', b'limit', b'324', b'123', b'xx',)
diff --git a/test/test_mixins/test_sortedset_commands.py b/test/test_mixins/test_sortedset_commands.py
index 419de95..11aa9d2 100644
--- a/test/test_mixins/test_sortedset_commands.py
+++ b/test/test_mixins/test_sortedset_commands.py
@@ -1,13 +1,15 @@
 from __future__ import annotations
 
+import math
 from collections import OrderedDict
+from typing import Tuple, List, Optional
 
-import math
 import pytest
 import redis
 import redis.client
 from packaging.version import Version
-from typing import Tuple, List, Optional
+
+from test import testtools
 
 REDIS_VERSION = Version(redis.__version__)
 
@@ -53,6 +55,62 @@ def test_zrange_same_score(r):
     assert r.zrange('foo', 2, 3) == [b'two_c', b'two_d']
 
 
+def test_zrange_with_bylex_and_byscore(r: redis.Redis):
+    r.zadd('foo', {'one_a': 0})
+    r.zadd('foo', {'two_a': 0})
+    r.zadd('foo', {'two_b': 0})
+    r.zadd('foo', {'three_a': 0})
+    with pytest.raises(redis.ResponseError):
+        testtools.raw_command(r, 'zrange', 'foo', '(t', '+', 'bylex', 'byscore')
+
+
+def test_zrange_with_rev_and_bylex(r: redis.Redis):
+    r.zadd('foo', {'one_a': 0})
+    r.zadd('foo', {'two_a': 0})
+    r.zadd('foo', {'two_b': 0})
+    r.zadd('foo', {'three_a': 0})
+    assert r.zrange('foo', b'+', b'(t', desc=True, bylex=True) == [b'two_b', b'two_a', b'three_a']
+    assert (
+            r.zrange('foo', b'[two_b', b'(t', desc=True, bylex=True)
+            == [b'two_b', b'two_a', b'three_a']
+    )
+    assert r.zrange('foo', b'(two_b', b'(t', desc=True, bylex=True) == [b'two_a', b'three_a']
+    assert (
+            r.zrange('foo', b'[two_b', b'[three_a', desc=True, bylex=True)
+            == [b'two_b', b'two_a', b'three_a']
+    )
+    assert r.zrange('foo', b'[two_b', b'(three_a', desc=True, bylex=True) == [b'two_b', b'two_a']
+    assert r.zrange('foo', b'(two_b', b'-', desc=True, bylex=True) == [b'two_a', b'three_a', b'one_a']
+    assert r.zrange('foo', b'(two_b', b'[two_b', bylex=True) == []
+    # reversed max + and min - boundaries
+    # these will be always empty, but allowed by redis
+    assert r.zrange('foo', b'-', b'+', desc=True, bylex=True) == []
+    assert r.zrange('foo', b'[three_a', b'+', desc=True, bylex=True) == []
+    assert r.zrange('foo', b'-', b'[o', desc=True, bylex=True) == []
+
+
+def test_zrange_with_bylex(r):
+    r.zadd('foo', {'one_a': 0})
+    r.zadd('foo', {'two_a': 0})
+    r.zadd('foo', {'two_b': 0})
+    r.zadd('foo', {'three_a': 0})
+    assert r.zrange('foo', b'(t', b'+', bylex=True) == [b'three_a', b'two_a', b'two_b']
+    assert r.zrange('foo', b'(t', b'[two_b', bylex=True) == [b'three_a', b'two_a', b'two_b']
+    assert r.zrange('foo', b'(t', b'(two_b', bylex=True) == [b'three_a', b'two_a']
+    assert (
+            r.zrange('foo', b'[three_a', b'[two_b', bylex=True)
+            == [b'three_a', b'two_a', b'two_b']
+    )
+    assert r.zrange('foo', b'(three_a', b'[two_b', bylex=True) == [b'two_a', b'two_b']
+    assert r.zrange('foo', b'-', b'(two_b', bylex=True) == [b'one_a', b'three_a', b'two_a']
+    assert r.zrange('foo', b'[two_b', b'(two_b', bylex=True) == []
+    # reversed max + and min - boundaries
+    # these will be always empty, but allowed by redis
+    assert r.zrange('foo', b'+', b'-', bylex=True) == []
+    assert r.zrange('foo', b'+', b'[three_a', bylex=True) == []
+    assert r.zrange('foo', b'[o', b'-', bylex=True) == []
+
+
 def test_zrange_with_byscore(r):
     r.zadd('foo', {'zero': 0})
     r.zadd('foo', {'two': 2})