diff --git a/core/src/main/java/com/redis/vl/query/FilterQuery.java b/core/src/main/java/com/redis/vl/query/FilterQuery.java
index 4a48e17..1d66d4d 100644
--- a/core/src/main/java/com/redis/vl/query/FilterQuery.java
+++ b/core/src/main/java/com/redis/vl/query/FilterQuery.java
@@ -254,11 +254,74 @@ public FilterQueryBuilder dialect(int dialect) {
/**
* Set the field to sort results by (defaults to ascending).
*
+ *
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.
+ *
+ *
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 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.
+ *
+ * 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).
+ *
+ *
Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
+ *
+ *
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 sortFields) {
+ List 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;
}
diff --git a/core/src/main/java/com/redis/vl/query/SortField.java b/core/src/main/java/com/redis/vl/query/SortField.java
new file mode 100644
index 0000000..15f2a07
--- /dev/null
+++ b/core/src/main/java/com/redis/vl/query/SortField.java
@@ -0,0 +1,46 @@
+package com.redis.vl.query;
+
+import lombok.Value;
+
+/**
+ * Represents a sort field with its direction.
+ *
+ * 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";
+ }
+}
diff --git a/core/src/main/java/com/redis/vl/query/SortSpec.java b/core/src/main/java/com/redis/vl/query/SortSpec.java
new file mode 100644
index 0000000..e77fbf9
--- /dev/null
+++ b/core/src/main/java/com/redis/vl/query/SortSpec.java
@@ -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.
+ *
+ *
Python port: Corresponds to SortSpec type alias and _parse_sort_spec() static method in Python
+ * redisvl (PR #393)
+ *
+ *
Python SortSpec accepts:
+ *
+ *
+ * - str: field name (defaults to ASC)
+ *
- Tuple[str, str]: (field_name, direction)
+ *
- List[Union[str, Tuple[str, str]]]: multiple fields with optional directions
+ *
+ *
+ * Java SortSpec provides overloaded methods:
+ *
+ *
+ * - {@code parseSortSpec(String field)} - single field, ASC
+ *
- {@code parseSortSpec(String field, String direction)} - single field with direction
+ *
- {@code parseSortSpec(SortField field)} - single SortField
+ *
- {@code parseSortSpec(List fields)} - multiple fields
+ *
+ *
+ * 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).
+ *
+ *
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 parseSortSpec(String field) {
+ validateFieldName(field);
+ return Collections.singletonList(SortField.asc(field.trim()));
+ }
+
+ /**
+ * Parse a single field name with direction.
+ *
+ * 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 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 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).
+ *
+ * Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
+ *
+ *
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 parseSortSpec(List fields) {
+ if (fields == null || fields.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // Make defensive copy
+ List 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).
+ *
+ * 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));
+ }
+ }
+}
diff --git a/core/src/main/java/com/redis/vl/query/TextQuery.java b/core/src/main/java/com/redis/vl/query/TextQuery.java
index ee4a6ab..297a3bb 100644
--- a/core/src/main/java/com/redis/vl/query/TextQuery.java
+++ b/core/src/main/java/com/redis/vl/query/TextQuery.java
@@ -2,11 +2,12 @@
import com.redis.vl.utils.TokenEscaper;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import lombok.Getter;
/**
- * Full-text search query with support for field weights.
+ * Full-text search query with support for field weights and sorting.
*
*
Python port: Implements text_field_name with Union[str, Dict[str, float]] for weighted text
* search across multiple fields.
@@ -25,6 +26,13 @@
* .text("search terms")
* .textFieldWeights(Map.of("title", 5.0, "content", 2.0, "tags", 1.0))
* .build();
+ *
+ * // With sorting
+ * TextQuery query = TextQuery.builder()
+ * .text("search terms")
+ * .textField("description")
+ * .sortBy("price", "DESC")
+ * .build();
* }
*/
@Getter
@@ -38,12 +46,20 @@ public class TextQuery {
/** Field names mapped to their search weights */
private Map fieldWeights;
+ /** Field to sort results by */
+ private final String sortBy;
+
+ /** Whether to sort in descending order */
+ private final boolean sortDescending;
+
private TextQuery(Builder builder) {
this.text = builder.text;
this.scorer = builder.scorer;
this.filterExpression = builder.filterExpression;
this.numResults = builder.numResults;
this.fieldWeights = new HashMap<>(builder.fieldWeights);
+ this.sortBy = builder.sortBy;
+ this.sortDescending = builder.sortDescending;
}
/**
@@ -149,13 +165,15 @@ public static Builder builder() {
return new Builder();
}
- /** Builder for TextQuery with support for field weights. */
+ /** Builder for TextQuery with support for field weights and sorting. */
public static class Builder {
private String text;
private String scorer = "BM25STD";
private Filter filterExpression;
private Integer numResults = 10;
private Map fieldWeights = new HashMap<>();
+ private String sortBy;
+ private boolean sortDescending = false;
/**
* Set the text to search for.
@@ -224,6 +242,91 @@ public Builder numResults(int numResults) {
return this;
}
+ /**
+ * Set the sort field (defaults to ascending).
+ *
+ * Python equivalent: sort_by="price"
+ *
+ * @param sortBy Field name to sort by
+ * @return This builder
+ */
+ public Builder sortBy(String sortBy) {
+ this.sortBy = sortBy;
+ this.sortDescending = false; // Default to ascending
+ return this;
+ }
+
+ /**
+ * Set the sort field with explicit direction.
+ *
+ *
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 Builder sortBy(String field, String direction) {
+ List parsed = SortSpec.parseSortSpec(field, direction);
+ SortField sortField = parsed.get(0);
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort field using SortField.
+ *
+ * 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 Builder sortBy(SortField sortField) {
+ if (sortField == null) {
+ throw new IllegalArgumentException("SortField cannot be null");
+ }
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort fields (supports multiple fields, but only first is used).
+ *
+ *
Python equivalent: sort_by=[("price", "ASC"), ("rating", "DESC")]
+ *
+ *
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 Builder sortBy(List sortFields) {
+ List parsed = SortSpec.parseSortSpec(sortFields);
+ if (!parsed.isEmpty()) {
+ SortField firstField = parsed.get(0);
+ this.sortBy = firstField.getFieldName();
+ this.sortDescending = !firstField.isAscending();
+ } else {
+ // Empty list - clear sorting
+ this.sortBy = null;
+ }
+ return this;
+ }
+
+ /**
+ * Set whether to sort in descending order.
+ *
+ * @param descending True for descending sort
+ * @return This builder
+ */
+ public Builder sortDescending(boolean descending) {
+ this.sortDescending = descending;
+ return this;
+ }
+
/**
* Build the TextQuery instance.
*
diff --git a/core/src/main/java/com/redis/vl/query/VectorQuery.java b/core/src/main/java/com/redis/vl/query/VectorQuery.java
index 34e7868..12c636c 100644
--- a/core/src/main/java/com/redis/vl/query/VectorQuery.java
+++ b/core/src/main/java/com/redis/vl/query/VectorQuery.java
@@ -809,13 +809,76 @@ public Builder normalizeVectorDistance(boolean normalize) {
}
/**
- * Set the sort field
+ * Set the sort field (defaults to ascending).
+ *
+ * Python equivalent: sort_by="price"
*
* @param sortBy Sort field name
* @return This builder
*/
public Builder sortBy(String sortBy) {
this.sortBy = sortBy;
+ this.sortDescending = false; // Default to ascending
+ return this;
+ }
+
+ /**
+ * Set the sort field with explicit direction.
+ *
+ *
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 Builder sortBy(String field, String direction) {
+ List parsed = SortSpec.parseSortSpec(field, direction);
+ SortField sortField = parsed.get(0);
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort field using SortField.
+ *
+ * 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 Builder sortBy(SortField sortField) {
+ if (sortField == null) {
+ throw new IllegalArgumentException("SortField cannot be null");
+ }
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort fields (supports multiple fields, but only first is used).
+ *
+ *
Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
+ *
+ *
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 Builder sortBy(List sortFields) {
+ List parsed = SortSpec.parseSortSpec(sortFields);
+ if (!parsed.isEmpty()) {
+ SortField firstField = parsed.get(0);
+ this.sortBy = firstField.getFieldName();
+ this.sortDescending = !firstField.isAscending();
+ } else {
+ // Empty list - clear sorting
+ this.sortBy = null;
+ }
return this;
}
diff --git a/core/src/main/java/com/redis/vl/query/VectorRangeQuery.java b/core/src/main/java/com/redis/vl/query/VectorRangeQuery.java
index ca81da6..03028a3 100644
--- a/core/src/main/java/com/redis/vl/query/VectorRangeQuery.java
+++ b/core/src/main/java/com/redis/vl/query/VectorRangeQuery.java
@@ -359,13 +359,76 @@ public Builder epsilon(double epsilon) {
}
/**
- * Set the field name to sort results by.
+ * Set the field name to sort results by (defaults to ascending).
+ *
+ * Python equivalent: sort_by="price"
*
* @param sortBy Field name for sorting
* @return This builder
*/
public Builder sortBy(String sortBy) {
this.sortBy = sortBy;
+ this.sortDescending = false; // Default to ascending
+ return this;
+ }
+
+ /**
+ * Set the sort field with explicit direction.
+ *
+ *
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 Builder sortBy(String field, String direction) {
+ List parsed = SortSpec.parseSortSpec(field, direction);
+ SortField sortField = parsed.get(0);
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort field using SortField.
+ *
+ * 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 Builder sortBy(SortField sortField) {
+ if (sortField == null) {
+ throw new IllegalArgumentException("SortField cannot be null");
+ }
+ this.sortBy = sortField.getFieldName();
+ this.sortDescending = !sortField.isAscending();
+ return this;
+ }
+
+ /**
+ * Set the sort fields (supports multiple fields, but only first is used).
+ *
+ *
Python equivalent: sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"]
+ *
+ *
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 Builder sortBy(List sortFields) {
+ List parsed = SortSpec.parseSortSpec(sortFields);
+ if (!parsed.isEmpty()) {
+ SortField firstField = parsed.get(0);
+ this.sortBy = firstField.getFieldName();
+ this.sortDescending = !firstField.isAscending();
+ } else {
+ // Empty list - clear sorting
+ this.sortBy = null;
+ }
return this;
}
diff --git a/core/src/test/java/com/redis/vl/query/MultiFieldSortingTest.java b/core/src/test/java/com/redis/vl/query/MultiFieldSortingTest.java
new file mode 100644
index 0000000..3dc791b
--- /dev/null
+++ b/core/src/test/java/com/redis/vl/query/MultiFieldSortingTest.java
@@ -0,0 +1,277 @@
+package com.redis.vl.query;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for multi-field sorting in query classes.
+ *
+ * Ported from Python: tests/unit/test_multi_field_sorting.py
+ */
+@DisplayName("Multi-Field Sorting Tests")
+class MultiFieldSortingTest {
+
+ @Test
+ @DisplayName("FilterQuery: Should accept single field string (backward compatible)")
+ void testFilterQuerySingleFieldString() {
+ // Python: FilterQuery(sort_by="price")
+ FilterQuery query = FilterQuery.builder().sortBy("price").build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ // Default is ascending - Query should be built successfully
+ assertThat(query.buildRedisQuery()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should accept single field with direction tuple")
+ void testFilterQuerySingleFieldWithDirection() {
+ // Python: FilterQuery(sort_by=("price", "DESC"))
+ FilterQuery query = FilterQuery.builder().sortBy("price", "DESC").build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ // Should build Redis query successfully
+ assertThat(query.buildRedisQuery()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should accept single SortField")
+ void testFilterQuerySingleSortField() {
+ // Python: FilterQuery(sort_by=("rating", "DESC"))
+ FilterQuery query = FilterQuery.builder().sortBy(SortField.desc("rating")).build();
+
+ assertThat(query.getSortBy()).isEqualTo("rating");
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should accept multiple fields as list")
+ void testFilterQueryMultipleFields() {
+ // Python: FilterQuery(sort_by=[("price", "DESC"), ("rating", "ASC"), "stock"])
+ List sortFields =
+ List.of(SortField.desc("price"), SortField.asc("rating"), SortField.asc("stock"));
+
+ FilterQuery query = FilterQuery.builder().sortBy(sortFields).build();
+
+ // Only first field is used (Redis limitation)
+ assertThat(query.getSortBy()).isEqualTo("price");
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should log warning for multiple fields")
+ void testFilterQueryMultipleFieldsWarning() {
+ // Python: logs warning "Multiple sort fields specified" and "Using first field: 'price'"
+ // This is tested in integration tests with caplog
+ List sortFields = List.of(SortField.desc("price"), SortField.asc("rating"));
+
+ FilterQuery query = FilterQuery.builder().sortBy(sortFields).build();
+
+ // Should use only first field
+ assertThat(query.getSortBy()).isEqualTo("price");
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should handle empty list gracefully")
+ void testFilterQueryEmptyList() {
+ // Python: FilterQuery(sort_by=[], num_results=10) - handled gracefully
+ FilterQuery query = FilterQuery.builder().sortBy(List.of()).build();
+
+ // Should have no sort field
+ assertThat(query.getSortBy()).isNull();
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Should reject invalid direction")
+ void testFilterQueryInvalidDirection() {
+ // Python: raises ValueError "Sort direction must be 'ASC' or 'DESC'"
+ assertThatThrownBy(() -> FilterQuery.builder().sortBy("price", "INVALID").build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Sort direction must be 'ASC' or 'DESC'");
+ }
+
+ @Test
+ @DisplayName("FilterQuery: Backward compatibility with sortAscending method")
+ void testFilterQueryBackwardCompatibility() {
+ // Python: query.sort_by("price", asc=False)
+ FilterQuery query = FilterQuery.builder().sortBy("price").sortAscending(false).build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ // Should be descending - Query should be built successfully
+ assertThat(query.buildRedisQuery()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("VectorQuery: Should accept single field string")
+ void testVectorQuerySingleFieldString() {
+ // Python: VectorQuery(..., sort_by="price")
+ VectorQuery query =
+ VectorQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy("price")
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ }
+
+ @Test
+ @DisplayName("VectorQuery: Should accept single field with direction")
+ void testVectorQuerySingleFieldWithDirection() {
+ // Python: VectorQuery(..., sort_by=("price", "ASC"))
+ VectorQuery query =
+ VectorQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy("price", "ASC")
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ assertThat(query.isSortDescending()).isFalse();
+ }
+
+ @Test
+ @DisplayName("VectorQuery: Should accept single SortField")
+ void testVectorQuerySingleSortField() {
+ VectorQuery query =
+ VectorQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy(SortField.desc("rating"))
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("rating");
+ assertThat(query.isSortDescending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("VectorQuery: Should accept multiple fields")
+ void testVectorQueryMultipleFields() {
+ // Python: VectorQuery(..., sort_by=[("rating", "DESC"), "price"])
+ List sortFields = List.of(SortField.desc("rating"), SortField.asc("price"));
+
+ VectorQuery query =
+ VectorQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy(sortFields)
+ .build();
+
+ // Only first field is used
+ assertThat(query.getSortBy()).isEqualTo("rating");
+ assertThat(query.isSortDescending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("VectorRangeQuery: Should accept single field string")
+ void testVectorRangeQuerySingleFieldString() {
+ VectorRangeQuery query =
+ VectorRangeQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy("price")
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ }
+
+ @Test
+ @DisplayName("VectorRangeQuery: Should accept single field with direction")
+ void testVectorRangeQuerySingleFieldWithDirection() {
+ VectorRangeQuery query =
+ VectorRangeQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy("price", "DESC")
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ assertThat(query.isSortDescending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("VectorRangeQuery: Should accept single SortField")
+ void testVectorRangeQuerySingleSortField() {
+ VectorRangeQuery query =
+ VectorRangeQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy(SortField.asc("rating"))
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("rating");
+ assertThat(query.isSortDescending()).isFalse();
+ }
+
+ @Test
+ @DisplayName("VectorRangeQuery: Should accept multiple fields")
+ void testVectorRangeQueryMultipleFields() {
+ List sortFields = List.of(SortField.desc("price"), SortField.asc("stock"));
+
+ VectorRangeQuery query =
+ VectorRangeQuery.builder()
+ .field("embedding")
+ .vector(new float[] {0.1f, 0.2f, 0.3f})
+ .sortBy(sortFields)
+ .build();
+
+ // Only first field is used
+ assertThat(query.getSortBy()).isEqualTo("price");
+ assertThat(query.isSortDescending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("TextQuery: Should accept single field string")
+ void testTextQuerySingleFieldString() {
+ // Python: TextQuery(..., sort_by=("price", "DESC"))
+ TextQuery query =
+ TextQuery.builder().text("search query").textField("description").sortBy("price").build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ }
+
+ @Test
+ @DisplayName("TextQuery: Should accept single field with direction")
+ void testTextQuerySingleFieldWithDirection() {
+ TextQuery query =
+ TextQuery.builder()
+ .text("search query")
+ .textField("description")
+ .sortBy("price", "DESC")
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("price");
+ assertThat(query.isSortDescending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("TextQuery: Should accept single SortField")
+ void testTextQuerySingleSortField() {
+ TextQuery query =
+ TextQuery.builder()
+ .text("search query")
+ .textField("description")
+ .sortBy(SortField.asc("rating"))
+ .build();
+
+ assertThat(query.getSortBy()).isEqualTo("rating");
+ assertThat(query.isSortDescending()).isFalse();
+ }
+
+ @Test
+ @DisplayName("TextQuery: Should accept multiple fields")
+ void testTextQueryMultipleFields() {
+ List sortFields = List.of(SortField.asc("price"), SortField.desc("rating"));
+
+ TextQuery query =
+ TextQuery.builder()
+ .text("search query")
+ .textField("description")
+ .sortBy(sortFields)
+ .build();
+
+ // Only first field is used
+ assertThat(query.getSortBy()).isEqualTo("price");
+ assertThat(query.isSortDescending()).isFalse();
+ }
+}
diff --git a/core/src/test/java/com/redis/vl/query/SortSpecTest.java b/core/src/test/java/com/redis/vl/query/SortSpecTest.java
new file mode 100644
index 0000000..32239f5
--- /dev/null
+++ b/core/src/test/java/com/redis/vl/query/SortSpecTest.java
@@ -0,0 +1,144 @@
+package com.redis.vl.query;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for SortSpec utility class.
+ *
+ * Ported from Python: tests/unit/test_multi_field_sorting.py
+ *
+ *
Python reference: SortSpec type alias and _parse_sort_spec() method
+ */
+@DisplayName("SortSpec Tests")
+class SortSpecTest {
+
+ @Test
+ @DisplayName("Should parse single field string (backward compatible)")
+ void testParseSingleFieldString() {
+ // Python: sort_by="price"
+ List result = SortSpec.parseSortSpec("price");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getFieldName()).isEqualTo("price");
+ assertThat(result.get(0).isAscending()).isTrue();
+ assertThat(result.get(0).getDirection()).isEqualTo("ASC");
+ }
+
+ @Test
+ @DisplayName("Should parse single field with ASC direction")
+ void testParseSingleFieldWithAscDirection() {
+ // Python: sort_by=("price", "ASC")
+ List result = SortSpec.parseSortSpec("price", "ASC");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getFieldName()).isEqualTo("price");
+ assertThat(result.get(0).isAscending()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should parse single field with DESC direction")
+ void testParseSingleFieldWithDescDirection() {
+ // Python: sort_by=("rating", "DESC")
+ List result = SortSpec.parseSortSpec("rating", "DESC");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getFieldName()).isEqualTo("rating");
+ assertThat(result.get(0).isAscending()).isFalse();
+ assertThat(result.get(0).getDirection()).isEqualTo("DESC");
+ }
+
+ @Test
+ @DisplayName("Should parse single SortField")
+ void testParseSingleSortField() {
+ SortField field = SortField.desc("price");
+ List result = SortSpec.parseSortSpec(field);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getFieldName()).isEqualTo("price");
+ assertThat(result.get(0).isAscending()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should parse multiple fields as list of SortFields")
+ void testParseMultipleSortFields() {
+ // Python: sort_by=[("price", "DESC"), ("rating", "ASC"), ("age", "DESC")]
+ List fields =
+ List.of(SortField.desc("price"), SortField.asc("rating"), SortField.desc("age"));
+
+ List result = SortSpec.parseSortSpec(fields);
+
+ assertThat(result).hasSize(3);
+ assertThat(result.get(0).getFieldName()).isEqualTo("price");
+ assertThat(result.get(0).isAscending()).isFalse();
+ assertThat(result.get(1).getFieldName()).isEqualTo("rating");
+ assertThat(result.get(1).isAscending()).isTrue();
+ assertThat(result.get(2).getFieldName()).isEqualTo("age");
+ assertThat(result.get(2).isAscending()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should reject invalid sort direction")
+ void testRejectInvalidSortDirection() {
+ // Python: raises ValueError "Sort direction must be 'ASC' or 'DESC'"
+ assertThatThrownBy(() -> SortSpec.parseSortSpec("price", "INVALID"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Sort direction must be 'ASC' or 'DESC'");
+
+ assertThatThrownBy(() -> SortSpec.parseSortSpec("price", "ascending"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Sort direction must be 'ASC' or 'DESC'");
+ }
+
+ @Test
+ @DisplayName("Should handle null field name")
+ void testHandleNullFieldName() {
+ assertThatThrownBy(() -> SortSpec.parseSortSpec((String) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Field name cannot be null or empty");
+ }
+
+ @Test
+ @DisplayName("Should handle empty field name")
+ void testHandleEmptyFieldName() {
+ assertThatThrownBy(() -> SortSpec.parseSortSpec(""))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Field name cannot be null or empty");
+
+ assertThatThrownBy(() -> SortSpec.parseSortSpec(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Field name cannot be null or empty");
+ }
+
+ @Test
+ @DisplayName("Should handle empty list")
+ void testHandleEmptyList() {
+ // Python: empty list is handled gracefully - returns empty list
+ List result = SortSpec.parseSortSpec(List.of());
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should handle null list")
+ void testHandleNullList() {
+ List result = SortSpec.parseSortSpec((List) null);
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should normalize direction strings to uppercase")
+ void testNormalizeDirectionStrings() {
+ // Should accept lowercase and convert to uppercase
+ List result1 = SortSpec.parseSortSpec("price", "asc");
+ assertThat(result1.get(0).getDirection()).isEqualTo("ASC");
+
+ List result2 = SortSpec.parseSortSpec("price", "desc");
+ assertThat(result2.get(0).getDirection()).isEqualTo("DESC");
+
+ List result3 = SortSpec.parseSortSpec("price", "AsC");
+ assertThat(result3.get(0).getDirection()).isEqualTo("ASC");
+ }
+}