Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions core/src/main/java/com/redis/vl/query/FilterQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,74 @@ public FilterQueryBuilder dialect(int dialect) {
/**
* Set the field to sort results by (defaults to ascending).
*
* <p>Python equivalent: sort_by="price"
*
* @param sortBy Field name to sort by
* @return this builder
*/
public FilterQueryBuilder sortBy(String sortBy) {
this.sortBy = sortBy;
this.sortAscending = true; // Default to ascending
return this;
}

/**
* Set the field to sort results by with explicit direction.
*
* <p>Python equivalent: sort_by=("price", "DESC")
*
* @param field Field name to sort by
* @param direction Sort direction ("ASC" or "DESC", case-insensitive)
* @return this builder
* @throws IllegalArgumentException if direction is invalid
*/
public FilterQueryBuilder sortBy(String field, String direction) {
List<SortField> parsed = SortSpec.parseSortSpec(field, direction);
SortField sortField = parsed.get(0);
this.sortBy = sortField.getFieldName();
this.sortAscending = sortField.isAscending();
return this;
}

/**
* Set the field to sort results by using SortField.
*
* <p>Python equivalent: sort_by=("rating", "DESC") or using SortField.desc("rating")
*
* @param sortField SortField specifying field and direction
* @return this builder
* @throws IllegalArgumentException if sortField is null
*/
public FilterQueryBuilder sortBy(SortField sortField) {
if (sortField == null) {
throw new IllegalArgumentException("SortField cannot be null");
}
this.sortBy = sortField.getFieldName();
this.sortAscending = sortField.isAscending();
return this;
}

/**
* Set the fields to sort results by (supports multiple fields, but only first is used).
*
* <p>Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
*
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided,
* only the first field is used and a warning is logged.
*
* @param sortFields List of SortFields
* @return this builder
*/
public FilterQueryBuilder sortBy(List<SortField> sortFields) {
List<SortField> parsed = SortSpec.parseSortSpec(sortFields);
if (!parsed.isEmpty()) {
SortField firstField = parsed.get(0);
this.sortBy = firstField.getFieldName();
this.sortAscending = firstField.isAscending();
} else {
// Empty list - clear sorting
this.sortBy = null;
}
return this;
}

Expand Down
46 changes: 46 additions & 0 deletions core/src/main/java/com/redis/vl/query/SortField.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.redis.vl.query;

import lombok.Value;

/**
* Represents a sort field with its direction.
*
* <p>Python port: Corresponds to tuple (field_name, ascending) in _parse_sort_spec
*/
@Value
public class SortField {
/** Field name to sort by */
String fieldName;

/** Whether to sort ascending (true) or descending (false) */
boolean ascending;

/**
* Create a SortField with ascending order.
*
* @param fieldName Field name
* @return SortField with ascending=true
*/
public static SortField asc(String fieldName) {
return new SortField(fieldName, true);
}

/**
* Create a SortField with descending order.
*
* @param fieldName Field name
* @return SortField with ascending=false
*/
public static SortField desc(String fieldName) {
return new SortField(fieldName, false);
}

/**
* Get the direction as a string.
*
* @return "ASC" or "DESC"
*/
public String getDirection() {
return ascending ? "ASC" : "DESC";
}
}
154 changes: 154 additions & 0 deletions core/src/main/java/com/redis/vl/query/SortSpec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.redis.vl.query;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Utility class for parsing flexible sort specifications into normalized format.
*
* <p>Python port: Corresponds to SortSpec type alias and _parse_sort_spec() static method in Python
* redisvl (PR #393)
*
* <p>Python SortSpec accepts:
*
* <ul>
* <li>str: field name (defaults to ASC)
* <li>Tuple[str, str]: (field_name, direction)
* <li>List[Union[str, Tuple[str, str]]]: multiple fields with optional directions
* </ul>
*
* <p>Java SortSpec provides overloaded methods:
*
* <ul>
* <li>{@code parseSortSpec(String field)} - single field, ASC
* <li>{@code parseSortSpec(String field, String direction)} - single field with direction
* <li>{@code parseSortSpec(SortField field)} - single SortField
* <li>{@code parseSortSpec(List<SortField> fields)} - multiple fields
* </ul>
*
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided, only
* the first field is used and a warning is logged.
*/
public final class SortSpec {

private static final Logger logger = LoggerFactory.getLogger(SortSpec.class);

// Private constructor - utility class
private SortSpec() {
throw new UnsupportedOperationException("Utility class");
}

/**
* Parse a single field name (defaults to ascending order).
*
* <p>Python equivalent: sort_by="price"
*
* @param field Field name to sort by
* @return List containing single SortField with ascending=true
* @throws IllegalArgumentException if field is null or empty
*/
public static List<SortField> parseSortSpec(String field) {
validateFieldName(field);
return Collections.singletonList(SortField.asc(field.trim()));
}

/**
* Parse a single field name with direction.
*
* <p>Python equivalent: sort_by=("price", "DESC")
*
* @param field Field name to sort by
* @param direction Sort direction ("ASC" or "DESC", case-insensitive)
* @return List containing single SortField
* @throws IllegalArgumentException if field is null/empty or direction is invalid
*/
public static List<SortField> parseSortSpec(String field, String direction) {
validateFieldName(field);
validateDirection(direction);

String normalizedDirection = direction.trim().toUpperCase();
boolean ascending = "ASC".equals(normalizedDirection);

return Collections.singletonList(new SortField(field.trim(), ascending));
}

/**
* Parse a single SortField.
*
* @param field SortField to wrap in list
* @return List containing the single SortField
* @throws IllegalArgumentException if field is null
*/
public static List<SortField> parseSortSpec(SortField field) {
if (field == null) {
throw new IllegalArgumentException("SortField cannot be null");
}
return Collections.singletonList(field);
}

/**
* Parse a list of SortFields (supports multiple fields).
*
* <p>Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
*
* <p>Note: Redis Search only supports single-field sorting. When multiple fields are provided,
* only the first field is used and a warning is logged.
*
* @param fields List of SortFields
* @return List of SortFields (may be empty, uses only first field for Redis)
*/
public static List<SortField> parseSortSpec(List<SortField> fields) {
if (fields == null || fields.isEmpty()) {
return Collections.emptyList();
}

// Make defensive copy
List<SortField> result = new ArrayList<>(fields);

// Log warning if multiple fields specified (Redis limitation)
if (result.size() > 1) {
logger.warn(
"Multiple sort fields specified ({}), but Redis Search only supports single-field"
+ " sorting. Using first field: '{}'",
result.size(),
result.get(0).getFieldName());
}

return result;
}

/**
* Validate field name is not null or empty.
*
* @param field Field name to validate
* @throws IllegalArgumentException if field is null or empty/blank
*/
private static void validateFieldName(String field) {
if (field == null || field.trim().isEmpty()) {
throw new IllegalArgumentException("Field name cannot be null or empty");
}
}

/**
* Validate sort direction is "ASC" or "DESC" (case-insensitive).
*
* <p>Python raises: ValueError("Sort direction must be 'ASC' or 'DESC'")
*
* @param direction Direction string to validate
* @throws IllegalArgumentException if direction is invalid
*/
private static void validateDirection(String direction) {
if (direction == null || direction.trim().isEmpty()) {
throw new IllegalArgumentException("Sort direction cannot be null or empty");
}

String normalized = direction.trim().toUpperCase();
if (!normalized.equals("ASC") && !normalized.equals("DESC")) {
throw new IllegalArgumentException(
String.format("Sort direction must be 'ASC' or 'DESC', got: '%s'", direction));
}
}
}
Loading