diff --git a/core/src/test/java/com/redis/vl/query/MultiVectorQueryIntegrationTest.java b/core/src/test/java/com/redis/vl/query/MultiVectorQueryIntegrationTest.java new file mode 100644 index 0000000..083da3f --- /dev/null +++ b/core/src/test/java/com/redis/vl/query/MultiVectorQueryIntegrationTest.java @@ -0,0 +1,276 @@ +package com.redis.vl.query; + +import static org.assertj.core.api.Assertions.*; + +import com.redis.vl.BaseIntegrationTest; +import com.redis.vl.index.SearchIndex; +import com.redis.vl.schema.*; +import java.util.*; +import org.junit.jupiter.api.*; + +/** + * Integration tests for Multi-Vector Query support (#402). + * + *

Tests simultaneous search across multiple vector fields with weighted score combination. + * + *

Python reference: PR #402 - Multi-vector query support + */ +@Tag("integration") +@DisplayName("Multi-Vector Query Integration Tests") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MultiVectorQueryIntegrationTest extends BaseIntegrationTest { + + private static final String INDEX_NAME = "multi_vector_test_idx"; + private static SearchIndex searchIndex; + + @BeforeAll + static void setupIndex() { + // Clean up any existing index + try { + unifiedJedis.ftDropIndex(INDEX_NAME); + } catch (Exception e) { + // Ignore if index doesn't exist + } + + // Create schema with multiple vector fields + IndexSchema schema = + IndexSchema.builder() + .name(INDEX_NAME) + .prefix("product:") + .field(TextField.builder().name("title").build()) + .field(TextField.builder().name("description").build()) + .field(TagField.builder().name("category").build()) + .field(NumericField.builder().name("price").sortable(true).build()) + // Text embeddings (3 dimensions) + .field( + VectorField.builder() + .name("text_embedding") + .dimensions(3) + .distanceMetric(VectorField.DistanceMetric.COSINE) + .build()) + // Image embeddings (2 dimensions) + .field( + VectorField.builder() + .name("image_embedding") + .dimensions(2) + .distanceMetric(VectorField.DistanceMetric.COSINE) + .build()) + .build(); + + searchIndex = new SearchIndex(schema, unifiedJedis); + searchIndex.create(); + + // Insert test documents with multiple vector embeddings + Map doc1 = new HashMap<>(); + doc1.put("id", "1"); + doc1.put("title", "Red Laptop"); + doc1.put("description", "Premium laptop"); + doc1.put("category", "electronics"); + doc1.put("price", 1200); + doc1.put("text_embedding", new float[] {0.1f, 0.2f, 0.3f}); + doc1.put("image_embedding", new float[] {0.5f, 0.5f}); + + Map doc2 = new HashMap<>(); + doc2.put("id", "2"); + doc2.put("title", "Blue Phone"); + doc2.put("description", "Budget smartphone"); + doc2.put("category", "electronics"); + doc2.put("price", 300); + doc2.put("text_embedding", new float[] {0.4f, 0.5f, 0.6f}); + doc2.put("image_embedding", new float[] {0.3f, 0.4f}); + + Map doc3 = new HashMap<>(); + doc3.put("id", "3"); + doc3.put("title", "Green Tablet"); + doc3.put("description", "Mid-range tablet"); + doc3.put("category", "electronics"); + doc3.put("price", 500); + doc3.put("text_embedding", new float[] {0.7f, 0.8f, 0.9f}); + doc3.put("image_embedding", new float[] {0.1f, 0.2f}); + + // Load all documents + searchIndex.load(Arrays.asList(doc1, doc2, doc3), "id"); + + // Wait for indexing + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @AfterAll + static void cleanupIndex() { + if (searchIndex != null) { + try { + searchIndex.drop(); + } catch (Exception e) { + // Ignore + } + } + } + + @Test + @Order(1) + @DisplayName("Should create multi-vector query with single vector") + void testSingleVectorQuery() { + Vector textVec = + Vector.builder() + .vector(new float[] {0.1f, 0.2f, 0.3f}) + .fieldName("text_embedding") + .dtype("float32") + .weight(1.0) + .build(); + + MultiVectorQuery query = MultiVectorQuery.builder().vector(textVec).numResults(10).build(); + + assertThat(query.getVectors()).hasSize(1); + assertThat(query.getNumResults()).isEqualTo(10); + + Map params = query.toParams(); + assertThat(params).containsKey("vector_0"); + assertThat(params.get("vector_0")).isInstanceOf(byte[].class); + } + + @Test + @Order(2) + @DisplayName("Should create multi-vector query with multiple vectors") + void testMultipleVectorsQuery() { + Vector textVec = + Vector.builder() + .vector(new float[] {0.1f, 0.2f, 0.3f}) + .fieldName("text_embedding") + .weight(0.7) + .build(); + + Vector imageVec = + Vector.builder() + .vector(new float[] {0.5f, 0.5f}) + .fieldName("image_embedding") + .weight(0.3) + .build(); + + MultiVectorQuery query = + MultiVectorQuery.builder().vectors(textVec, imageVec).numResults(10).build(); + + assertThat(query.getVectors()).hasSize(2); + + // Verify params + Map params = query.toParams(); + assertThat(params).containsKeys("vector_0", "vector_1"); + + // Verify query string format + String queryString = query.toQueryString(); + assertThat(queryString) + .contains("@text_embedding:[VECTOR_RANGE 2.0 $vector_0]") + .contains("@image_embedding:[VECTOR_RANGE 2.0 $vector_1]") + .contains(" | "); + + // Verify scoring + String formula = query.getScoringFormula(); + assertThat(formula).contains("0.70 * score_0").contains("0.30 * score_1"); + } + + @Test + @Order(3) + @DisplayName("Should combine multi-vector query with filter expression") + void testMultiVectorQueryWithFilter() { + Vector textVec = + Vector.builder().vector(new float[] {0.1f, 0.2f, 0.3f}).fieldName("text_embedding").build(); + + Filter filter = Filter.tag("category", "electronics"); + + MultiVectorQuery query = + MultiVectorQuery.builder().vector(textVec).filterExpression(filter).numResults(5).build(); + + String queryString = query.toQueryString(); + assertThat(queryString).contains(" AND ").contains("@category:{electronics}"); + } + + @Test + @Order(4) + @DisplayName("Should calculate score from multiple vectors with different weights") + void testWeightedScoringCalculation() { + Vector v1 = + Vector.builder() + .vector(new float[] {0.1f, 0.2f, 0.3f}) + .fieldName("text_embedding") + .weight(0.6) + .build(); + + Vector v2 = + Vector.builder() + .vector(new float[] {0.5f, 0.5f}) + .fieldName("image_embedding") + .weight(0.4) + .build(); + + MultiVectorQuery query = MultiVectorQuery.builder().vectors(v1, v2).build(); + + // Verify individual score calculations + Map calculations = query.getScoreCalculations(); + assertThat(calculations).hasSize(2); + assertThat(calculations.get("score_0")).isEqualTo("(2 - distance_0)/2"); + assertThat(calculations.get("score_1")).isEqualTo("(2 - distance_1)/2"); + + // Verify combined scoring formula + String formula = query.getScoringFormula(); + assertThat(formula).isEqualTo("0.60 * score_0 + 0.40 * score_1"); + } + + @Test + @Order(5) + @DisplayName("Should support different vector dimensions and dtypes") + void testDifferentDimensionsAndDtypes() { + Vector v1 = + Vector.builder() + .vector(new float[] {0.1f, 0.2f, 0.3f}) // 3 dimensions + .fieldName("text_embedding") + .dtype("float32") + .weight(0.5) + .build(); + + Vector v2 = + Vector.builder() + .vector(new float[] {0.5f, 0.5f}) // 2 dimensions + .fieldName("image_embedding") + .dtype("float32") + .weight(0.5) + .build(); + + MultiVectorQuery query = MultiVectorQuery.builder().vectors(v1, v2).build(); + + assertThat(query.getVectors().get(0).getVector()).hasSize(3); + assertThat(query.getVectors().get(1).getVector()).hasSize(2); + } + + @Test + @Order(6) + @DisplayName("Should specify return fields") + void testReturnFields() { + Vector textVec = + Vector.builder().vector(new float[] {0.1f, 0.2f, 0.3f}).fieldName("text_embedding").build(); + + MultiVectorQuery query = + MultiVectorQuery.builder() + .vector(textVec) + .returnFields("title", "price", "category") + .build(); + + assertThat(query.getReturnFields()).containsExactly("title", "price", "category"); + } + + @Test + @Order(7) + @DisplayName("Should use VECTOR_RANGE with threshold 2.0") + void testVectorRangeThreshold() { + Vector textVec = + Vector.builder().vector(new float[] {0.1f, 0.2f, 0.3f}).fieldName("text_embedding").build(); + + MultiVectorQuery query = MultiVectorQuery.builder().vector(textVec).build(); + + String queryString = query.toQueryString(); + // Distance threshold hardcoded at 2.0 to include all eligible documents + assertThat(queryString).contains("VECTOR_RANGE 2.0"); + } +} diff --git a/core/src/test/java/com/redis/vl/query/QuerySortingIntegrationTest.java b/core/src/test/java/com/redis/vl/query/QuerySortingIntegrationTest.java index 5ee7064..2567c49 100644 --- a/core/src/test/java/com/redis/vl/query/QuerySortingIntegrationTest.java +++ b/core/src/test/java/com/redis/vl/query/QuerySortingIntegrationTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; /** @@ -24,6 +25,7 @@ * *

Python reference: /redis-vl-python/tests/integration/test_query.py */ +@Tag("integration") @DisplayName("Query Sorting Integration Tests") class QuerySortingIntegrationTest extends BaseIntegrationTest { @@ -284,6 +286,136 @@ void testSortFilterQueryAlreadyWorks() { } } + /** Test multi-field sorting with FilterQuery (only first field used - Redis limitation) */ + @Test + void testMultiFieldSortingFilterQuery() { + // Specify multiple sort fields - only first should be used (Redis limitation) + List sortFields = List.of(SortField.desc("age"), SortField.asc("credit_score")); + + FilterQuery query = + FilterQuery.builder() + .filterExpression(Filter.tag("credit_score", "high")) + .returnFields(List.of("user", "age", "credit_score")) + .sortBy(sortFields) + .build(); + + // Should use only the first field (age DESC) + assertThat(query.getSortBy()).isEqualTo("age"); + + List> results = index.query(query); + + // Verify results are sorted by age in descending order (first field) + for (int i = 0; i < results.size() - 1; i++) { + int currentAge = getIntValue(results.get(i), "age"); + int nextAge = getIntValue(results.get(i + 1), "age"); + assertThat(currentAge) + .as("Age at position %d should be >= age at position %d (DESC)", i, i + 1) + .isGreaterThanOrEqualTo(nextAge); + } + + // First result should be oldest + assertThat(results.get(0).get("user")).isEqualTo("tyler"); + } + + /** Test multi-field sorting with VectorQuery (only first field used) */ + @Test + void testMultiFieldSortingVectorQuery() { + // Multiple sort fields - only first is used + List sortFields = List.of(SortField.asc("age"), SortField.desc("credit_score")); + + VectorQuery query = + VectorQuery.builder() + .field("user_embedding") + .vector(new float[] {0.1f, 0.1f, 0.5f}) + .returnFields(List.of("user", "age", "credit_score")) + .sortBy(sortFields) + .build(); + + // Only first field should be used + assertThat(query.getSortBy()).isEqualTo("age"); + assertThat(query.isSortDescending()).isFalse(); + + List> results = index.query(query); + + // Verify sorted by first field (age ASC) + for (int i = 0; i < results.size() - 1; i++) { + int currentAge = getIntValue(results.get(i), "age"); + int nextAge = getIntValue(results.get(i + 1), "age"); + assertThat(currentAge).isLessThanOrEqualTo(nextAge); + } + + // First result should be youngest + assertThat(results.get(0).get("user")).isEqualTo("tim"); + } + + /** Test multi-field sorting with VectorRangeQuery (only first field used) */ + @Test + void testMultiFieldSortingVectorRangeQuery() { + List sortFields = List.of(SortField.desc("age"), SortField.asc("user")); + + VectorRangeQuery query = + VectorRangeQuery.builder() + .field("user_embedding") + .vector(new float[] {0.1f, 0.1f, 0.5f}) + .distanceThreshold(1.0f) + .returnFields(List.of("user", "age")) + .sortBy(sortFields) + .build(); + + // Only first field used + assertThat(query.getSortBy()).isEqualTo("age"); + assertThat(query.isSortDescending()).isTrue(); + + List> results = index.query(query); + assertThat(results).hasSizeGreaterThan(0); + + // Sorted by age DESC (first field) + for (int i = 0; i < results.size() - 1; i++) { + int currentAge = getIntValue(results.get(i), "age"); + int nextAge = getIntValue(results.get(i + 1), "age"); + assertThat(currentAge).isGreaterThanOrEqualTo(nextAge); + } + } + + /** Test multi-field sorting with TextQuery (only first field used) */ + @Test + void testMultiFieldSortingTextQuery() { + List sortFields = List.of(SortField.asc("age"), SortField.desc("credit_score")); + + TextQuery query = + TextQuery.builder().text("engineer").textField("job").sortBy(sortFields).build(); + + // Only first field used + assertThat(query.getSortBy()).isEqualTo("age"); + assertThat(query.isSortDescending()).isFalse(); + + List> results = index.query(query); + assertThat(results).hasSizeGreaterThan(0); + + // Sorted by age ASC (first field) + for (int i = 0; i < results.size() - 1; i++) { + int currentAge = getIntValue(results.get(i), "age"); + int nextAge = getIntValue(results.get(i + 1), "age"); + assertThat(currentAge).isLessThanOrEqualTo(nextAge); + } + } + + /** Test that empty sort list is handled gracefully */ + @Test + void testEmptyMultiFieldSort() { + FilterQuery query = + FilterQuery.builder() + .returnFields(List.of("user", "age")) + .sortBy(List.of()) // Empty list + .build(); + + assertThat(query.getSortBy()).isNull(); + + // Should still execute without sorting + List> results = index.query(query); + assertThat(results).hasSizeGreaterThan(0); + } + // Helper method for type conversion (Hash storage returns strings) private int getIntValue(Map map, String key) { Object value = map.get(key); diff --git a/core/src/test/java/com/redis/vl/schema/UnfNoindexIntegrationTest.java b/core/src/test/java/com/redis/vl/schema/UnfNoindexIntegrationTest.java new file mode 100644 index 0000000..263e06e --- /dev/null +++ b/core/src/test/java/com/redis/vl/schema/UnfNoindexIntegrationTest.java @@ -0,0 +1,400 @@ +package com.redis.vl.schema; + +import static org.assertj.core.api.Assertions.*; + +import com.redis.vl.BaseIntegrationTest; +import com.redis.vl.index.SearchIndex; +import java.util.*; +import org.junit.jupiter.api.*; +import redis.clients.jedis.args.SortingOrder; +import redis.clients.jedis.search.SearchResult; + +/** + * Integration tests for UNF (un-normalized form) and NOINDEX field attributes (#374). + * + *

Tests field attributes for controlling sorting normalization and indexing behavior. + * + *

Python reference: PR #386 - UNF/NOINDEX support + */ +@Tag("integration") +@DisplayName("UNF/NOINDEX Field Attributes Integration Tests") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class UnfNoindexIntegrationTest extends BaseIntegrationTest { + + private static final String INDEX_NAME = "unf_noindex_test_idx"; + private static SearchIndex searchIndex; + + @BeforeAll + static void setupIndex() { + // Clean up any existing index + try { + unifiedJedis.ftDropIndex(INDEX_NAME); + } catch (Exception e) { + // Ignore if index doesn't exist + } + + // Create schema with UNF and NOINDEX fields + IndexSchema schema = + IndexSchema.builder() + .name(INDEX_NAME) + .prefix("product:") + // Regular sortable text field (normalized) + .field(TextField.builder().name("name").sortable(true).build()) + // UNF sortable text field (un-normalized, preserves case) + .field(TextField.builder().name("brand").sortable(true).unf(true).build()) + // NOINDEX sortable text field (not searchable, but sortable) + .field(TextField.builder().name("sku").sortable(true).indexed(false).build()) + // Regular sortable numeric field + .field(NumericField.builder().name("price").sortable(true).build()) + // UNF numeric field (flag stored, but Jedis limitation) + .field(NumericField.builder().name("stock").sortable(true).unf(true).build()) + // Regular indexed text field for search + .field(TextField.builder().name("description").build()) + .build(); + + searchIndex = new SearchIndex(schema, unifiedJedis); + searchIndex.create(); + + // Insert test documents with mixed case data + Map doc1 = new HashMap<>(); + doc1.put("id", "1"); + doc1.put("name", "apple laptop"); + doc1.put("brand", "Apple"); + doc1.put("sku", "SKU-001"); + doc1.put("price", 1200); + doc1.put("stock", 50); + doc1.put("description", "Premium laptop"); + + Map doc2 = new HashMap<>(); + doc2.put("id", "2"); + doc2.put("name", "banana phone"); + doc2.put("brand", "banana"); + doc2.put("sku", "SKU-002"); + doc2.put("price", 800); + doc2.put("stock", 30); + doc2.put("description", "Budget phone"); + + Map doc3 = new HashMap<>(); + doc3.put("id", "3"); + doc3.put("name", "cherry tablet"); + doc3.put("brand", "CHERRY"); + doc3.put("sku", "SKU-003"); + doc3.put("price", 500); + doc3.put("stock", 20); + doc3.put("description", "Mid-range tablet"); + + Map doc4 = new HashMap<>(); + doc4.put("id", "4"); + doc4.put("name", "date watch"); + doc4.put("brand", "Date"); + doc4.put("sku", "SKU-004"); + doc4.put("price", 300); + doc4.put("stock", 10); + doc4.put("description", "Smart watch"); + + // Load all documents + searchIndex.load(Arrays.asList(doc1, doc2, doc3, doc4), "id"); + + // Wait for indexing + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @AfterAll + static void cleanupIndex() { + if (searchIndex != null) { + try { + searchIndex.drop(); + } catch (Exception e) { + // Ignore + } + } + } + + @Test + @Order(1) + @DisplayName("Should create fields with UNF attribute") + void testUnfFieldCreation() { + IndexSchema schema = searchIndex.getSchema(); + + // TextField with UNF + TextField brandField = + (TextField) + schema.getFields().stream().filter(f -> f.getName().equals("brand")).findFirst().get(); + assertThat(brandField.isUnf()).isTrue(); + assertThat(brandField.isSortable()).isTrue(); + + // NumericField with UNF (flag stored despite Jedis limitation) + NumericField stockField = + (NumericField) + schema.getFields().stream().filter(f -> f.getName().equals("stock")).findFirst().get(); + assertThat(stockField.isUnf()).isTrue(); + assertThat(stockField.isSortable()).isTrue(); + } + + @Test + @Order(2) + @DisplayName("Should sort by regular field with case normalization") + void testRegularSortableCaseNormalization() { + // Query sorted by 'name' (regular sortable, normalized) + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("name", SortingOrder.ASC) + .limit(0, 10)); + + assertThat(result.getTotalResults()).isEqualTo(4); + + // With normalization, all names are treated lowercase + // Expected order: "apple laptop" < "banana phone" < "cherry tablet" < "date watch" + List names = new ArrayList<>(); + result + .getDocuments() + .forEach( + doc -> { + names.add(doc.getString("name")); + }); + + assertThat(names) + .containsExactly("apple laptop", "banana phone", "cherry tablet", "date watch"); + } + + @Test + @Order(3) + @DisplayName("Should sort by UNF field preserving original case") + void testUnfSortablePreservesCase() { + // Query sorted by 'brand' (UNF sortable, case-preserved) + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("brand", SortingOrder.ASC) + .limit(0, 10)); + + assertThat(result.getTotalResults()).isEqualTo(4); + + // With UNF, case is preserved in sorting + // Expected order: "Apple" < "CHERRY" < "Date" < "banana" + // (uppercase letters sort before lowercase in ASCII) + List brands = new ArrayList<>(); + result + .getDocuments() + .forEach( + doc -> { + brands.add(doc.getString("brand")); + }); + + // ASCII order: 'A' (65) < 'C' (67) < 'D' (68) < 'b' (98) + assertThat(brands).containsExactly("Apple", "CHERRY", "Date", "banana"); + } + + @Test + @Order(4) + @DisplayName("Should allow sorting by numeric field") + void testNumericSortable() { + // Query sorted by 'price' (regular numeric sortable) + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("price", SortingOrder.ASC) + .limit(0, 10)); + + assertThat(result.getTotalResults()).isEqualTo(4); + + List prices = new ArrayList<>(); + result + .getDocuments() + .forEach( + doc -> { + prices.add(Double.parseDouble(doc.getString("price"))); + }); + + assertThat(prices).containsExactly(300.0, 500.0, 800.0, 1200.0); + } + + @Test + @Order(5) + @DisplayName("Should allow sorting by UNF numeric field (Jedis limitation noted)") + void testUnfNumericSortable() { + // Query sorted by 'stock' (UNF numeric sortable, but Jedis doesn't support sortableUNF for + // numeric) + // This test documents current behavior with Jedis limitation + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("stock", SortingOrder.ASC) + .limit(0, 10)); + + assertThat(result.getTotalResults()).isEqualTo(4); + + List stock = new ArrayList<>(); + result + .getDocuments() + .forEach( + doc -> { + stock.add(Double.parseDouble(doc.getString("stock"))); + }); + + // Even though UNF is set, numeric fields sort normally + // (Jedis doesn't have sortableUNF() for NumericField yet) + assertThat(stock).containsExactly(10.0, 20.0, 30.0, 50.0); + } + + @Test + @Order(6) + @DisplayName("NOINDEX field should not be searchable but should be retrievable") + void testNoindexFieldNotSearchable() { + // Try to search by 'sku' field (NOINDEX, should not match) + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, "@sku:SKU-001", redis.clients.jedis.search.FTSearchParams.searchParams()); + + // NOINDEX field should not be searchable + assertThat(result.getTotalResults()).isEqualTo(0); + } + + @Test + @Order(7) + @DisplayName("NOINDEX field should be retrievable in results") + void testNoindexFieldRetrievable() { + // Search by indexed field, but retrieve NOINDEX field + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "@description:laptop", + redis.clients.jedis.search.FTSearchParams.searchParams()); + + assertThat(result.getTotalResults()).isEqualTo(1); + + // NOINDEX field should still be retrievable + redis.clients.jedis.search.Document doc = result.getDocuments().get(0); + assertThat(doc.getString("sku")).isEqualTo("SKU-001"); + } + + @Test + @Order(8) + @DisplayName("NOINDEX field should be sortable") + void testNoindexFieldSortable() { + // Query sorted by 'sku' (NOINDEX but sortable) + SearchResult result = + unifiedJedis.ftSearch( + INDEX_NAME, + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("sku", SortingOrder.ASC) + .limit(0, 10)); + + assertThat(result.getTotalResults()).isEqualTo(4); + + List skus = new ArrayList<>(); + result + .getDocuments() + .forEach( + doc -> { + skus.add(doc.getString("sku")); + }); + + // Should sort correctly even though not indexed + assertThat(skus).containsExactly("SKU-001", "SKU-002", "SKU-003", "SKU-004"); + } + + @Test + @Order(9) + @DisplayName("UNF should only apply when sortable is true") + void testUnfRequiresSortable() { + // Create a field with UNF but not sortable + TextField field = TextField.builder().name("test").unf(true).build(); + + // UNF flag is set but field is not sortable + assertThat(field.isUnf()).isTrue(); + assertThat(field.isSortable()).isFalse(); + + // When converted to Jedis field, UNF should be ignored (no sortable) + redis.clients.jedis.search.schemafields.SchemaField jedisField = field.toJedisSchemaField(); + assertThat(jedisField).isNotNull(); + // Field should not be sortable, so UNF has no effect + } + + @Test + @Order(10) + @DisplayName("Should support combined UNF and NOINDEX attributes") + void testUnfAndNoindexCombined() { + // Clean up existing index + try { + unifiedJedis.ftDropIndex("combined_test_idx"); + } catch (Exception e) { + // Ignore + } + + // Create field with both UNF and NOINDEX + IndexSchema schema = + IndexSchema.builder() + .name("combined_test_idx") + .prefix("test:") + .field(TextField.builder().name("code").sortable(true).unf(true).indexed(false).build()) + .field(TextField.builder().name("description").build()) + .build(); + + SearchIndex testIndex = new SearchIndex(schema, unifiedJedis); + testIndex.create(); + + try { + // Insert test data + Map doc1 = new HashMap<>(); + doc1.put("id", "1"); + doc1.put("code", "Alpha"); + doc1.put("description", "First"); + + Map doc2 = new HashMap<>(); + doc2.put("id", "2"); + doc2.put("code", "beta"); + doc2.put("description", "Second"); + + // Load documents + testIndex.load(Arrays.asList(doc1, doc2), "id"); + + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Field should be sortable with UNF (case preserved) + SearchResult result = + unifiedJedis.ftSearch( + "combined_test_idx", + "*", + redis.clients.jedis.search.FTSearchParams.searchParams() + .sortBy("code", SortingOrder.ASC)); + + assertThat(result.getTotalResults()).isEqualTo(2); + + List codes = new ArrayList<>(); + result.getDocuments().forEach(doc -> codes.add(doc.getString("code"))); + + // UNF preserves case: 'A' (65) < 'b' (98) + assertThat(codes).containsExactly("Alpha", "beta"); + + // Field should not be searchable (NOINDEX) + SearchResult searchResult = + unifiedJedis.ftSearch( + "combined_test_idx", + "@code:Alpha", + redis.clients.jedis.search.FTSearchParams.searchParams()); + assertThat(searchResult.getTotalResults()).isEqualTo(0); + + } finally { + testIndex.drop(); + } + } +}