Skip to content

Commit 8f03877

Browse files
committed
feat: implement multi-field sorting support (port of Python PR #393)
Add flexible sort specification API to support multiple sorting formats: - Single field string: sortBy("price") - Field with direction: sortBy("price", "DESC") - SortField object: sortBy(SortField.desc("price")) - Multiple fields: sortBy(List.of(SortField.desc("price"), SortField.asc("rating"))) Implementation details: - Created SortField class to represent field name and sort direction - Created SortSpec utility with parseSortSpec() methods for normalization - Updated FilterQuery, VectorQuery, VectorRangeQuery, and TextQuery builders - Added warning when multiple fields specified (Redis limitation - only first used) - Validates sort direction must be "ASC" or "DESC" (case-insensitive) Test coverage: - 11 unit tests in SortSpecTest for parsing and validation - 21 unit tests in MultiFieldSortingTest for query builder integration - All existing tests continue to pass Maintains backward compatibility with existing sortBy(String) and sortAscending/sortDescending methods. Refs: redis/redis-vl-python#393 Fixes: #373
1 parent 87bca26 commit 8f03877

File tree

8 files changed

+1036
-3
lines changed

8 files changed

+1036
-3
lines changed

core/src/main/java/com/redis/vl/query/FilterQuery.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,74 @@ public FilterQueryBuilder dialect(int dialect) {
254254
/**
255255
* Set the field to sort results by (defaults to ascending).
256256
*
257+
* <p>Python equivalent: sort_by="price"
258+
*
257259
* @param sortBy Field name to sort by
258260
* @return this builder
259261
*/
260262
public FilterQueryBuilder sortBy(String sortBy) {
261263
this.sortBy = sortBy;
264+
this.sortAscending = true; // Default to ascending
265+
return this;
266+
}
267+
268+
/**
269+
* Set the field to sort results by with explicit direction.
270+
*
271+
* <p>Python equivalent: sort_by=("price", "DESC")
272+
*
273+
* @param field Field name to sort by
274+
* @param direction Sort direction ("ASC" or "DESC", case-insensitive)
275+
* @return this builder
276+
* @throws IllegalArgumentException if direction is invalid
277+
*/
278+
public FilterQueryBuilder sortBy(String field, String direction) {
279+
List<SortField> parsed = SortSpec.parseSortSpec(field, direction);
280+
SortField sortField = parsed.get(0);
281+
this.sortBy = sortField.getFieldName();
282+
this.sortAscending = sortField.isAscending();
283+
return this;
284+
}
285+
286+
/**
287+
* Set the field to sort results by using SortField.
288+
*
289+
* <p>Python equivalent: sort_by=("rating", "DESC") or using SortField.desc("rating")
290+
*
291+
* @param sortField SortField specifying field and direction
292+
* @return this builder
293+
* @throws IllegalArgumentException if sortField is null
294+
*/
295+
public FilterQueryBuilder sortBy(SortField sortField) {
296+
if (sortField == null) {
297+
throw new IllegalArgumentException("SortField cannot be null");
298+
}
299+
this.sortBy = sortField.getFieldName();
300+
this.sortAscending = sortField.isAscending();
301+
return this;
302+
}
303+
304+
/**
305+
* Set the fields to sort results by (supports multiple fields, but only first is used).
306+
*
307+
* <p>Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
308+
*
309+
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided,
310+
* only the first field is used and a warning is logged.
311+
*
312+
* @param sortFields List of SortFields
313+
* @return this builder
314+
*/
315+
public FilterQueryBuilder sortBy(List<SortField> sortFields) {
316+
List<SortField> parsed = SortSpec.parseSortSpec(sortFields);
317+
if (!parsed.isEmpty()) {
318+
SortField firstField = parsed.get(0);
319+
this.sortBy = firstField.getFieldName();
320+
this.sortAscending = firstField.isAscending();
321+
} else {
322+
// Empty list - clear sorting
323+
this.sortBy = null;
324+
}
262325
return this;
263326
}
264327

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.redis.vl.query;
2+
3+
import lombok.Value;
4+
5+
/**
6+
* Represents a sort field with its direction.
7+
*
8+
* <p>Python port: Corresponds to tuple (field_name, ascending) in _parse_sort_spec
9+
*/
10+
@Value
11+
public class SortField {
12+
/** Field name to sort by */
13+
String fieldName;
14+
15+
/** Whether to sort ascending (true) or descending (false) */
16+
boolean ascending;
17+
18+
/**
19+
* Create a SortField with ascending order.
20+
*
21+
* @param fieldName Field name
22+
* @return SortField with ascending=true
23+
*/
24+
public static SortField asc(String fieldName) {
25+
return new SortField(fieldName, true);
26+
}
27+
28+
/**
29+
* Create a SortField with descending order.
30+
*
31+
* @param fieldName Field name
32+
* @return SortField with ascending=false
33+
*/
34+
public static SortField desc(String fieldName) {
35+
return new SortField(fieldName, false);
36+
}
37+
38+
/**
39+
* Get the direction as a string.
40+
*
41+
* @return "ASC" or "DESC"
42+
*/
43+
public String getDirection() {
44+
return ascending ? "ASC" : "DESC";
45+
}
46+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package com.redis.vl.query;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
/**
10+
* Utility class for parsing flexible sort specifications into normalized format.
11+
*
12+
* <p>Python port: Corresponds to SortSpec type alias and _parse_sort_spec() static method in Python
13+
* redisvl (PR #393)
14+
*
15+
* <p>Python SortSpec accepts:
16+
*
17+
* <ul>
18+
* <li>str: field name (defaults to ASC)
19+
* <li>Tuple[str, str]: (field_name, direction)
20+
* <li>List[Union[str, Tuple[str, str]]]: multiple fields with optional directions
21+
* </ul>
22+
*
23+
* <p>Java SortSpec provides overloaded methods:
24+
*
25+
* <ul>
26+
* <li>{@code parseSortSpec(String field)} - single field, ASC
27+
* <li>{@code parseSortSpec(String field, String direction)} - single field with direction
28+
* <li>{@code parseSortSpec(SortField field)} - single SortField
29+
* <li>{@code parseSortSpec(List<SortField> fields)} - multiple fields
30+
* </ul>
31+
*
32+
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided, only
33+
* the first field is used and a warning is logged.
34+
*/
35+
public final class SortSpec {
36+
37+
private static final Logger logger = LoggerFactory.getLogger(SortSpec.class);
38+
39+
// Private constructor - utility class
40+
private SortSpec() {
41+
throw new UnsupportedOperationException("Utility class");
42+
}
43+
44+
/**
45+
* Parse a single field name (defaults to ascending order).
46+
*
47+
* <p>Python equivalent: sort_by="price"
48+
*
49+
* @param field Field name to sort by
50+
* @return List containing single SortField with ascending=true
51+
* @throws IllegalArgumentException if field is null or empty
52+
*/
53+
public static List<SortField> parseSortSpec(String field) {
54+
validateFieldName(field);
55+
return Collections.singletonList(SortField.asc(field.trim()));
56+
}
57+
58+
/**
59+
* Parse a single field name with direction.
60+
*
61+
* <p>Python equivalent: sort_by=("price", "DESC")
62+
*
63+
* @param field Field name to sort by
64+
* @param direction Sort direction ("ASC" or "DESC", case-insensitive)
65+
* @return List containing single SortField
66+
* @throws IllegalArgumentException if field is null/empty or direction is invalid
67+
*/
68+
public static List<SortField> parseSortSpec(String field, String direction) {
69+
validateFieldName(field);
70+
validateDirection(direction);
71+
72+
String normalizedDirection = direction.trim().toUpperCase();
73+
boolean ascending = "ASC".equals(normalizedDirection);
74+
75+
return Collections.singletonList(new SortField(field.trim(), ascending));
76+
}
77+
78+
/**
79+
* Parse a single SortField.
80+
*
81+
* @param field SortField to wrap in list
82+
* @return List containing the single SortField
83+
* @throws IllegalArgumentException if field is null
84+
*/
85+
public static List<SortField> parseSortSpec(SortField field) {
86+
if (field == null) {
87+
throw new IllegalArgumentException("SortField cannot be null");
88+
}
89+
return Collections.singletonList(field);
90+
}
91+
92+
/**
93+
* Parse a list of SortFields (supports multiple fields).
94+
*
95+
* <p>Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
96+
*
97+
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided,
98+
* only the first field is used and a warning is logged.
99+
*
100+
* @param fields List of SortFields
101+
* @return List of SortFields (may be empty, uses only first field for Redis)
102+
*/
103+
public static List<SortField> parseSortSpec(List<SortField> fields) {
104+
if (fields == null || fields.isEmpty()) {
105+
return Collections.emptyList();
106+
}
107+
108+
// Make defensive copy
109+
List<SortField> result = new ArrayList<>(fields);
110+
111+
// Log warning if multiple fields specified (Redis limitation)
112+
if (result.size() > 1) {
113+
logger.warn(
114+
"Multiple sort fields specified ({}), but Redis Search only supports single-field"
115+
+ " sorting. Using first field: '{}'",
116+
result.size(),
117+
result.get(0).getFieldName());
118+
}
119+
120+
return result;
121+
}
122+
123+
/**
124+
* Validate field name is not null or empty.
125+
*
126+
* @param field Field name to validate
127+
* @throws IllegalArgumentException if field is null or empty/blank
128+
*/
129+
private static void validateFieldName(String field) {
130+
if (field == null || field.trim().isEmpty()) {
131+
throw new IllegalArgumentException("Field name cannot be null or empty");
132+
}
133+
}
134+
135+
/**
136+
* Validate sort direction is "ASC" or "DESC" (case-insensitive).
137+
*
138+
* <p>Python raises: ValueError("Sort direction must be 'ASC' or 'DESC'")
139+
*
140+
* @param direction Direction string to validate
141+
* @throws IllegalArgumentException if direction is invalid
142+
*/
143+
private static void validateDirection(String direction) {
144+
if (direction == null || direction.trim().isEmpty()) {
145+
throw new IllegalArgumentException("Sort direction cannot be null or empty");
146+
}
147+
148+
String normalized = direction.trim().toUpperCase();
149+
if (!normalized.equals("ASC") && !normalized.equals("DESC")) {
150+
throw new IllegalArgumentException(
151+
String.format("Sort direction must be 'ASC' or 'DESC', got: '%s'", direction));
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)