Repository: redis/redis-py

Issue:
Add support for BF.CARD

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) Report
Base: **92.23**% // Head: **78.83**% // Decreases project coverage by **`-13.40%`** :warning:
> Coverage data is based on head [(`a942905`)](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) compared to base [(`f46d7f3`)](https://codecov.io/gh/redis/redis-py/commit/f46d7f3ef7ce64f90acd4a3dcc14375ab9ae9c9d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).
> Patch coverage: 93.33% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #2545       +/-   ##
===========================================
- Coverage   92.23%   78.83%   -13.41%     
===========================================
  Files         115      115               
  Lines       29554    29569       +15     
===========================================
- Hits        27260    23311     -3949     
- Misses       2294     6258     +3964     
```


| [Impacted Files](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) | Coverage Δ | |
|---|---|---|
| [redis/commands/bf/\_\_init\_\_.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvY29tbWFuZHMvYmYvX19pbml0X18ucHk=) | `96.73% <ø> (ø)` | |
| [tests/test\_bloom.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-dGVzdHMvdGVzdF9ibG9vbS5weQ==) | `99.36% <91.66%> (-0.31%)` | :arrow_down: |
| [redis/commands/bf/commands.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvY29tbWFuZHMvYmYvY29tbWFuZHMucHk=) | `98.50% <100.00%> (+0.02%)` | :arrow_up: |
| [tests/test\_asyncio/test\_cluster.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-dGVzdHMvdGVzdF9hc3luY2lvL3Rlc3RfY2x1c3Rlci5weQ==) | `18.51% <0.00%> (-78.97%)` | :arrow_down: |
| [tests/test\_cluster.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-dGVzdHMvdGVzdF9jbHVzdGVyLnB5) | `18.13% <0.00%> (-78.89%)` | :arrow_down: |
| [redis/asyncio/cluster.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvYXN5bmNpby9jbHVzdGVyLnB5) | `16.96% <0.00%> (-74.72%)` | :arrow_down: |
| [redis/cluster.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvY2x1c3Rlci5weQ==) | `18.81% <0.00%> (-71.54%)` | :arrow_down: |
| [redis/asyncio/parser.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvYXN5bmNpby9wYXJzZXIucHk=) | `17.30% <0.00%> (-63.47%)` | :arrow_down: |
| [redis/commands/cluster.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvY29tbWFuZHMvY2x1c3Rlci5weQ==) | `36.95% <0.00%> (-57.07%)` | :arrow_down: |
| [redis/crc.py](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis#diff-cmVkaXMvY3JjLnB5) | `45.45% <0.00%> (-54.55%)` | :arrow_down: |
| ... and [14 more](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/redis/redis-py/pull/2545?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).


Patch:
diff --git a/CHANGES b/CHANGES
index fca8d31..02daf5e 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,4 @@
+    * Fix string cleanse in Redis Graph
     * Make PythonParser resumable in case of error (#2510)
     * Add `timeout=None` in `SentinelConnectionManager.read_response`
     * Documentation fix: password protected socket connection (#2374)
diff --git a/redis/cluster.py b/redis/cluster.py
index f115007..5f730a8 100644
--- a/redis/cluster.py
+++ b/redis/cluster.py
@@ -121,6 +121,7 @@ REDIS_ALLOWED_KEYS = (
     "charset",
     "connection_class",
     "connection_pool",
+    "connection_pool_class",
     "client_name",
     "credential_provider",
     "db",
@@ -1267,6 +1268,7 @@ class NodesManager:
         require_full_coverage=False,
         lock=None,
         dynamic_startup_nodes=True,
+        connection_pool_class=ConnectionPool,
         **kwargs,
     ):
         self.nodes_cache = {}
@@ -1277,6 +1279,7 @@ class NodesManager:
         self.from_url = from_url
         self._require_full_coverage = require_full_coverage
         self._dynamic_startup_nodes = dynamic_startup_nodes
+        self.connection_pool_class = connection_pool_class
         self._moved_exception = None
         self.connection_kwargs = kwargs
         self.read_load_balancer = LoadBalancer()
@@ -1420,7 +1423,7 @@ class NodesManager:
             # Create a redis node with a costumed connection pool
             kwargs.update({"host": host})
             kwargs.update({"port": port})
-            r = Redis(connection_pool=ConnectionPool(**kwargs))
+            r = Redis(connection_pool=self.connection_pool_class(**kwargs))
         else:
             r = Redis(host=host, port=port, **kwargs)
         return r
@@ -1437,6 +1440,8 @@ class NodesManager:
             if target_node is None or target_node.redis_connection is None:
                 # create new cluster node for this cluster
                 target_node = ClusterNode(host, port, role)
+            if target_node.server_type != role:
+                target_node.server_type = role
 
         return target_node
 
diff --git a/redis/commands/bf/__init__.py b/redis/commands/bf/__init__.py
index a448def..4da060e 100644
--- a/redis/commands/bf/__init__.py
+++ b/redis/commands/bf/__init__.py
@@ -198,6 +198,7 @@ class BFBloom(BFCommands, AbstractBloom):
             # BF_MEXISTS: spaceHolder,
             # BF_SCANDUMP: spaceHolder,
             # BF_LOADCHUNK: spaceHolder,
+            # BF_CARD: spaceHolder,
             BF_INFO: BFInfo,
         }
 
diff --git a/redis/commands/bf/commands.py b/redis/commands/bf/commands.py
index f6bf281..c45523c 100644
--- a/redis/commands/bf/commands.py
+++ b/redis/commands/bf/commands.py
@@ -11,6 +11,7 @@ BF_MEXISTS = "BF.MEXISTS"
 BF_SCANDUMP = "BF.SCANDUMP"
 BF_LOADCHUNK = "BF.LOADCHUNK"
 BF_INFO = "BF.INFO"
+BF_CARD = "BF.CARD"
 
 CF_RESERVE = "CF.RESERVE"
 CF_ADD = "CF.ADD"
@@ -165,6 +166,14 @@ class BFCommands:
         """  # noqa
         return self.execute_command(BF_INFO, key)
 
+    def card(self, key):
+        """
+        Returns the cardinality of a Bloom filter - number of items that were added to a Bloom filter and detected as unique
+        (items that caused at least one bit to be set in at least one sub-filter).
+        For more information see `BF.CARD <https://redis.io/commands/bf.card>`_.
+        """  # noqa
+        return self.execute_command(BF_CARD, key)
+
 
 class CFCommands:
     """Cuckoo Filter commands."""
diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py
index 6989ab5..b65cd1a 100644
--- a/redis/commands/helpers.py
+++ b/redis/commands/helpers.py
@@ -115,6 +115,7 @@ def quote_string(v):
     if len(v) == 0:
         return '""'
 
+    v = v.replace("\\", "\\\\")
     v = v.replace('"', '\\"')
 
     return f'"{v}"'


Test patch:
diff --git a/tests/test_asyncio/test_bloom.py b/tests/test_asyncio/test_bloom.py
index cac989f..9f4a805 100644
--- a/tests/test_asyncio/test_bloom.py
+++ b/tests/test_asyncio/test_bloom.py
@@ -149,6 +149,21 @@ async def test_bf_info(modclient: redis.Redis):
         assert True
 
 
+@pytest.mark.redismod
+async def test_bf_card(modclient: redis.Redis):
+    # return 0 if the key does not exist
+    assert await modclient.bf().card("not_exist") == 0
+
+    # Store a filter
+    assert await modclient.bf().add("bf1", "item_foo") == 1
+    assert await modclient.bf().card("bf1") == 1
+
+    # Error when key is of a type other than Bloom filter.
+    with pytest.raises(redis.ResponseError):
+        await modclient.set("setKey", "value")
+        await modclient.bf().card("setKey")
+
+
 # region Test Cuckoo Filter
 @pytest.mark.redismod
 async def test_cf_add_and_insert(modclient: redis.Redis):
diff --git a/tests/test_bloom.py b/tests/test_bloom.py
index 13921bf..30d3219 100644
--- a/tests/test_bloom.py
+++ b/tests/test_bloom.py
@@ -165,6 +165,21 @@ def test_bf_info(client):
         assert True
 
 
+@pytest.mark.redismod
+def test_bf_card(client):
+    # return 0 if the key does not exist
+    assert client.bf().card("not_exist") == 0
+
+    # Store a filter
+    assert client.bf().add("bf1", "item_foo") == 1
+    assert client.bf().card("bf1") == 1
+
+    # Error when key is of a type other than Bloom filter.
+    with pytest.raises(redis.ResponseError):
+        client.set("setKey", "value")
+        client.bf().card("setKey")
+
+
 # region Test Cuckoo Filter
 @pytest.mark.redismod
 def test_cf_add_and_insert(client):
diff --git a/tests/test_cluster.py b/tests/test_cluster.py
index 27cfee1..da6a8e4 100644
--- a/tests/test_cluster.py
+++ b/tests/test_cluster.py
@@ -18,7 +18,7 @@ from redis.cluster import (
     get_node_name,
 )
 from redis.commands import CommandsParser
-from redis.connection import Connection
+from redis.connection import BlockingConnectionPool, Connection, ConnectionPool
 from redis.crc import key_slot
 from redis.exceptions import (
     AskError,
@@ -2262,6 +2262,57 @@ class TestNodesManager:
 
         assert len(n_manager.nodes_cache) == 6
 
+    def test_init_promote_server_type_for_node_in_cache(self):
+        """
+        When replica is promoted to master, nodes_cache must change the server type
+        accordingly
+        """
+        cluster_slots_before_promotion = [
+            [0, 16383, ["127.0.0.1", 7000], ["127.0.0.1", 7003]]
+        ]
+        cluster_slots_after_promotion = [
+            [0, 16383, ["127.0.0.1", 7003], ["127.0.0.1", 7004]]
+        ]
+
+        cluster_slots_results = [
+            cluster_slots_before_promotion,
+            cluster_slots_after_promotion,
+        ]
+
+        with patch.object(Redis, "execute_command") as execute_command_mock:
+
+            def execute_command(*_args, **_kwargs):
+                if _args[0] == "CLUSTER SLOTS":
+                    mock_cluster_slots = cluster_slots_results.pop(0)
+                    return mock_cluster_slots
+                elif _args[0] == "COMMAND":
+                    return {"get": [], "set": []}
+                elif _args[0] == "INFO":
+                    return {"cluster_enabled": True}
+                elif len(_args) > 1 and _args[1] == "cluster-require-full-coverage":
+                    return {"cluster-require-full-coverage": False}
+                else:
+                    return execute_command_mock(*_args, **_kwargs)
+
+            execute_command_mock.side_effect = execute_command
+
+            nm = NodesManager(
+                startup_nodes=[ClusterNode(host=default_host, port=default_port)],
+                from_url=False,
+                require_full_coverage=False,
+                dynamic_startup_nodes=True,
+            )
+
+            assert nm.default_node.host == "127.0.0.1"
+            assert nm.default_node.port == 7000
+            assert nm.default_node.server_type == PRIMARY
+
+            nm.initialize()
+
+            assert nm.default_node.host == "127.0.0.1"
+            assert nm.default_node.port == 7003
+            assert nm.default_node.server_type == PRIMARY
+
     def test_init_slots_cache_cluster_mode_disabled(self):
         """
         Test that creating a RedisCluster failes if one of the startup nodes
@@ -2445,6 +2496,21 @@ class TestNodesManager:
         else:
             assert startup_nodes == ["my@DNS.com:7000"]
 
+    @pytest.mark.parametrize(
+        "connection_pool_class", [ConnectionPool, BlockingConnectionPool]
+    )
+    def test_connection_pool_class(self, connection_pool_class):
+        rc = get_mocked_redis_client(
+            url="redis://my@DNS.com:7000",
+            cluster_slots=default_cluster_slots,
+            connection_pool_class=connection_pool_class,
+        )
+
+        for node in rc.nodes_manager.nodes_cache.values():
+            assert isinstance(
+                node.redis_connection.connection_pool, connection_pool_class
+            )
+
 
 @pytest.mark.onlycluster
 class TestClusterPubSubObject:
diff --git a/tests/test_graph.py b/tests/test_graph.py
index d71df48..4721b2f 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -124,7 +124,7 @@ def test_path(client):
 
 @pytest.mark.redismod
 def test_param(client):
-    params = [1, 2.3, "str", True, False, None, [0, 1, 2]]
+    params = [1, 2.3, "str", True, False, None, [0, 1, 2], r"\" RETURN 1337 //"]
     query = "RETURN $param"
     for param in params:
         result = client.graph().query(query, {"param": param})
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 3595829..57a94d2 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -80,3 +80,9 @@ def test_quote_string():
     assert quote_string("hello world!") == '"hello world!"'
     assert quote_string("") == '""'
     assert quote_string("hello world!") == '"hello world!"'
+    assert quote_string("abc") == '"abc"'
+    assert quote_string("") == '""'
+    assert quote_string('"') == r'"\""'
+    assert quote_string(r"foo \ bar") == r'"foo \\ bar"'
+    assert quote_string(r"foo \" bar") == r'"foo \\\" bar"'
+    assert quote_string('a"a') == r'"a\"a"'