Skip to content

Commit ec4612b

Browse files
committed
feat: add multiple prefix support to SearchIndex.fromExisting()
Port of Python RedisVL multiple prefix support (PR #392, commit c3c1733). Enables SearchIndex.fromExisting() to preserve all prefixes from Redis indices that were created with multiple PREFIX values, not just the first. Implementation handles Union[String, List<String>] prefix type to match Python behavior. Normalizes to first prefix for Redis key construction while preserving all prefixes in schema definition for comparison and serialization. Changes: **IndexSchema.Index:** - Changed `prefix` field from String to Object (supports String or List<String>) - Added `getPrefix()` - normalizes to first prefix for key construction - Added `getPrefixRaw()` - returns raw prefix (String or List) - Added `setPrefix(List<String>)` - overload with single-element normalization - Added package-private `setPrefixRaw(Object)` for builder use - Updated `getIndex()` to preserve raw prefix in defensive copy **IndexSchema.Builder:** - Updated `prefix` field to Object type - Added `prefix(List<String>)` overload with normalization - Updated `build()` to set raw prefix for multi-element lists - Updated `index(Index)` to use getPrefixRaw() **SearchIndex:** - Updated `fromExisting()` to preserve all prefixes - Normalizes single-element prefix lists to strings for backward compatibility - Captures all prefixes when multiple are configured **Tests:** - New IndexSchemaPrefixTest with 6 unit tests - New integration test testFromExistingMultiplePrefixes() - All tests verify backward compatibility and multi-prefix handling Test Coverage: - Single prefix as String (backward compatible) - Single-element prefix list normalized to String - Multiple prefixes preserved as List - getPrefix() returns first prefix for key construction - getPrefixRaw() returns original List or String - Serialization preserves multiple prefixes - Integration test with real Redis index Backward compatibility maintained by normalizing single-element prefix lists to strings when loading from Redis. Ensures schema comparisons work correctly between existing and new index configurations. Python reference: /redis-vl-python/tests/unit/test_convert_index_info.py Python reference: /redis-vl-python/tests/integration/test_search_index.py:172-243 Resolves #258, #392
1 parent 6896339 commit ec4612b

File tree

4 files changed

+305
-12
lines changed

4 files changed

+305
-12
lines changed

core/src/main/java/com/redis/vl/index/SearchIndex.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,13 @@ public static SearchIndex fromExisting(String indexName, UnifiedJedis client) {
376376
@SuppressWarnings("unchecked")
377377
List<String> prefixes = (List<String>) indexDef.get(i + 1);
378378
if (!prefixes.isEmpty()) {
379-
builder.prefix(prefixes.get(0));
379+
// Python port: preserve all prefixes (issue #258/#392)
380+
// Normalize single-element lists to string for backward compatibility
381+
if (prefixes.size() == 1) {
382+
builder.prefix(prefixes.get(0));
383+
} else {
384+
builder.prefix(prefixes);
385+
}
380386
}
381387
} else if ("key_type".equals(key) && i + 1 < indexDef.size()) {
382388
String keyType = indexDef.get(i + 1).toString();

core/src/main/java/com/redis/vl/schema/IndexSchema.java

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,9 @@ private Map<String, Object> toMap() {
472472

473473
Map<String, Object> indexData = new HashMap<>();
474474
indexData.put("name", index.getName());
475-
if (index.getPrefix() != null) {
476-
indexData.put("prefix", index.getPrefix());
475+
if (index.getPrefixRaw() != null) {
476+
// Serialize raw prefix (String or List<String>)
477+
indexData.put("prefix", index.getPrefixRaw());
477478
}
478479
indexData.put("storage_type", index.getStorageType().getValue());
479480
data.put("index", indexData);
@@ -498,7 +499,8 @@ public Index getIndex() {
498499
// Return a new Index with the same values to prevent external modification
499500
Index copy = new Index();
500501
copy.setName(index.getName());
501-
copy.setPrefix(index.getPrefix());
502+
// Use setPrefixRaw to preserve List<String> prefixes
503+
copy.setPrefixRaw(index.getPrefixRaw());
502504
copy.setKeySeparator(index.getKeySeparator());
503505
copy.setStorageType(index.getStorageType());
504506
return copy;
@@ -564,7 +566,13 @@ public String getValue() {
564566
@Getter
565567
public static class Index {
566568
private String name;
567-
private String prefix;
569+
570+
/**
571+
* The prefix(es) used for Redis keys. Can be either a String (single prefix) or List<String>
572+
* (multiple prefixes). Python port: supports Union[str, List[str]] for compatibility.
573+
*/
574+
private Object prefix;
575+
568576
private String keySeparator = ":";
569577
private StorageType storageType = StorageType.HASH;
570578

@@ -599,23 +607,71 @@ public void setName(String name) {
599607
}
600608

601609
/**
602-
* Get the key prefix
610+
* Get the key prefix (normalized).
611+
*
612+
* <p>If multiple prefixes are configured, returns the first one for Redis key construction.
613+
* This matches Python behavior: prefix[0] if isinstance(prefix, list) else prefix.
603614
*
604-
* @return the key prefix
615+
* @return the normalized key prefix (first prefix if multiple), or null if not set
605616
*/
606617
public String getPrefix() {
618+
if (prefix instanceof List) {
619+
@SuppressWarnings("unchecked")
620+
List<String> prefixList = (List<String>) prefix;
621+
return prefixList.isEmpty() ? null : prefixList.get(0);
622+
}
623+
return (String) prefix;
624+
}
625+
626+
/**
627+
* Get the raw prefix value (can be String or List<String>).
628+
*
629+
* <p>This method returns the prefix exactly as stored, without normalization. Use {@link
630+
* #getPrefix()} for the normalized prefix used in key construction.
631+
*
632+
* @return the raw prefix (String or List<String>), or null if not set
633+
*/
634+
public Object getPrefixRaw() {
607635
return prefix;
608636
}
609637

610638
/**
611-
* Set the key prefix
639+
* Set the key prefix (single prefix)
612640
*
613641
* @param prefix the key prefix to set
614642
*/
615643
public void setPrefix(String prefix) {
616644
this.prefix = prefix;
617645
}
618646

647+
/**
648+
* Set multiple key prefixes.
649+
*
650+
* <p>Normalizes single-element lists to strings for backward compatibility. Python port:
651+
* matches behavior in convert_index_info_to_schema.
652+
*
653+
* @param prefixes the list of key prefixes to set
654+
*/
655+
public void setPrefix(List<String> prefixes) {
656+
if (prefixes == null) {
657+
this.prefix = null;
658+
} else if (prefixes.size() == 1) {
659+
// Normalize single-element lists to string for backward compatibility
660+
this.prefix = prefixes.get(0);
661+
} else {
662+
this.prefix = List.copyOf(prefixes); // Defensive copy
663+
}
664+
}
665+
666+
/**
667+
* Set prefix without normalization (package-private for Builder use).
668+
*
669+
* @param prefix the prefix to set (String or List<String>)
670+
*/
671+
void setPrefixRaw(Object prefix) {
672+
this.prefix = prefix;
673+
}
674+
619675
/**
620676
* Get the key separator
621677
*
@@ -673,7 +729,7 @@ public int hashCode() {
673729
public static class Builder {
674730
private final List<BaseField> fields = new ArrayList<>();
675731
private String name;
676-
private String prefix;
732+
private Object prefix; // Can be String or List<String>
677733
private StorageType storageType;
678734

679735
/** Package-private constructor used by builder() and of() factory methods. */
@@ -691,7 +747,7 @@ public Builder name(String name) {
691747
}
692748

693749
/**
694-
* Set the key prefix
750+
* Set the key prefix (single prefix)
695751
*
696752
* @param prefix the key prefix
697753
* @return this builder
@@ -701,6 +757,27 @@ public Builder prefix(String prefix) {
701757
return this;
702758
}
703759

760+
/**
761+
* Set multiple key prefixes.
762+
*
763+
* <p>Normalizes single-element lists to strings for backward compatibility. Python port:
764+
* matches behavior in convert_index_info_to_schema.
765+
*
766+
* @param prefixes the list of key prefixes
767+
* @return this builder
768+
*/
769+
public Builder prefix(List<String> prefixes) {
770+
if (prefixes == null) {
771+
this.prefix = null;
772+
} else if (prefixes.size() == 1) {
773+
// Normalize single-element lists to string for backward compatibility
774+
this.prefix = prefixes.get(0);
775+
} else {
776+
this.prefix = List.copyOf(prefixes); // Defensive copy
777+
}
778+
return this;
779+
}
780+
704781
/**
705782
* Set the key prefix (alias for prefix)
706783
*
@@ -811,7 +888,25 @@ public Builder addVectorField(
811888
* @return the constructed IndexSchema
812889
*/
813890
public IndexSchema build() {
814-
return new IndexSchema(name, prefix, storageType, fields);
891+
// Handle prefix (can be String or List<String>)
892+
String prefixStr = null;
893+
if (prefix instanceof String) {
894+
prefixStr = (String) prefix;
895+
} else if (prefix instanceof List) {
896+
@SuppressWarnings("unchecked")
897+
List<String> prefixList = (List<String>) prefix;
898+
prefixStr = prefixList.isEmpty() ? null : prefixList.get(0);
899+
}
900+
901+
IndexSchema schema = new IndexSchema(name, prefixStr, storageType, fields);
902+
903+
// Set the raw prefix (bypass normalization) for Lists to preserve multi-element lists
904+
// Access the actual index field directly, not the defensive copy from getIndex()
905+
if (prefix instanceof List) {
906+
schema.index.setPrefixRaw(prefix);
907+
}
908+
909+
return schema;
815910
}
816911

817912
/**
@@ -822,7 +917,7 @@ public IndexSchema build() {
822917
*/
823918
public Builder index(Index index) {
824919
this.name = index.getName();
825-
this.prefix = index.getPrefix();
920+
this.prefix = index.getPrefixRaw(); // Use raw prefix to preserve list
826921
this.storageType = index.getStorageType();
827922
return this;
828923
}

core/src/test/java/com/redis/vl/index/SearchIndexIntegrationTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,4 +1016,86 @@ void testFromDictWithUrlAndValidateOnLoad() {
10161016
}
10171017
}
10181018
}
1019+
1020+
/**
1021+
* Port of Python test_search_index_from_existing_multiple_prefixes
1022+
*
1023+
* <p>Python reference: /redis-vl-python/tests/integration/test_search_index.py:172-243
1024+
*
1025+
* <p>Test that from_existing correctly handles indices with multiple prefixes (issue #258/#392).
1026+
*/
1027+
@Test
1028+
@DisplayName("Should handle multiple prefixes in from_existing()")
1029+
void testFromExistingMultiplePrefixes() {
1030+
String indexName = "test_multi_prefix_" + UUID.randomUUID().toString().substring(0, 8);
1031+
1032+
try {
1033+
// Clean up any existing index
1034+
try {
1035+
unifiedJedis.ftDropIndex(indexName);
1036+
} catch (Exception e) {
1037+
// Ignore if doesn't exist
1038+
}
1039+
1040+
// Create index using raw FT.CREATE command with multiple prefixes
1041+
// FT.CREATE index ON HASH PREFIX 3 prefix_a: prefix_b: prefix_c: SCHEMA user TAG text TEXT
1042+
// ...
1043+
unifiedJedis.ftCreate(
1044+
indexName,
1045+
redis.clients.jedis.search.FTCreateParams.createParams()
1046+
.on(redis.clients.jedis.search.IndexDataType.HASH)
1047+
.prefix("prefix_a:", "prefix_b:", "prefix_c:"),
1048+
redis.clients.jedis.search.schemafields.TextField.of("text"),
1049+
redis.clients.jedis.search.schemafields.TagField.of("user"),
1050+
redis.clients.jedis.search.schemafields.VectorField.builder()
1051+
.fieldName("embedding")
1052+
.algorithm(redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm.FLAT)
1053+
.attributes(
1054+
Map.of(
1055+
"TYPE", "FLOAT32",
1056+
"DIM", 3,
1057+
"DISTANCE_METRIC", "COSINE"))
1058+
.build());
1059+
1060+
// Now test from_existing - this is where the bug was
1061+
SearchIndex loadedIndex = SearchIndex.fromExisting(indexName, unifiedJedis);
1062+
1063+
// Verify all prefixes are preserved (this was failing before fix)
1064+
// Before the fix, only "prefix_a:" would be returned
1065+
Object prefixRaw = loadedIndex.getSchema().getIndex().getPrefixRaw();
1066+
assertThat(prefixRaw)
1067+
.as("Multiple prefixes should be preserved when loading existing index")
1068+
.isInstanceOf(List.class);
1069+
1070+
@SuppressWarnings("unchecked")
1071+
List<String> prefixList = (List<String>) prefixRaw;
1072+
assertThat(prefixList).containsExactly("prefix_a:", "prefix_b:", "prefix_c:");
1073+
1074+
// Verify the normalized prefix method returns the first prefix
1075+
assertThat(loadedIndex.getPrefix()).isEqualTo("prefix_a:");
1076+
1077+
// Verify the index name and storage type
1078+
assertThat(loadedIndex.getName()).isEqualTo(indexName);
1079+
assertThat(loadedIndex.getSchema().getIndex().getStorageType())
1080+
.isEqualTo(IndexSchema.StorageType.HASH);
1081+
1082+
// Verify TAG and TEXT fields are present
1083+
assertThat(loadedIndex.getSchema().getField("user")).isNotNull();
1084+
assertThat(loadedIndex.getSchema().getField("text")).isNotNull();
1085+
1086+
// Verify vector field if present
1087+
BaseField embeddingField = loadedIndex.getSchema().getField("embedding");
1088+
if (embeddingField != null) {
1089+
assertThat(embeddingField).isInstanceOf(VectorField.class);
1090+
}
1091+
1092+
} finally {
1093+
// Cleanup
1094+
try {
1095+
unifiedJedis.ftDropIndex(indexName);
1096+
} catch (Exception e) {
1097+
// Ignore cleanup errors
1098+
}
1099+
}
1100+
}
10191101
}

0 commit comments

Comments
 (0)