From db46a632ca5bd0e8829252da7f85696099ccd62b Mon Sep 17 00:00:00 2001 From: Yi-Pin Chen Date: Fri, 21 Jun 2024 16:15:47 -0700 Subject: [PATCH 1/3] Python: Added COPY command (#383) Python: Added COPY command --- .../glide/async_commands/cluster_commands.py | 40 +++++++ .../async_commands/standalone_commands.py | 43 ++++++++ .../glide/async_commands/transaction.py | 62 +++++++++++ python/python/tests/test_async_client.py | 103 +++++++++++++++++- python/python/tests/test_transaction.py | 24 ++++ 5 files changed, 271 insertions(+), 1 deletion(-) diff --git a/python/python/glide/async_commands/cluster_commands.py b/python/python/glide/async_commands/cluster_commands.py index 50fd8390f5..65957bc5f7 100644 --- a/python/python/glide/async_commands/cluster_commands.py +++ b/python/python/glide/async_commands/cluster_commands.py @@ -514,3 +514,43 @@ async def flushall( TClusterResponse[TOK], await self._execute_command(RequestType.FlushAll, args, route), ) + + async def copy( + self, + source: str, + destination: str, + replace: Optional[bool] = None, + ) -> bool: + """ + Copies the value stored at the `source` to the `destination` key. When `replace` is True, + removes the `destination` key first if it already exists, otherwise performs no action. + + See https://valkey.io/commands/copy for more details. + + Note: + Both `source` and `destination` must map to the same hash slot. + + Args: + source (str): The key to the source value. + destination (str): The key where the value should be copied to. + replace (Optional[bool]): If the destination key should be removed before copying the value to it. + + Returns: + bool: True if the source was copied. Otherwise, returns False. + + Examples: + >>> await client.set("source", "sheep") + >>> await client.copy("source", "destination") + True # Source was copied + >>> await client.get("destination") + "sheep" + + Since: Redis version 6.2.0. + """ + args = [source, destination] + if replace is True: + args.append("REPLACE") + return cast( + bool, + await self._execute_command(RequestType.Copy, args), + ) diff --git a/python/python/glide/async_commands/standalone_commands.py b/python/python/glide/async_commands/standalone_commands.py index add1cdde1d..32630d5060 100644 --- a/python/python/glide/async_commands/standalone_commands.py +++ b/python/python/glide/async_commands/standalone_commands.py @@ -459,3 +459,46 @@ async def flushall(self, flush_mode: Optional[FlushMode] = None) -> TOK: TOK, await self._execute_command(RequestType.FlushAll, args), ) + + async def copy( + self, + source: str, + destination: str, + destinationDB: Optional[int] = None, + replace: Optional[bool] = None, + ) -> bool: + """ + Copies the value stored at the `source` to the `destination` key. If `destinationDB` + is specified, the value will be copied to the database specified by `destinationDB`, + otherwise the current database will be used. When `replace` is True, removes the + `destination` key first if it already exists, otherwise performs no action. + + See https://valkey.io/commands/copy for more details. + + Args: + source (str): The key to the source value. + destination (str): The key where the value should be copied to. + destinationDB (Optional[int]): The alternative logical database index for the destination key. + replace (Optional[bool]): If the destination key should be removed before copying the value to it. + + Returns: + bool: True if the source was copied. Otherwise, return False. + + Examples: + >>> await client.set("source", "sheep") + >>> await client.copy("source", "destination", 1, False) + True # Source was copied + >>> await client.get("destination") + "sheep" + + Since: Redis version 6.2.0. + """ + args = [source, destination] + if destinationDB is not None: + args.extend(["DB", str(destinationDB)]) + if replace is True: + args.append("REPLACE") + return cast( + bool, + await self._execute_command(RequestType.Copy, args), + ) diff --git a/python/python/glide/async_commands/transaction.py b/python/python/glide/async_commands/transaction.py index 5c77b3f352..ff7b3646fe 100644 --- a/python/python/glide/async_commands/transaction.py +++ b/python/python/glide/async_commands/transaction.py @@ -3451,6 +3451,40 @@ def sort_store( ) return self.append_command(RequestType.Sort, args) + def copy( + self: TTransaction, + source: str, + destination: str, + destinationDB: Optional[int] = None, + replace: Optional[bool] = None, + ) -> TTransaction: + """ + Copies the value stored at the `source` to the `destination` key. If `destinationDB` + is specified, the value will be copied to the database specified by `destinationDB`, + otherwise the current database will be used. When `replace` is True, removes the + `destination` key first if it already exists, otherwise performs no action. + + See https://valkey.io/commands/copy for more details. + + Args: + source (str): The key to the source value. + destination (str): The key where the value should be copied to. + destinationDB (Optional[int]): The alternative logical database index for the destination key. + replace (Optional[bool]): If the destination key should be removed before copying the value to it. + + Command response: + bool: True if the source was copied. Otherwise, return False. + + Since: Redis version 6.2.0. + """ + args = [source, destination] + if destinationDB is not None: + args.extend(["DB", str(destinationDB)]) + if replace is not None: + args.append("REPLACE") + + return self.append_command(RequestType.Copy, args) + class ClusterTransaction(BaseTransaction): """ @@ -3518,4 +3552,32 @@ def sort_store( args = _build_sort_args(key, None, limit, None, order, alpha, store=destination) return self.append_command(RequestType.Sort, args) + def copy( + self: TTransaction, + source: str, + destination: str, + replace: Optional[bool] = None, + ) -> TTransaction: + """ + Copies the value stored at the `source` to the `destination` key. When `replace` is True, + removes the `destination` key first if it already exists, otherwise performs no action. + + See https://valkey.io/commands/copy for more details. + + Args: + source (str): The key to the source value. + destination (str): The key where the value should be copied to. + replace (Optional[bool]): If the destination key should be removed before copying the value to it. + + Command response: + bool: True if the source was copied. Otherwise, return False. + + Since: Redis version 6.2.0. + """ + args = [source, destination] + if replace is not None: + args.append("REPLACE") + + return self.append_command(RequestType.Copy, args) + # TODO: add all CLUSTER commands diff --git a/python/python/tests/test_async_client.py b/python/python/tests/test_async_client.py index 13ec84a126..4a175ccc01 100644 --- a/python/python/tests/test_async_client.py +++ b/python/python/tests/test_async_client.py @@ -5234,6 +5234,106 @@ async def test_flushall(self, redis_client: TRedisClient): assert await redis_client.flushall(FlushMode.SYNC, AllPrimaries()) is OK assert await redis_client.dbsize() == 0 + @pytest.mark.parametrize("cluster_mode", [True, False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_copy_no_database(self, redis_client: TRedisClient): + min_version = "6.2.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + + source = f"{{testKey}}:1-{get_random_string(10)}" + destination = f"{{testKey}}:2-{get_random_string(10)}" + value1 = get_random_string(5) + value2 = get_random_string(5) + + # neither key exists + assert await redis_client.copy(source, destination, replace=False) is False + assert await redis_client.copy(source, destination) is False + + # source exists, destination does not + await redis_client.set(source, value1) + assert await redis_client.copy(source, destination, replace=False) is True + assert await redis_client.get(destination) == value1 + + # new value for source key + await redis_client.set(source, value2) + + # both exists, no REPLACE + assert await redis_client.copy(source, destination) is False + assert await redis_client.copy(source, destination, replace=False) is False + assert await redis_client.get(destination) == value1 + + # both exists, with REPLACE + assert await redis_client.copy(source, destination, replace=True) is True + assert await redis_client.get(destination) == value2 + + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_copy_database(self, redis_client: RedisClient): + min_version = "6.2.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + + source = get_random_string(10) + destination = get_random_string(10) + value1 = get_random_string(5) + value2 = get_random_string(5) + index1 = 1 + index2 = 2 + + try: + assert await redis_client.select(0) == OK + + # neither key exists + assert ( + await redis_client.copy(source, destination, index1, replace=False) + is False + ) + + # source exists, destination does not + await redis_client.set(source, value1) + assert ( + await redis_client.copy(source, destination, index1, replace=False) + is True + ) + assert await redis_client.select(1) == OK + assert await redis_client.get(destination) == value1 + + # new value for source key + assert await redis_client.select(0) == OK + await redis_client.set(source, value2) + + # no REPLACE, copying to existing key on DB 0 & 1, non-existing key on DB 2 + assert ( + await redis_client.copy(source, destination, index1, replace=False) + is False + ) + assert ( + await redis_client.copy(source, destination, index2, replace=False) + is True + ) + + # new value only gets copied to DB 2 + assert await redis_client.select(1) == OK + assert await redis_client.get(destination) == value1 + assert await redis_client.select(2) == OK + assert await redis_client.get(destination) == value2 + + # both exists, with REPLACE, when value isn't the same, source always get copied to destination + assert await redis_client.select(0) == OK + assert ( + await redis_client.copy(source, destination, index1, replace=True) + is True + ) + assert await redis_client.select(1) == OK + assert await redis_client.get(destination) == value2 + + # invalid DB index + with pytest.raises(RequestError): + await redis_client.copy(source, destination, -1, replace=True) + finally: + assert await redis_client.select(0) == OK + class TestMultiKeyCommandCrossSlot: @pytest.mark.parametrize("cluster_mode", [True]) @@ -5284,7 +5384,8 @@ async def test_multi_key_command_returns_cross_slot_error( "zxy", GeospatialData(15, 37), GeoSearchByBox(400, 400, GeoUnit.KILOMETERS), - ) + ), + redis_client.copy("abc", "zxy", replace=True), ] ) diff --git a/python/python/tests/test_transaction.py b/python/python/tests/test_transaction.py index 9f0a0f7291..c494383cb0 100644 --- a/python/python/tests/test_transaction.py +++ b/python/python/tests/test_transaction.py @@ -98,6 +98,10 @@ async def transaction_test( transaction.pexpiretime(key) args.append(-1) + if not await check_if_server_version_lt(redis_client, "6.2.0"): + transaction.copy(key, key2, replace=True) + args.append(1) + transaction.rename(key, key2) args.append(OK) @@ -658,6 +662,26 @@ def test_transaction_clear(self): transaction.clear() assert len(transaction.commands) == 0 + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_standalone_copy_transaction(self, redis_client: RedisClient): + min_version = "6.2.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + + keyslot = get_random_string(3) + key = "{{{}}}:{}".format(keyslot, get_random_string(3)) # to get the same slot + key1 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # to get the same slot + value = get_random_string(5) + transaction = Transaction() + transaction.select(1) + transaction.set(key, value) + transaction.copy(key, key1, 1, replace=True) + transaction.get(key1) + result = await redis_client.exec(transaction) + assert result is not None + assert result[3] == value + @pytest.mark.parametrize("cluster_mode", [True, False]) @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) async def test_transaction_chaining_calls(self, redis_client: TRedisClient): From 226d164134a8153e224eda61c9cc8743e85c3fa5 Mon Sep 17 00:00:00 2001 From: Yi-Pin Chen Date: Fri, 21 Jun 2024 16:21:14 -0700 Subject: [PATCH 2/3] Updated CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1291dd1069..7d0655c4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ * Python: Added TOUCH command ([#1582](https://github.com/aws/glide-for-redis/pull/1582)) * Python: Added BITOP command ([#1596](https://github.com/aws/glide-for-redis/pull/1596)) * Python: Added BITPOS command ([#1604](https://github.com/aws/glide-for-redis/pull/1604)) +* Python: Added COPY command ([#1626](https://github.com/aws/glide-for-redis/pull/1626)) ### Breaking Changes * Node: Update XREAD to return a Map of Map ([#1494](https://github.com/aws/glide-for-redis/pull/1494)) From 6c9f9202e8ff253dab94b2500d1bcb5e07ded1a8 Mon Sep 17 00:00:00 2001 From: Yi-Pin Chen Date: Fri, 21 Jun 2024 16:53:12 -0700 Subject: [PATCH 3/3] Addressed review comments --- .../async_commands/standalone_commands.py | 1 + python/python/tests/test_async_client.py | 20 ++++++++++--------- python/python/tests/test_transaction.py | 3 ++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/python/python/glide/async_commands/standalone_commands.py b/python/python/glide/async_commands/standalone_commands.py index 32630d5060..dbab238b60 100644 --- a/python/python/glide/async_commands/standalone_commands.py +++ b/python/python/glide/async_commands/standalone_commands.py @@ -488,6 +488,7 @@ async def copy( >>> await client.set("source", "sheep") >>> await client.copy("source", "destination", 1, False) True # Source was copied + >>> await client.select(1) >>> await client.get("destination") "sheep" diff --git a/python/python/tests/test_async_client.py b/python/python/tests/test_async_client.py index 2d6faa4a60..cdaa33f8b7 100644 --- a/python/python/tests/test_async_client.py +++ b/python/python/tests/test_async_client.py @@ -5599,7 +5599,6 @@ async def test_flushall(self, redis_client: TRedisClient): @pytest.mark.parametrize("cluster_mode", [True, False]) @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) - async def test_getex(self, redis_client: TRedisClient): min_version = "6.2.0" if await check_if_server_version_lt(redis_client, min_version): @@ -5631,8 +5630,10 @@ async def test_getex(self, redis_client: TRedisClient): await redis_client.getex(key1, ExpiryGetEx(ExpiryTypeGetEx.PERSIST, None)) == value ) - assert await redis_client.ttl(key1) == -1 + assert await redis_client.ttl(key1) == -1 + @pytest.mark.parametrize("cluster_mode", [True, False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) async def test_copy_no_database(self, redis_client: TRedisClient): min_version = "6.2.0" if await check_if_server_version_lt(redis_client, min_version): @@ -5675,11 +5676,12 @@ async def test_copy_database(self, redis_client: RedisClient): destination = get_random_string(10) value1 = get_random_string(5) value2 = get_random_string(5) + index0 = 0 index1 = 1 index2 = 2 try: - assert await redis_client.select(0) == OK + assert await redis_client.select(index0) == OK # neither key exists assert ( @@ -5693,11 +5695,11 @@ async def test_copy_database(self, redis_client: RedisClient): await redis_client.copy(source, destination, index1, replace=False) is True ) - assert await redis_client.select(1) == OK + assert await redis_client.select(index1) == OK assert await redis_client.get(destination) == value1 # new value for source key - assert await redis_client.select(0) == OK + assert await redis_client.select(index0) == OK await redis_client.set(source, value2) # no REPLACE, copying to existing key on DB 0 & 1, non-existing key on DB 2 @@ -5711,18 +5713,18 @@ async def test_copy_database(self, redis_client: RedisClient): ) # new value only gets copied to DB 2 - assert await redis_client.select(1) == OK + assert await redis_client.select(index1) == OK assert await redis_client.get(destination) == value1 - assert await redis_client.select(2) == OK + assert await redis_client.select(index2) == OK assert await redis_client.get(destination) == value2 # both exists, with REPLACE, when value isn't the same, source always get copied to destination - assert await redis_client.select(0) == OK + assert await redis_client.select(index0) == OK assert ( await redis_client.copy(source, destination, index1, replace=True) is True ) - assert await redis_client.select(1) == OK + assert await redis_client.select(index1) == OK assert await redis_client.get(destination) == value2 # invalid DB index diff --git a/python/python/tests/test_transaction.py b/python/python/tests/test_transaction.py index 4477e176d4..e4b86b1e83 100644 --- a/python/python/tests/test_transaction.py +++ b/python/python/tests/test_transaction.py @@ -112,7 +112,7 @@ async def transaction_test( if not await check_if_server_version_lt(redis_client, "6.2.0"): transaction.copy(key, key2, replace=True) - args.append(1) + args.append(True) transaction.rename(key, key2) args.append(OK) @@ -719,6 +719,7 @@ async def test_standalone_copy_transaction(self, redis_client: RedisClient): transaction.get(key1) result = await redis_client.exec(transaction) assert result is not None + assert result[2] == True assert result[3] == value @pytest.mark.parametrize("cluster_mode", [True, False])