Skip to content

Commit 417186b

Browse files
bsboddenclaude
andcommitted
feat: add support for multiple field sorting specifications (#373)
Enhanced sort_by parameter to accept multiple formats: - Single field: sort_by="price" - Field with direction: sort_by=("price", "DESC") - Multiple fields: sort_by=["price", ("rating", "DESC")] Note: Redis Search only supports single-field sorting, so only the first field is used for actual sorting. A warning is logged when multiple fields are specified. This provides a flexible API for future enhancements while working within Redis's current limitations. Changes: - Added SortSpec type alias for flexible sort specifications - Implemented _parse_sort_spec() static method to normalize sort inputs - Overrode sort_by() method in BaseQuery with validation and warnings - Updated type hints for FilterQuery, VectorQuery, VectorRangeQuery, TextQuery - Added comprehensive unit tests for multiple field sorting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c3c1733 commit 417186b

File tree

2 files changed

+375
-11
lines changed

2 files changed

+375
-11
lines changed

redisvl/query/query.py

Lines changed: 176 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,22 @@
55

66
from redisvl.query.filter import FilterExpression
77
from redisvl.redis.utils import array_to_buffer
8+
from redisvl.utils.log import get_logger
89
from redisvl.utils.token_escaper import TokenEscaper
910
from redisvl.utils.utils import denorm_cosine_distance, lazy_import
1011

12+
logger = get_logger(__name__)
13+
1114
nltk = lazy_import("nltk")
1215
nltk_stopwords = lazy_import("nltk.corpus.stopwords")
1316

17+
# Type alias for sort specification
18+
# Can be:
19+
# - str: single field name (ASC by default)
20+
# - Tuple[str, str]: (field_name, direction)
21+
# - List: list of field names or tuples
22+
SortSpec = Union[str, Tuple[str, str], List[Union[str, Tuple[str, str]]]]
23+
1424

1525
class BaseQuery(RedisQuery):
1626
"""
@@ -58,6 +68,144 @@ def _build_query_string(self) -> str:
5868
"""Build the full Redis query string."""
5969
raise NotImplementedError("Must be implemented by subclasses")
6070

71+
@staticmethod
72+
def _parse_sort_spec(sort_spec: Optional[SortSpec]) -> List[Tuple[str, bool]]:
73+
"""Parse sort specification into list of (field, ascending) tuples.
74+
75+
Args:
76+
sort_spec: Sort specification in various formats:
77+
- str: single field name (defaults to ASC)
78+
- Tuple[str, str]: (field_name, "ASC"|"DESC")
79+
- List: list of strings or tuples
80+
81+
Returns:
82+
List of (field_name, ascending) tuples where ascending is a boolean.
83+
84+
Raises:
85+
TypeError: If sort_spec is not a valid type.
86+
ValueError: If direction is not "ASC" or "DESC".
87+
88+
Examples:
89+
>>> BaseQuery._parse_sort_spec("price")
90+
[("price", True)]
91+
>>> BaseQuery._parse_sort_spec(("price", "DESC"))
92+
[("price", False)]
93+
>>> BaseQuery._parse_sort_spec(["price", ("rating", "DESC")])
94+
[("price", True), ("rating", False)]
95+
"""
96+
if sort_spec is None or sort_spec == []:
97+
return []
98+
99+
result: List[Tuple[str, bool]] = []
100+
101+
# Single field as string
102+
if isinstance(sort_spec, str):
103+
result.append((sort_spec, True)) # Default to ASC
104+
105+
# Single field as tuple
106+
elif isinstance(sort_spec, tuple):
107+
if len(sort_spec) != 2:
108+
raise ValueError(
109+
f"Sort tuple must have exactly 2 elements (field, direction), got {len(sort_spec)}"
110+
)
111+
field, direction = sort_spec
112+
if not isinstance(field, str):
113+
raise TypeError(f"Field name must be a string, got {type(field)}")
114+
if not isinstance(direction, str):
115+
raise TypeError(f"Direction must be a string, got {type(direction)}")
116+
117+
direction_upper = direction.upper()
118+
if direction_upper not in ("ASC", "DESC"):
119+
raise ValueError(
120+
f"Sort direction must be 'ASC' or 'DESC', got '{direction}'"
121+
)
122+
123+
result.append((field, direction_upper == "ASC"))
124+
125+
# Multiple fields as list
126+
elif isinstance(sort_spec, list):
127+
for item in sort_spec:
128+
# Recursively parse each item
129+
parsed = BaseQuery._parse_sort_spec(item)
130+
result.extend(parsed)
131+
132+
else:
133+
raise TypeError(
134+
f"sort_by must be a string, tuple, or list, got {type(sort_spec)}"
135+
)
136+
137+
return result
138+
139+
def sort_by(
140+
self, sort_spec: Optional[SortSpec] = None, asc: bool = True
141+
) -> "BaseQuery":
142+
"""Set the sort order for query results.
143+
144+
This method supports sorting by single or multiple fields. Note that Redis Search
145+
natively supports only a single SORTBY field. When multiple fields are specified,
146+
only the FIRST field is used for the Redis SORTBY clause.
147+
148+
Args:
149+
sort_spec: Sort specification in various formats:
150+
- str: single field name
151+
- Tuple[str, str]: (field_name, "ASC"|"DESC")
152+
- List: list of field names or tuples
153+
asc: Default sort direction when not specified (only used when sort_spec is a string).
154+
Defaults to True (ascending).
155+
156+
Returns:
157+
self: Returns the query object for method chaining.
158+
159+
Raises:
160+
TypeError: If sort_spec is not a valid type.
161+
ValueError: If direction is not "ASC" or "DESC".
162+
163+
Examples:
164+
>>> query.sort_by("price") # Single field, ascending
165+
>>> query.sort_by(("price", "DESC")) # Single field, descending
166+
>>> query.sort_by(["price", "rating"]) # Multiple fields (only first used)
167+
>>> query.sort_by([("price", "DESC"), ("rating", "ASC")])
168+
169+
Note:
170+
When multiple fields are specified, only the first field is used for sorting
171+
in Redis. Future versions may support multi-field sorting through post-query
172+
sorting in Python.
173+
"""
174+
if sort_spec is None or sort_spec == []:
175+
# No sorting
176+
self._sortby = None
177+
return self
178+
179+
# Handle backward compatibility: if sort_spec is a string and asc is specified
180+
# treat it as the old (field, asc) format
181+
parsed: List[Tuple[str, bool]]
182+
if isinstance(sort_spec, str) and asc is not True:
183+
# Old API: query.sort_by("field", asc=False)
184+
parsed = [(sort_spec, asc)]
185+
else:
186+
# New API: parse the sort_spec
187+
parsed = self._parse_sort_spec(sort_spec)
188+
189+
if not parsed:
190+
self._sortby = None
191+
return self
192+
193+
# Use the first field for Redis SORTBY
194+
first_field, first_asc = parsed[0]
195+
196+
# Log warning if multiple fields specified
197+
if len(parsed) > 1:
198+
logger.warning(
199+
f"Multiple sort fields specified: {[f[0] for f in parsed]}. "
200+
f"Redis Search only supports single-field sorting. Using first field: '{first_field}'. "
201+
"Additional fields are ignored."
202+
)
203+
204+
# Call parent's sort_by with the first field
205+
super().sort_by(first_field, asc=first_asc)
206+
207+
return self
208+
61209
def set_filter(
62210
self, filter_expression: Optional[Union[str, FilterExpression]] = None
63211
):
@@ -170,7 +318,7 @@ def __init__(
170318
return_fields: Optional[List[str]] = None,
171319
num_results: int = 10,
172320
dialect: int = 2,
173-
sort_by: Optional[str] = None,
321+
sort_by: Optional[SortSpec] = None,
174322
in_order: bool = False,
175323
params: Optional[Dict[str, Any]] = None,
176324
):
@@ -182,7 +330,12 @@ def __init__(
182330
return_fields (Optional[List[str]], optional): The fields to return.
183331
num_results (Optional[int], optional): The number of results to return. Defaults to 10.
184332
dialect (int, optional): The query dialect. Defaults to 2.
185-
sort_by (Optional[str], optional): The field to order the results by. Defaults to None.
333+
sort_by (Optional[SortSpec], optional): The field(s) to order the results by. Can be:
334+
- str: single field name (e.g., "price")
335+
- Tuple[str, str]: (field_name, "ASC"|"DESC") (e.g., ("price", "DESC"))
336+
- List: list of fields or tuples (e.g., ["price", ("rating", "DESC")])
337+
Note: Redis Search only supports single-field sorting, so only the first field is used.
338+
Defaults to None.
186339
in_order (bool, optional): Requires the terms in the field to have the same order as the
187340
terms in the query filter. Defaults to False.
188341
params (Optional[Dict[str, Any]], optional): The parameters for the query. Defaults to None.
@@ -292,7 +445,7 @@ def __init__(
292445
num_results: int = 10,
293446
return_score: bool = True,
294447
dialect: int = 2,
295-
sort_by: Optional[str] = None,
448+
sort_by: Optional[SortSpec] = None,
296449
in_order: bool = False,
297450
hybrid_policy: Optional[str] = None,
298451
batch_size: Optional[int] = None,
@@ -319,8 +472,12 @@ def __init__(
319472
distance. Defaults to True.
320473
dialect (int, optional): The RediSearch query dialect.
321474
Defaults to 2.
322-
sort_by (Optional[str]): The field to order the results by. Defaults
323-
to None. Results will be ordered by vector distance.
475+
sort_by (Optional[SortSpec]): The field(s) to order the results by. Can be:
476+
- str: single field name
477+
- Tuple[str, str]: (field_name, "ASC"|"DESC")
478+
- List: list of fields or tuples
479+
Note: Only the first field is used for Redis sorting.
480+
Defaults to None. Results will be ordered by vector distance.
324481
in_order (bool): Requires the terms in the field to have
325482
the same order as the terms in the query filter, regardless of
326483
the offsets between them. Defaults to False.
@@ -543,7 +700,7 @@ def __init__(
543700
num_results: int = 10,
544701
return_score: bool = True,
545702
dialect: int = 2,
546-
sort_by: Optional[str] = None,
703+
sort_by: Optional[SortSpec] = None,
547704
in_order: bool = False,
548705
hybrid_policy: Optional[str] = None,
549706
batch_size: Optional[int] = None,
@@ -576,8 +733,12 @@ def __init__(
576733
distance. Defaults to True.
577734
dialect (int, optional): The RediSearch query dialect.
578735
Defaults to 2.
579-
sort_by (Optional[str]): The field to order the results by. Defaults
580-
to None. Results will be ordered by vector distance.
736+
sort_by (Optional[SortSpec]): The field(s) to order the results by. Can be:
737+
- str: single field name
738+
- Tuple[str, str]: (field_name, "ASC"|"DESC")
739+
- List: list of fields or tuples
740+
Note: Only the first field is used for Redis sorting.
741+
Defaults to None. Results will be ordered by vector distance.
581742
in_order (bool): Requires the terms in the field to have
582743
the same order as the terms in the query filter, regardless of
583744
the offsets between them. Defaults to False.
@@ -863,7 +1024,7 @@ def __init__(
8631024
num_results: int = 10,
8641025
return_score: bool = True,
8651026
dialect: int = 2,
866-
sort_by: Optional[str] = None,
1027+
sort_by: Optional[SortSpec] = None,
8671028
in_order: bool = False,
8681029
params: Optional[Dict[str, Any]] = None,
8691030
stopwords: Optional[Union[str, Set[str]]] = "english",
@@ -887,8 +1048,12 @@ def __init__(
8871048
Defaults to True.
8881049
dialect (int, optional): The RediSearch query dialect.
8891050
Defaults to 2.
890-
sort_by (Optional[str]): The field to order the results by. Defaults
891-
to None. Results will be ordered by text score.
1051+
sort_by (Optional[SortSpec]): The field(s) to order the results by. Can be:
1052+
- str: single field name
1053+
- Tuple[str, str]: (field_name, "ASC"|"DESC")
1054+
- List: list of fields or tuples
1055+
Note: Only the first field is used for Redis sorting.
1056+
Defaults to None. Results will be ordered by text score.
8921057
in_order (bool): Requires the terms in the field to have
8931058
the same order as the terms in the query filter, regardless of
8941059
the offsets between them. Defaults to False.

0 commit comments

Comments
 (0)