Skip to content

Commit e0db616

Browse files
committed
Add DELEX command support with tests
1 parent b982dcb commit e0db616

File tree

3 files changed

+257
-0
lines changed

3 files changed

+257
-0
lines changed

redis/commands/core.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,47 @@ def delete(self, *names: KeyT) -> ResponseT:
17291729
def __delitem__(self, name: KeyT):
17301730
self.delete(name)
17311731

1732+
def delex(
1733+
self,
1734+
name: KeyT,
1735+
ifeq: Optional[EncodableT] = None,
1736+
ifne: Optional[EncodableT] = None,
1737+
ifdeq: Optional[str] = None, # hex digest
1738+
ifdne: Optional[str] = None, # hex digest
1739+
) -> int:
1740+
"""
1741+
Conditionally removes the specified key.
1742+
1743+
ifeq match-value - Delete the key only if its value is equal to match-value
1744+
ifne match-value - Delete the key only if its value is not equal to match-value
1745+
ifdeq match-digest - Delete the key only if the digest of its value is equal to match-digest
1746+
ifdne match-digest - Delete the key only if the digest of its value is not equal to match-digest
1747+
1748+
Returns:
1749+
int: 1 if the key was deleted, 0 otherwise.
1750+
Raises:
1751+
redis.exceptions.ResponseError: if key exists but is not a string
1752+
and a condition is specified.
1753+
ValueError: if more than one condition is provided.
1754+
1755+
For more information, see https://redis.io/commands/delex
1756+
"""
1757+
conds = [x is not None for x in (ifeq, ifne, ifdeq, ifdne)]
1758+
if sum(conds) > 1:
1759+
raise ValueError("Only one of IFEQ/IFNE/IFDEQ/IFDNE may be specified")
1760+
1761+
pieces = ["DELEX", name]
1762+
if ifeq is not None:
1763+
pieces += ["IFEQ", ifeq]
1764+
elif ifne is not None:
1765+
pieces += ["IFNE", ifne]
1766+
elif ifdeq is not None:
1767+
pieces += ["IFDEQ", ifdeq]
1768+
elif ifdne is not None:
1769+
pieces += ["IFDNE", ifdne]
1770+
1771+
return self.execute_command(*pieces)
1772+
17321773
def dump(self, name: KeyT) -> ResponseT:
17331774
"""
17341775
Return a serialized version of the value stored at the specified key.

tests/test_asyncio/test_commands.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,114 @@ async def test_delitem(self, r: redis.Redis):
10321032
await r.delete("a")
10331033
assert await r.get("a") is None
10341034

1035+
def _ensure_str(self, x):
1036+
return x.decode("ascii") if isinstance(x, (bytes, bytearray)) else x
1037+
1038+
async def _server_xxh3_digest(self, r, key):
1039+
"""
1040+
Get the server-computed XXH3 hex digest for the key's value.
1041+
Requires the DIGEST command implemented on the server.
1042+
"""
1043+
d = await r.execute_command("DIGEST", key)
1044+
return None if d is None else self._ensure_str(d).lower()
1045+
1046+
@skip_if_server_version_lt("8.3.224")
1047+
async def test_delex_nonexistent(self, r):
1048+
await r.delete("nope")
1049+
assert await r.delex("nope") == 0
1050+
1051+
@skip_if_server_version_lt("8.3.224")
1052+
async def test_delex_unconditional_delete_string(self, r):
1053+
await r.set("k", b"v")
1054+
assert await r.exists("k") == 1
1055+
assert await r.delex("k") == 1
1056+
assert await r.exists("k") == 0
1057+
1058+
@skip_if_server_version_lt("8.3.224")
1059+
async def test_delex_unconditional_delete_nonstring_allowed(self, r):
1060+
# Spec: error happens only when a condition is specified on a non-string key.
1061+
await r.lpush("lst", "a")
1062+
assert await r.delex("lst") == 1
1063+
assert await r.exists("lst") == 0
1064+
1065+
await r.lpush("lst", "a")
1066+
1067+
with pytest.raises(redis.ResponseError):
1068+
await r.delex("lst", ifeq=b"a")
1069+
assert await r.exists("lst") == 1
1070+
1071+
@skip_if_server_version_lt("8.3.224")
1072+
async def test_delex_ifeq(self, r):
1073+
await r.set("k", b"abc")
1074+
assert await r.delex("k", ifeq=b"abc") == 1 # matches → deleted
1075+
assert await r.exists("k") == 0
1076+
1077+
await r.set("k", b"abc")
1078+
assert await r.delex("k", ifeq=b"zzz") == 0 # not match → not deleted
1079+
assert await r.get("k") == b"abc" # still there
1080+
1081+
@skip_if_server_version_lt("8.3.224")
1082+
async def test_delex_ifne(self, r):
1083+
await r.set("k2", b"abc")
1084+
assert await r.delex("k2", ifne=b"zzz") == 1 # different → deleted
1085+
assert await r.exists("k2") == 0
1086+
1087+
await r.set("k2", b"abc")
1088+
assert await r.delex("k2", ifne=b"abc") == 0 # equal → not deleted
1089+
assert await r.get("k2") == b"abc"
1090+
1091+
@skip_if_server_version_lt("8.3.224")
1092+
async def test_delex_with_conditionon_nonstring_values(self, r):
1093+
await r.lpush("nk", "x")
1094+
with pytest.raises(redis.ResponseError):
1095+
await r.delex("nk", ifeq=b"x")
1096+
with pytest.raises(redis.ResponseError):
1097+
await r.delex("nk", ifne=b"x")
1098+
with pytest.raises(redis.ResponseError):
1099+
await r.delex("nk", ifdeq="deadbeef")
1100+
1101+
@skip_if_server_version_lt("8.3.224")
1102+
@pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"])
1103+
async def test_delex_ifdeq_and_ifdne(self, r, val):
1104+
await r.set("h", val)
1105+
d = await self._server_xxh3_digest(r, "h")
1106+
assert d is not None
1107+
1108+
# IFDEQ should delete with exact digest
1109+
await r.set("h", val)
1110+
assert await r.delex("h", ifdeq=d) == 1
1111+
assert await r.exists("h") == 0
1112+
1113+
# IFDNE should NOT delete when digest matches
1114+
await r.set("h", val)
1115+
assert await r.delex("h", ifdne=d) == 0
1116+
assert await r.get("h") == val
1117+
1118+
# IFDNE should delete when digest doesn't match
1119+
await r.set("h", val)
1120+
wrong = "0" * len(d)
1121+
if wrong == d:
1122+
wrong = "f" * len(d)
1123+
assert await r.delex("h", ifdne=wrong) == 1
1124+
assert await r.exists("h") == 0
1125+
1126+
@skip_if_server_version_lt("8.3.224")
1127+
async def test_delex_pipeline(self, r):
1128+
await r.mset({"p1": b"A", "p2": b"B"})
1129+
p = r.pipeline()
1130+
p.delex("p1", ifeq=b"A")
1131+
p.delex("p2", ifne=b"B") # false → 0
1132+
p.delex("nope") # nonexistent → 0
1133+
out = await p.execute()
1134+
assert out == [1, 0, 0]
1135+
1136+
@skip_if_server_version_lt("8.3.224")
1137+
async def test_delex_mutual_exclusion_client_side(self, r):
1138+
with pytest.raises(ValueError):
1139+
await r.delex("k", ifeq=b"A", ifne=b"B")
1140+
with pytest.raises(ValueError):
1141+
await r.delex("k", ifdeq="aa", ifdne="bb")
1142+
10351143
@skip_if_server_version_lt("4.0.0")
10361144
async def test_unlink(self, r: redis.Redis):
10371145
assert await r.unlink("a") == 0

tests/test_commands.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,114 @@ def test_delitem(self, r):
15011501
del r["a"]
15021502
assert r.get("a") is None
15031503

1504+
def _ensure_str(self, x):
1505+
return x.decode("ascii") if isinstance(x, (bytes, bytearray)) else x
1506+
1507+
def _server_xxh3_digest(self, r, key):
1508+
"""
1509+
Get the server-computed XXH3 hex digest for the key's value.
1510+
Requires the DIGEST command implemented on the server.
1511+
"""
1512+
d = r.execute_command("DIGEST", key)
1513+
return None if d is None else self._ensure_str(d).lower()
1514+
1515+
@skip_if_server_version_lt("8.3.224")
1516+
def test_delex_nonexistent(self, r):
1517+
r.delete("nope")
1518+
assert r.delex("nope") == 0
1519+
1520+
@skip_if_server_version_lt("8.3.224")
1521+
def test_delex_unconditional_delete_string(self, r):
1522+
r.set("k", b"v")
1523+
assert r.exists("k") == 1
1524+
assert r.delex("k") == 1
1525+
assert r.exists("k") == 0
1526+
1527+
@skip_if_server_version_lt("8.3.224")
1528+
def test_delex_unconditional_delete_nonstring_allowed(self, r):
1529+
# Spec: error happens only when a condition is specified on a non-string key.
1530+
r.lpush("lst", "a")
1531+
assert r.delex("lst") == 1
1532+
assert r.exists("lst") == 0
1533+
1534+
r.lpush("lst", "a")
1535+
1536+
with pytest.raises(redis.ResponseError):
1537+
r.delex("lst", ifeq=b"a")
1538+
assert r.exists("lst") == 1
1539+
1540+
@skip_if_server_version_lt("8.3.224")
1541+
def test_delex_ifeq(self, r):
1542+
r.set("k", b"abc")
1543+
assert r.delex("k", ifeq=b"abc") == 1 # matches → deleted
1544+
assert r.exists("k") == 0
1545+
1546+
r.set("k", b"abc")
1547+
assert r.delex("k", ifeq=b"zzz") == 0 # not match → not deleted
1548+
assert r.get("k") == b"abc" # still there
1549+
1550+
@skip_if_server_version_lt("8.3.224")
1551+
def test_delex_ifne(self, r):
1552+
r.set("k2", b"abc")
1553+
assert r.delex("k2", ifne=b"zzz") == 1 # different → deleted
1554+
assert r.exists("k2") == 0
1555+
1556+
r.set("k2", b"abc")
1557+
assert r.delex("k2", ifne=b"abc") == 0 # equal → not deleted
1558+
assert r.get("k2") == b"abc"
1559+
1560+
@skip_if_server_version_lt("8.3.224")
1561+
def test_delex_with_conditionon_nonstring_values(self, r):
1562+
r.lpush("nk", "x")
1563+
with pytest.raises(redis.ResponseError):
1564+
r.delex("nk", ifeq=b"x")
1565+
with pytest.raises(redis.ResponseError):
1566+
r.delex("nk", ifne=b"x")
1567+
with pytest.raises(redis.ResponseError):
1568+
r.delex("nk", ifdeq="deadbeef")
1569+
1570+
@skip_if_server_version_lt("8.3.224")
1571+
@pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"])
1572+
def test_delex_ifdeq_and_ifdne(self, r, val):
1573+
r.set("h", val)
1574+
d = self._server_xxh3_digest(r, "h")
1575+
assert d is not None
1576+
1577+
# IFDEQ should delete with exact digest
1578+
r.set("h", val)
1579+
assert r.delex("h", ifdeq=d) == 1
1580+
assert r.exists("h") == 0
1581+
1582+
# IFDNE should NOT delete when digest matches
1583+
r.set("h", val)
1584+
assert r.delex("h", ifdne=d) == 0
1585+
assert r.get("h") == val
1586+
1587+
# IFDNE should delete when digest doesn't match
1588+
r.set("h", val)
1589+
wrong = "0" * len(d)
1590+
if wrong == d:
1591+
wrong = "f" * len(d)
1592+
assert r.delex("h", ifdne=wrong) == 1
1593+
assert r.exists("h") == 0
1594+
1595+
@skip_if_server_version_lt("8.3.224")
1596+
def test_delex_pipeline(self, r):
1597+
r.mset({"p1": b"A", "p2": b"B"})
1598+
p = r.pipeline()
1599+
p.delex("p1", ifeq=b"A")
1600+
p.delex("p2", ifne=b"B") # false → 0
1601+
p.delex("nope") # nonexistent → 0
1602+
out = p.execute()
1603+
assert out == [1, 0, 0]
1604+
1605+
@skip_if_server_version_lt("8.3.224")
1606+
def test_delex_mutual_exclusion_client_side(self, r):
1607+
with pytest.raises(ValueError):
1608+
r.delex("k", ifeq=b"A", ifne=b"B")
1609+
with pytest.raises(ValueError):
1610+
r.delex("k", ifdeq="aa", ifdne="bb")
1611+
15041612
@skip_if_server_version_lt("4.0.0")
15051613
def test_unlink(self, r):
15061614
assert r.unlink("a") == 0

0 commit comments

Comments
 (0)