Skip to content

Commit 3acf928

Browse files
committed
feat: normalize key separators to prevent double separators
Port of Python RedisVL fix for issue #368. Addresses key separator handling when prefix ends with the separator character. Changes: - BaseStorage.createKey(): Strip trailing separators from prefix before concatenating to avoid double separators (e.g., "user::123" becomes "user:123") - SearchIndex.fetch(): Apply same normalization when checking if input is a full key to match new key format - Add KeySeparatorTest with 8 unit tests covering edge cases - Update JsonStorageIntegrationTest to use correct single-separator format This ensures consistent key format regardless of whether the prefix is defined with or without a trailing separator, matching Python behavior where prefix.rstrip(separator) is used.
1 parent 633e603 commit 3acf928

File tree

4 files changed

+174
-8
lines changed

4 files changed

+174
-8
lines changed

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,10 +1148,21 @@ public List<String> load(
11481148
public Map<String, Object> fetch(String idOrKey) {
11491149
// If input already contains the prefix, use it as-is, otherwise construct the key
11501150
String key;
1151-
if (getPrefix() != null
1152-
&& !getPrefix().isEmpty()
1153-
&& idOrKey.startsWith(getPrefix() + getKeySeparator())) {
1154-
key = idOrKey; // Already a full key
1151+
if (getPrefix() != null && !getPrefix().isEmpty()) {
1152+
// Normalize prefix to avoid double separator issues (issue #368)
1153+
String normalizedPrefix = getPrefix();
1154+
String separator = getKeySeparator();
1155+
if (separator != null && !separator.isEmpty() && normalizedPrefix.endsWith(separator)) {
1156+
normalizedPrefix =
1157+
normalizedPrefix.substring(0, normalizedPrefix.length() - separator.length());
1158+
}
1159+
String prefixWithSeparator = normalizedPrefix + separator;
1160+
1161+
if (idOrKey.startsWith(prefixWithSeparator)) {
1162+
key = idOrKey; // Already a full key
1163+
} else {
1164+
key = key(idOrKey); // Just an ID, construct the key
1165+
}
11551166
} else {
11561167
key = key(idOrKey); // Just an ID, construct the key
11571168
}

core/src/main/java/com/redis/vl/storage/BaseStorage.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public BaseStorage(IndexSchema indexSchema) {
4242
/**
4343
* Create a Redis key using a combination of a prefix, separator, and the identifier.
4444
*
45+
* <p>Python port: Normalizes the prefix by removing trailing separators to avoid double
46+
* separators (issue #368). Matches behavior of Python's BaseStorage._key() method.
47+
*
4548
* @param id The unique identifier for the Redis entry
4649
* @param prefix A prefix to append before the key value
4750
* @param keySeparator A separator to insert between prefix and key value
@@ -50,9 +53,24 @@ public BaseStorage(IndexSchema indexSchema) {
5053
protected static String createKey(String id, String prefix, String keySeparator) {
5154
if (prefix == null || prefix.isEmpty()) {
5255
return id;
53-
} else {
54-
return prefix + keySeparator + id;
5556
}
57+
58+
// Normalize prefix by removing trailing separators to avoid doubles (issue #368)
59+
// Python equivalent: prefix.rstrip(key_separator) if key_separator else prefix
60+
String normalizedPrefix = prefix;
61+
if (keySeparator != null && !keySeparator.isEmpty()) {
62+
while (normalizedPrefix.endsWith(keySeparator)) {
63+
normalizedPrefix =
64+
normalizedPrefix.substring(0, normalizedPrefix.length() - keySeparator.length());
65+
}
66+
}
67+
68+
// If normalized prefix is empty, return just the id
69+
if (normalizedPrefix.isEmpty()) {
70+
return keySeparator + id;
71+
}
72+
73+
return normalizedPrefix + keySeparator + id;
5674
}
5775

5876
/**

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@ private static void loadTestData() {
223223
void testDocumentsStored() {
224224
// Check if we can fetch a document
225225
// The key format is prefix + separator + id, where separator is ":"
226-
Map<String, Object> doc = jsonIndex.fetch("json:" + TEST_PREFIX + "::Laptop Pro");
226+
// Note: Prefix already ends with ":", so no double separator (issue #368)
227+
Map<String, Object> doc = jsonIndex.fetch("json:" + TEST_PREFIX + ":Laptop Pro");
227228
assertThat(doc).as("Document should be retrievable").isNotNull();
228229
assertThat(doc.get("title")).isEqualTo("Laptop Pro");
229230

@@ -249,7 +250,8 @@ void testJsonVectorQueryWithJsonPath() {
249250
assertThat(indexInfo).isNotNull();
250251

251252
// Fetch a document to see its structure
252-
String docId = "json:" + TEST_PREFIX + "::Laptop Pro";
253+
// Note: Prefix already ends with ":", so no double separator (issue #368)
254+
String docId = "json:" + TEST_PREFIX + ":Laptop Pro";
253255
Map<String, Object> doc = jsonIndex.fetch(docId);
254256
assertThat(doc).isNotNull();
255257

@@ -598,10 +600,15 @@ void testJsonFetchDocument() {
598600
assertThat(keys).hasSize(1);
599601
String key = keys.get(0);
600602

603+
// Verify the key was written to Redis
604+
boolean keyExists = unifiedJedis.exists(key);
605+
assertThat(keyExists).as("Key should exist in Redis: " + key).isTrue();
606+
601607
// Fetch the document
602608
Map<String, Object> doc = jsonIndex.fetch(key);
603609

604610
// Should preserve nested structure
611+
assertThat(doc).as("Document should be fetched with key: " + key).isNotNull();
605612
assertThat(doc).containsKey("metadata");
606613
assertThat(doc.get("metadata")).isInstanceOf(Map.class);
607614

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.redis.vl.storage;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
8+
/**
9+
* Unit tests for key separator handling in BaseStorage.
10+
*
11+
* <p>Tests the fix for issue #368: Handle key separators correctly to avoid double separators when
12+
* prefix ends with the separator character.
13+
*
14+
* <p>Python port: Ensures BaseStorage.createKey() normalizes prefixes by stripping trailing
15+
* separators, matching the behavior of Python's BaseStorage._key() method.
16+
*/
17+
@DisplayName("Key Separator Handling Tests")
18+
class KeySeparatorTest {
19+
20+
@Test
21+
@DisplayName("Should avoid double separator when prefix ends with separator")
22+
void testPrefixEndingWithSeparator() {
23+
// Issue #368: Prefix ending with separator should not create double separator
24+
String key = BaseStorage.createKey("123", "user:", ":");
25+
assertThat(key)
26+
.isEqualTo("user:123")
27+
.describedAs("Should strip trailing separator from prefix");
28+
29+
key = BaseStorage.createKey("456", "app:", ":");
30+
assertThat(key).isEqualTo("app:456").describedAs("Should strip trailing separator from prefix");
31+
}
32+
33+
@Test
34+
@DisplayName("Should work correctly when prefix does not end with separator")
35+
void testPrefixWithoutTrailingSeparator() {
36+
// Normal case: prefix without trailing separator
37+
String key = BaseStorage.createKey("123", "user", ":");
38+
assertThat(key).isEqualTo("user:123").describedAs("Should add separator between prefix and id");
39+
40+
key = BaseStorage.createKey("456", "app", ":");
41+
assertThat(key).isEqualTo("app:456").describedAs("Should add separator between prefix and id");
42+
}
43+
44+
@Test
45+
@DisplayName("Should handle empty or null prefix")
46+
void testEmptyPrefix() {
47+
// Empty prefix should return just the id
48+
String key = BaseStorage.createKey("123", "", ":");
49+
assertThat(key).isEqualTo("123").describedAs("Empty prefix should return just the id");
50+
51+
// Null prefix should return just the id
52+
key = BaseStorage.createKey("123", null, ":");
53+
assertThat(key).isEqualTo("123").describedAs("Null prefix should return just the id");
54+
}
55+
56+
@Test
57+
@DisplayName("Should handle different separator characters")
58+
void testDifferentSeparators() {
59+
// Test with underscore separator
60+
String key = BaseStorage.createKey("123", "prefix_", "_");
61+
assertThat(key)
62+
.isEqualTo("prefix_123")
63+
.describedAs("Should strip trailing underscore from prefix");
64+
65+
key = BaseStorage.createKey("123", "prefix", "_");
66+
assertThat(key)
67+
.isEqualTo("prefix_123")
68+
.describedAs("Should add underscore between prefix and id");
69+
70+
// Test with dash separator
71+
key = BaseStorage.createKey("123", "prefix-", "-");
72+
assertThat(key).isEqualTo("prefix-123").describedAs("Should strip trailing dash from prefix");
73+
74+
key = BaseStorage.createKey("123", "prefix", "-");
75+
assertThat(key).isEqualTo("prefix-123").describedAs("Should add dash between prefix and id");
76+
}
77+
78+
@Test
79+
@DisplayName("Should handle multi-level prefixes with trailing separator")
80+
void testMultiLevelPrefix() {
81+
// Multi-level prefix ending with separator
82+
String key = BaseStorage.createKey("123", "app:env:service:", ":");
83+
assertThat(key)
84+
.isEqualTo("app:env:service:123")
85+
.describedAs("Should strip trailing separator from multi-level prefix");
86+
87+
// Multi-level prefix without trailing separator
88+
key = BaseStorage.createKey("123", "app:env:service", ":");
89+
assertThat(key)
90+
.isEqualTo("app:env:service:123")
91+
.describedAs("Should add separator to multi-level prefix");
92+
}
93+
94+
@Test
95+
@DisplayName("Should handle null or empty separator")
96+
void testNullOrEmptySeparator() {
97+
// Empty separator should concatenate directly
98+
String key = BaseStorage.createKey("123", "prefix", "");
99+
assertThat(key)
100+
.isEqualTo("prefix123")
101+
.describedAs("Empty separator should concatenate directly");
102+
103+
// Null separator - current behavior might vary, test for safety
104+
// The fix should handle this edge case gracefully
105+
key = BaseStorage.createKey("123", "prefix", null);
106+
// With null separator, we can't strip trailing separator, so just concatenate
107+
assertThat(key).isNotNull().describedAs("Should handle null separator gracefully");
108+
}
109+
110+
@Test
111+
@DisplayName("Should handle prefix that is just the separator")
112+
void testPrefixIsJustSeparator() {
113+
// Edge case: prefix is just the separator character
114+
String key = BaseStorage.createKey("123", ":", ":");
115+
assertThat(key)
116+
.isEqualTo(":123")
117+
.describedAs("Prefix of just separator should normalize to separator + id");
118+
}
119+
120+
@Test
121+
@DisplayName("Should handle multiple trailing separators")
122+
void testMultipleTrailingSeparators() {
123+
// Edge case: prefix ends with multiple separator characters
124+
// Note: Python's rstrip() removes all trailing occurrences
125+
String key = BaseStorage.createKey("123", "prefix:::", ":");
126+
assertThat(key)
127+
.isEqualTo("prefix:123")
128+
.describedAs("Should strip all trailing separators from prefix");
129+
}
130+
}

0 commit comments

Comments
 (0)