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: + * + *

+ * + *

Java SortSpec provides overloaded methods: + * + *

+ * + *

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"); + } +}