diff --git a/tests/commands/hash/test_hexpire.py b/tests/commands/hash/test_hexpire.py new file mode 100644 index 0000000..2908f47 --- /dev/null +++ b/tests/commands/hash/test_hexpire.py @@ -0,0 +1,133 @@ +import pytest +import time +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_hash(redis: Redis): + hash_name = "myhash" + redis.delete(hash_name) + + +def test_hexpire_expires_hash_key(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 1) == [1] + + time.sleep(2) + assert redis.hget(hash_name, field) is None + + +def test_hexpire_nx_sets_expiry_if_no_expiry(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 1, nx=True) == [1] + + time.sleep(2) + assert redis.hget(hash_name, field) is None + + +def test_hexpire_nx_does_not_set_expiry_if_already_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1000) + assert redis.hexpire(hash_name, field, 1, nx=True) == [0] + + +def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1) + assert redis.hexpire(hash_name, [field], 5, xx=True) == [1] + + time.sleep(6) + assert redis.hget(hash_name, field) is None + + +def test_hexpire_xx_does_not_set_expiry_if_not_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 5, xx=True) == [0] + + +def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1) + assert redis.hexpire(hash_name, field, 5, gt=True) == [1] + + time.sleep(6) + assert redis.hget(hash_name, field) is None + + +def test_hexpire_gt_does_not_set_if_new_not_greater(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 10) + assert redis.hexpire(hash_name, [field], 5, gt=True) == [0] + + +def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, [field], 5) + assert redis.hexpire(hash_name, field, 3, lt=True) == [1] + + time.sleep(4) + assert redis.hget(hash_name, field) is None + + +def test_hexpire_lt_does_not_set_if_new_not_less(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 10) + assert redis.hexpire(hash_name, [field], 20, lt=True) == [0] + + +def test_hexpire_returns_minus2_if_field_does_not_exist(redis: Redis): + hash_name = "myhash" + field = "field1" + field2 = "field2" + redis.hset(hash_name, field, "10") + assert redis.hexpire(hash_name, field2, 1) == [-2] + + +def test_hexpire_returns_minus2_if_hash_does_not_exist(redis: Redis): + assert redis.hexpire("nonexistent_hash", "field1", 1) == [-2] + + +def test_hexpire_returns_2_when_called_with_zero_seconds(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 0) == [2] + assert redis.hget(hash_name, field) is None diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 5c72434..7946221 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -364,6 +364,69 @@ def expire( return self.execute(command) + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets a timeout on a hash field in seconds. + After the timeout has expired, the hash field will automatically be deleted. + + :param key: The key of the hash. + :param field: The field within the hash to set the expiry for. + :param seconds: The timeout in seconds as an int or a datetime.timedelta object. + :param nx: Set expiry only when the field has no expiry. + :param xx: Set expiry only when the field has an existing expiry. + :param gt: Set expiry only when the new expiry is greater than the current one. + :param lt: Set expiry only when the new expiry is less than the current one. + + Example: + ```python + # With seconds + redis.hset("myhash", "field1", "value1") + redis.hexpire("myhash", "field1", 5) + + assert redis.hget("myhash", "field1") == "value1" + + time.sleep(5) + + assert redis.hget("myhash", "field1") is None + + # With a timedelta + redis.hset("myhash", "field1", "value1") + redis.hexpire("myhash", "field1", datetime.timedelta(seconds=5)) + ``` + + See https://redis.io/commands/hexpire for more details on expiration behavior. + """ + + if isinstance(seconds, datetime.timedelta): + seconds = int(seconds.total_seconds()) + + command: List = ["HEXPIRE", key, seconds] + + if nx: + command.append("NX") + if xx: + command.append("XX") + if gt: + command.append("GT") + if lt: + command.append("LT") + + if isinstance(fields, str): + fields = [fields] + + command.extend(["FIELDS", len(fields), *fields]) + + return self.execute(command) + def expireat( self, key: str, diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index 46a4649..73ab40d 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -192,6 +192,16 @@ class Commands: ) -> int: ... def hdel(self, key: str, *fields: str) -> int: ... def hexists(self, key: str, field: str) -> bool: ... + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> List[int]: ... def hget(self, key: str, field: str) -> Optional[str]: ... def hgetall(self, key: str) -> Dict[str, str]: ... def hincrby(self, key: str, field: str, increment: int) -> int: ... @@ -691,6 +701,16 @@ class AsyncCommands: ) -> int: ... async def hdel(self, key: str, *fields: str) -> int: ... async def hexists(self, key: str, field: str) -> bool: ... + async def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> int: ... async def hget(self, key: str, field: str) -> Optional[str]: ... async def hgetall(self, key: str) -> Dict[str, str]: ... async def hincrby(self, key: str, field: str, increment: int) -> int: ... @@ -1233,6 +1253,16 @@ class PipelineCommands: ) -> PipelineCommands: ... def hdel(self, key: str, *fields: str) -> PipelineCommands: ... def hexists(self, key: str, field: str) -> PipelineCommands: ... + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> PipelineCommands: ... def hget(self, key: str, field: str) -> PipelineCommands: ... def hgetall(self, key: str) -> PipelineCommands: ... def hincrby(self, key: str, field: str, increment: int) -> PipelineCommands: ...