Skip to content

Commit ba79474

Browse files
committed
add inclusive filter
1 parent bc161bc commit ba79474

File tree

3 files changed

+182
-20
lines changed

3 files changed

+182
-20
lines changed

docs/user_guide/02_hybrid_queries.ipynb

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,11 +1467,108 @@
14671467
"# Cleanup\n",
14681468
"index.delete()"
14691469
]
1470+
},
1471+
{
1472+
"cell_type": "code",
1473+
"execution_count": null,
1474+
"metadata": {},
1475+
"outputs": [],
1476+
"source": [
1477+
"from enum import Enum\n",
1478+
"\n",
1479+
"class Inclusive(str, Enum):\n",
1480+
" \"\"\"Enumeration for distance aggregation methods.\"\"\"\n",
1481+
"\n",
1482+
" BOTH = \"both\"\n",
1483+
" \"\"\"Inclusive of both sides of range (default)\"\"\"\n",
1484+
" NEITHER = \"neither\"\n",
1485+
" \"\"\"Inclusive of neither side of range\"\"\"\n",
1486+
" LEFT = \"left\"\n",
1487+
" \"\"\"Inclusive of only left\"\"\"\n",
1488+
" RIGHT = \"right\"\n",
1489+
" \"\"\"Inclusive of only right\"\"\"\n",
1490+
"\n",
1491+
"def my_fn(value: Inclusive) -> str:\n",
1492+
" return Inclusive(value).value"
1493+
]
1494+
},
1495+
{
1496+
"cell_type": "code",
1497+
"execution_count": 24,
1498+
"metadata": {},
1499+
"outputs": [
1500+
{
1501+
"data": {
1502+
"text/plain": [
1503+
"['both', 'neither', 'left', '']"
1504+
]
1505+
},
1506+
"execution_count": 24,
1507+
"metadata": {},
1508+
"output_type": "execute_result"
1509+
}
1510+
],
1511+
"source": [
1512+
"list(e.value for e in Inclusive)"
1513+
]
1514+
},
1515+
{
1516+
"cell_type": "code",
1517+
"execution_count": null,
1518+
"metadata": {},
1519+
"outputs": [
1520+
{
1521+
"data": {
1522+
"text/plain": [
1523+
"mappingproxy({'BOTH': <Inclusive.BOTH: 'both'>,\n",
1524+
" 'NEITHER': <Inclusive.NEITHER: 'neither'>,\n",
1525+
" 'LEFT': <Inclusive.LEFT: 'left'>,\n",
1526+
" 'RIGHT': <Inclusive.RIGHT: ''>})"
1527+
]
1528+
},
1529+
"execution_count": 22,
1530+
"metadata": {},
1531+
"output_type": "execute_result"
1532+
}
1533+
],
1534+
"source": [
1535+
"Inclusive."
1536+
]
1537+
},
1538+
{
1539+
"cell_type": "code",
1540+
"execution_count": 10,
1541+
"metadata": {},
1542+
"outputs": [
1543+
{
1544+
"ename": "ValueError",
1545+
"evalue": "'not' is not a valid Inclusive",
1546+
"output_type": "error",
1547+
"traceback": [
1548+
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
1549+
"\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
1550+
"Cell \u001b[0;32mIn[10], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mInclusive\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mnot\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n",
1551+
"File \u001b[0;32m~/.pyenv/versions/3.11.9/lib/python3.11/enum.py:714\u001b[0m, in \u001b[0;36mEnumType.__call__\u001b[0;34m(cls, value, names, module, qualname, type, start, boundary)\u001b[0m\n\u001b[1;32m 689\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 690\u001b[0m \u001b[38;5;124;03mEither returns an existing member, or creates a new enum class.\u001b[39;00m\n\u001b[1;32m 691\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 711\u001b[0m \u001b[38;5;124;03m`type`, if set, will be mixed in as the first base class.\u001b[39;00m\n\u001b[1;32m 712\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 713\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m names \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m: \u001b[38;5;66;03m# simple value lookup\u001b[39;00m\n\u001b[0;32m--> 714\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;43mcls\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[38;5;21;43m__new__\u001b[39;49m\u001b[43m(\u001b[49m\u001b[38;5;28;43mcls\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mvalue\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 715\u001b[0m \u001b[38;5;66;03m# otherwise, functional API: we're creating a new Enum type\u001b[39;00m\n\u001b[1;32m 716\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mcls\u001b[39m\u001b[38;5;241m.\u001b[39m_create_(\n\u001b[1;32m 717\u001b[0m value,\n\u001b[1;32m 718\u001b[0m names,\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 723\u001b[0m boundary\u001b[38;5;241m=\u001b[39mboundary,\n\u001b[1;32m 724\u001b[0m )\n",
1552+
"File \u001b[0;32m~/.pyenv/versions/3.11.9/lib/python3.11/enum.py:1137\u001b[0m, in \u001b[0;36mEnum.__new__\u001b[0;34m(cls, value)\u001b[0m\n\u001b[1;32m 1135\u001b[0m ve_exc \u001b[38;5;241m=\u001b[39m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m%r\u001b[39;00m\u001b[38;5;124m is not a valid \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;241m%\u001b[39m (value, \u001b[38;5;28mcls\u001b[39m\u001b[38;5;241m.\u001b[39m\u001b[38;5;18m__qualname__\u001b[39m))\n\u001b[1;32m 1136\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m result \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m exc \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m-> 1137\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m ve_exc\n\u001b[1;32m 1138\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m exc \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[1;32m 1139\u001b[0m exc \u001b[38;5;241m=\u001b[39m \u001b[38;5;167;01mTypeError\u001b[39;00m(\n\u001b[1;32m 1140\u001b[0m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124merror in \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[38;5;124m._missing_: returned \u001b[39m\u001b[38;5;132;01m%r\u001b[39;00m\u001b[38;5;124m instead of None or a valid member\u001b[39m\u001b[38;5;124m'\u001b[39m\n\u001b[1;32m 1141\u001b[0m \u001b[38;5;241m%\u001b[39m (\u001b[38;5;28mcls\u001b[39m\u001b[38;5;241m.\u001b[39m\u001b[38;5;18m__name__\u001b[39m, result)\n\u001b[1;32m 1142\u001b[0m )\n",
1553+
"\u001b[0;31mValueError\u001b[0m: 'not' is not a valid Inclusive"
1554+
]
1555+
}
1556+
],
1557+
"source": [
1558+
"Inclusive(\"not\")"
1559+
]
1560+
},
1561+
{
1562+
"cell_type": "code",
1563+
"execution_count": null,
1564+
"metadata": {},
1565+
"outputs": [],
1566+
"source": []
14701567
}
14711568
],
14721569
"metadata": {
14731570
"kernelspec": {
1474-
"display_name": "env",
1571+
"display_name": "redisvl-Q9FZQJWe-py3.11",
14751572
"language": "python",
14761573
"name": "python3"
14771574
},
@@ -1485,7 +1582,7 @@
14851582
"name": "python",
14861583
"nbconvert_exporter": "python",
14871584
"pygments_lexer": "ipython3",
1488-
"version": "3.11.11"
1585+
"version": "3.11.9"
14891586
},
14901587
"orig_nbformat": 4
14911588
},

redisvl/query/filter.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@
1010
# mypy: disable-error-code="override"
1111

1212

13+
class Inclusive(str, Enum):
14+
"""Enumeration for distance aggregation methods."""
15+
16+
BOTH = "both"
17+
"""Inclusive of both sides of range (default)"""
18+
NEITHER = "neither"
19+
"""Inclusive of neither side of range"""
20+
LEFT = "left"
21+
"""Inclusive of only left"""
22+
RIGHT = "right"
23+
"""Inclusive of only right"""
24+
25+
1326
class FilterOperator(Enum):
1427
EQ = 1
1528
NE = 2
@@ -379,7 +392,35 @@ def __le__(self, other: int) -> "FilterExpression":
379392
self._set_value(other, self.SUPPORTED_VAL_TYPES, FilterOperator.LE)
380393
return FilterExpression(str(self))
381394

382-
def between(self, start: int, end: int) -> "FilterExpression":
395+
@staticmethod
396+
def _validate_inclusive_string(inclusive: str) -> Inclusive:
397+
try:
398+
return Inclusive(inclusive)
399+
except:
400+
raise ValueError(
401+
f"Invalid inclusive value must be: {[i.value for i in Inclusive]}"
402+
)
403+
404+
def _format_inclusive_between(
405+
self, inclusive: Inclusive, start: int, end: int
406+
) -> str:
407+
if inclusive.value == Inclusive.BOTH.value:
408+
return f"@{self._field}:[{start} {end}]"
409+
410+
if inclusive.value == Inclusive.NEITHER.value:
411+
return f"@{self._field}:[({start} ({end}]"
412+
413+
if inclusive.value == Inclusive.LEFT.value:
414+
return f"@{self._field}:[{start} ({end}]"
415+
416+
if inclusive.value == Inclusive.RIGHT.value:
417+
return f"@{self._field}:[({start} {end}]"
418+
419+
raise ValueError(f"Inclusive value not found")
420+
421+
def between(
422+
self, start: int, end: int, inclusive: str = "both"
423+
) -> "FilterExpression":
383424
"""Create a Numeric equality filter expression.
384425
385426
Args:
@@ -391,8 +432,10 @@ def between(self, start: int, end: int) -> "FilterExpression":
391432
f = Num("zipcode") == 90210
392433
393434
"""
394-
self._set_value((start, end), self.SUPPORTED_VAL_TYPES, FilterOperator.BETWEEN)
395-
return FilterExpression(str(self))
435+
inclusive = self._validate_inclusive_string(inclusive)
436+
expression = self._format_inclusive_between(inclusive, start, end)
437+
438+
return FilterExpression(expression)
396439

397440
def __str__(self) -> str:
398441
"""Return the Redis Query string for the Numeric filter"""
@@ -674,7 +717,7 @@ def _convert_to_timestamp(self, value, end_date=False):
674717

675718
raise TypeError(f"Unsupported type for timestamp conversion: {type(value)}")
676719

677-
def __eq__(self, other):
720+
def __eq__(self, other) -> FilterExpression:
678721
"""
679722
Filter for timestamps equal to the specified value.
680723
For date objects (without time), this matches the entire day.
@@ -701,7 +744,7 @@ def __eq__(self, other):
701744
self._set_value(timestamp, self.SUPPORTED_TYPES, FilterOperator.EQ)
702745
return FilterExpression(str(self))
703746

704-
def __ne__(self, other):
747+
def __ne__(self, other) -> FilterExpression:
705748
"""
706749
Filter for timestamps not equal to the specified value.
707750
For date objects (without time), this excludes the entire day.
@@ -780,7 +823,7 @@ def __le__(self, other):
780823
self._set_value(timestamp, self.SUPPORTED_TYPES, FilterOperator.LE)
781824
return FilterExpression(str(self))
782825

783-
def between(self, start, end):
826+
def between(self, start, end, inclusive: str = "both"):
784827
"""
785828
Filter for timestamps between start and end (inclusive).
786829
@@ -791,10 +834,11 @@ def between(self, start, end):
791834
Returns:
792835
self: The filter object for method chaining
793836
"""
837+
inclusive = self._validate_inclusive_string(inclusive)
838+
794839
start_ts = self._convert_to_timestamp(start)
795840
end_ts = self._convert_to_timestamp(end, end_date=True)
796841

797-
self._set_value(
798-
(start_ts, end_ts), self.SUPPORTED_TYPES, FilterOperator.BETWEEN
799-
)
800-
return FilterExpression(str(self))
842+
expression = self._format_inclusive_between(inclusive, start_ts, end_ts)
843+
844+
return FilterExpression(expression)

tests/unit/test_filter.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import date, datetime, time, timezone
1+
from datetime import date, datetime, time, timedelta, timezone
22

33
import pytest
44

@@ -112,6 +112,18 @@ def test_numeric_filter():
112112
nf = Num("numeric_field") != None
113113
assert str(nf) == "*"
114114

115+
nf = Num("numeric_field").between(2, 5)
116+
assert str(nf) == "@numeric_field:[2 5]"
117+
118+
nf = Num("numeric_field").between(2, 5, inclusive="neither")
119+
assert str(nf) == "@numeric_field:[2 5]"
120+
121+
nf = Num("numeric_field").between(2, 5, inclusive="left")
122+
assert str(nf) == "@numeric_field:[2 (5]"
123+
124+
nf = Num("numeric_field").between(2, 5, inclusive="right")
125+
assert str(nf) == "@numeric_field:[(2 5]"
126+
115127

116128
def test_text_filter():
117129
txt_f = Text("text_field") == "text"
@@ -296,13 +308,6 @@ def test_num_filter_zero():
296308
), "Num filter should handle zero correctly"
297309

298310

299-
from datetime import date, datetime, timedelta, timezone
300-
301-
import pytest
302-
303-
from redisvl.query.filter import Timestamp
304-
305-
306311
def test_timestamp_datetime():
307312
"""Test Timestamp filter with datetime objects."""
308313
# Test with timezone-aware datetime
@@ -391,6 +396,22 @@ def test_timestamp_operators():
391396
ts = Timestamp("created_at") <= dt
392397
assert str(ts) == f"@created_at:[-inf {ts_value}]"
393398

399+
td = timedelta(days=5)
400+
dt2 = dt + td
401+
ts_value2 = dt2.timestamp()
402+
403+
ts = Timestamp("created_at").between(dt, dt2)
404+
assert str(ts) == f"@created_at:[{ts_value} {ts_value2}]"
405+
406+
ts = Timestamp("created_at").between(dt, dt2, inclusive="neither")
407+
assert str(ts) == f"@created_at:[({ts_value} ({ts_value2}]"
408+
409+
ts = Timestamp("created_at").between(dt, dt2, inclusive="left")
410+
assert str(ts) == f"@created_at:[{ts_value} ({ts_value2}]"
411+
412+
ts = Timestamp("created_at").between(dt, dt2, inclusive="right")
413+
assert str(ts) == f"@created_at:[({ts_value} {ts_value2}]"
414+
394415

395416
def test_timestamp_between():
396417
"""Test the between method for date ranges."""

0 commit comments

Comments
 (0)