Skip to content

Commit 4602c23

Browse files
committed
Vector store FilterExpressionConverter fix and enhancements
Changes doSingleValue() from concrete to abstract method, forcing explicit handling in all implementations. BREAKING CHANGE: Custom FilterExpressionConverter implementations must now implement doSingleValue() and use appropriate helpers. Signed-off-by: Ilayaperumal Gopinathan <ilayaperumal.gopinathan@broadcom.com>
1 parent f48c538 commit 4602c23

File tree

30 files changed

+989
-155
lines changed

30 files changed

+989
-155
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ build
2525
.gradle
2626
out
2727
*~
28-
2928
/.gradletasknamecache
3029
**/*.flattened-pom.xml
3130

spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,25 @@ If you're using the OpenSearch vector store through Spring AI's `VectorStore` in
441441

442442
Spring AI's internal implementation has been updated to handle these changes automatically.
443443

444+
=== AbstractFilterExpressionConverter: doSingleValue is now abstract
445+
446+
In `AbstractFilterExpressionConverter` (used by vector store filter expression converters), the method `doSingleValue(Object value, StringBuilder context)` has been changed from a concrete method to an abstract method. Custom vector store implementations that extend `AbstractFilterExpressionConverter` must now implement this method explicitly.
447+
448+
==== Impact
449+
450+
* Any custom `FilterExpressionConverter` that extends `AbstractFilterExpressionConverter` and did not override `doSingleValue()` will fail to compile.
451+
* Implementations must convert a single filter value (String, Number, Boolean, Date, etc.) into the target format and append it to the provided `StringBuilder` context.
452+
453+
==== Migration
454+
455+
Implement `doSingleValue(Object value, StringBuilder context)` in your custom converter. You can use the provided static helper methods:
456+
457+
* **JSON-based filters** (e.g. PostgreSQL JSONPath, Neo4j Cypher, Weaviate): use `emitJsonValue(Object value, StringBuilder context)` to serialize values with proper quoting and escaping.
458+
* **Lucene-based filters** (e.g. Elasticsearch, OpenSearch, GemFire): use `emitLuceneString(String value, StringBuilder context)` for string values, and handle other types (numbers, booleans, dates) according to your store's query syntax.
459+
* **Other formats**: implement your own logic and append the result to `context`.
460+
461+
NOTE: The framework normalizes values (e.g. ISO date strings converted to `Date`) before invoking `doSingleValue`, so your implementation receives already-normalized values. The static helper `normalizeDateString(Object)` is available if you need the same normalization when building expressions outside of the standard flow.
462+
444463

445464
[[run-all-m3-migrations]]
446465
=== Running All M3 Migrations at Once

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616

1717
package org.springframework.ai.vectorstore.filter.converter;
1818

19+
import java.time.Instant;
20+
import java.time.ZoneOffset;
21+
import java.time.format.DateTimeFormatter;
22+
import java.time.format.DateTimeParseException;
23+
import java.util.Date;
1924
import java.util.List;
25+
import java.util.regex.Pattern;
26+
27+
import tools.jackson.core.JacksonException;
28+
import tools.jackson.databind.ObjectMapper;
2029

2130
import org.springframework.ai.vectorstore.filter.Filter;
2231
import org.springframework.ai.vectorstore.filter.Filter.Expression;
@@ -37,6 +46,25 @@
3746
*/
3847
public abstract class AbstractFilterExpressionConverter implements FilterExpressionConverter {
3948

49+
/**
50+
* ObjectMapper used for JSON string escaping.
51+
*/
52+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
53+
54+
/**
55+
* Pattern for ISO-8601 date strings in UTC (yyyy-MM-dd'T'HH:mm:ss'Z') used to
56+
* recognize and normalize date strings before passing to converters.
57+
*/
58+
protected static final Pattern ISO_DATE_PATTERN = Pattern
59+
.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?Z");
60+
61+
/**
62+
* Formatter for parsing and normalizing ISO date strings.
63+
*/
64+
protected static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter
65+
.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'")
66+
.withZone(ZoneOffset.UTC);
67+
4068
/**
4169
* Create a new AbstractFilterExpressionConverter.
4270
*/
@@ -127,29 +155,124 @@ protected void doValue(Filter.Value filterValue, StringBuilder context) {
127155
doStartValueRange(filterValue, context);
128156
int c = 0;
129157
for (Object v : list) {
130-
this.doSingleValue(v, context);
158+
this.doSingleValue(normalizeDateString(v), context);
131159
if (c++ < list.size() - 1) {
132160
this.doAddValueRangeSpitter(filterValue, context);
133161
}
134162
}
135163
this.doEndValueRange(filterValue, context);
136164
}
137165
else {
138-
this.doSingleValue(filterValue.value(), context);
166+
this.doSingleValue(normalizeDateString(filterValue.value()), context);
139167
}
140168
}
141169

142170
/**
143-
* Convert the given value into a string representation.
171+
* If the value is a string matching the ISO date pattern, parse and return as
172+
* {@link Date} so that all converters that handle {@code Date} automatically support
173+
* date strings. Otherwise return the value unchanged.
174+
* @param value the value (possibly a date string)
175+
* @return the value, or a {@code Date} if the value was a parseable date string
176+
*/
177+
protected static Object normalizeDateString(Object value) {
178+
if (!(value instanceof String text) || !ISO_DATE_PATTERN.matcher(text).matches()) {
179+
return value;
180+
}
181+
try {
182+
return Date.from(Instant.from(ISO_DATE_FORMATTER.parse(text)));
183+
}
184+
catch (DateTimeParseException e) {
185+
throw new IllegalArgumentException("Invalid date type: " + text, e);
186+
}
187+
}
188+
189+
/**
190+
* Convert the given single value into a string representation and append it to the
191+
* context. This method handles all value types including String, Number, Boolean,
192+
* Date, etc.
193+
* <p>
194+
* For convenience, implementations can use the provided static helper methods such as
195+
* {@link #emitJsonValue(Object, StringBuilder)} for JSON-based filters,
196+
* {@link #emitLuceneString(String, StringBuilder)} for Lucene-based filters, or
197+
* implement their own format-specific escaping logic as needed.
144198
* @param value the value to convert
145199
* @param context the context to append the string representation to
146200
*/
147-
protected void doSingleValue(Object value, StringBuilder context) {
148-
if (value instanceof String) {
149-
context.append(String.format("\"%s\"", value));
201+
protected abstract void doSingleValue(Object value, StringBuilder context);
202+
203+
/**
204+
* Emit a string value formatted for Lucene query syntax by appending escaped
205+
* characters to the provided context. Used by Elasticsearch, OpenSearch, and GemFire
206+
* VectorDB query string filters.
207+
* <p>
208+
* Lucene/Elasticsearch query strings require backslash-escaping of special
209+
* characters: {@code + - = ! ( ) { } [ ] ^ " ~ * ? : \ / & | < >}
210+
* @param value the string value to format
211+
* @param context the context to append the escaped string to
212+
* @see <a href=
213+
* "https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_reserved_characters">Elasticsearch
214+
* Reserved Characters</a>
215+
*/
216+
protected static void emitLuceneString(String value, StringBuilder context) {
217+
for (int i = 0; i < value.length(); i++) {
218+
char c = value.charAt(i);
219+
220+
// Escape Lucene query string special characters
221+
switch (c) {
222+
case '+':
223+
case '-':
224+
case '=':
225+
case '!':
226+
case '(':
227+
case ')':
228+
case '{':
229+
case '}':
230+
case '[':
231+
case ']':
232+
case '^':
233+
case '"':
234+
case '~':
235+
case '*':
236+
case '?':
237+
case ':':
238+
case '\\':
239+
case '/':
240+
case '&':
241+
case '|':
242+
case '<':
243+
case '>':
244+
context.append('\\').append(c);
245+
break;
246+
default:
247+
context.append(c);
248+
break;
249+
}
250+
}
251+
}
252+
253+
/**
254+
* Emit a value formatted as JSON by appending its JSON representation to the provided
255+
* context. Used for PostgreSQL JSONPath, Neo4j Cypher, Weaviate GraphQL, and other
256+
* JSON-based filter expressions.
257+
* <p>
258+
* This method uses Jackson's ObjectMapper to properly serialize all value types:
259+
* <ul>
260+
* <li>Strings: properly quoted and escaped with double quotes, backslashes, and
261+
* control characters handled</li>
262+
* <li>Numbers: formatted without quotes (e.g., 42, 3.14)</li>
263+
* <li>Booleans: formatted as JSON literals {@code true} or {@code false}</li>
264+
* <li>null: formatted as JSON literal {@code null}</li>
265+
* <li>Other types: handled according to Jackson's default serialization</li>
266+
* </ul>
267+
* @param value the value to format (can be any type)
268+
* @param context the context to append the JSON representation to
269+
*/
270+
protected static void emitJsonValue(Object value, StringBuilder context) {
271+
try {
272+
context.append(OBJECT_MAPPER.writeValueAsString(value));
150273
}
151-
else {
152-
context.append(value);
274+
catch (JacksonException e) {
275+
throw new RuntimeException("Error serializing value to JSON.", e);
153276
}
154277
}
155278

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/PineconeFilterExpressionConverter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,9 @@ protected void doKey(Key key, StringBuilder context) {
6262
context.append("\"").append(identifier).append("\": ");
6363
}
6464

65+
@Override
66+
protected void doSingleValue(Object value, StringBuilder context) {
67+
emitJsonValue(value, context);
68+
}
69+
6570
}

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/PrintFilterExpressionConverter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,9 @@ public void doEndGroup(Group group, StringBuilder context) {
5353
context.append(")");
5454
}
5555

56+
@Override
57+
protected void doSingleValue(Object value, StringBuilder context) {
58+
emitJsonValue(value, context);
59+
}
60+
5661
}

spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/SimpleVectorStoreFilterExpressionConverter.java

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,10 @@
1616

1717
package org.springframework.ai.vectorstore.filter.converter;
1818

19-
import java.time.Instant;
2019
import java.time.ZoneOffset;
2120
import java.time.format.DateTimeFormatter;
22-
import java.time.format.DateTimeParseException;
2321
import java.util.Date;
2422
import java.util.List;
25-
import java.util.regex.Pattern;
2623

2724
import org.springframework.ai.vectorstore.filter.Filter;
2825
import org.springframework.ai.vectorstore.filter.Filter.Expression;
@@ -35,8 +32,6 @@
3532
*/
3633
public class SimpleVectorStoreFilterExpressionConverter extends AbstractFilterExpressionConverter {
3734

38-
private static final Pattern DATE_FORMAT_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z");
39-
4035
private final DateTimeFormatter dateFormat;
4136

4237
public SimpleVectorStoreFilterExpressionConverter() {
@@ -83,7 +78,7 @@ protected void doValue(Filter.Value filterValue, StringBuilder context) {
8378
var formattedList = new StringBuilder("{");
8479
int c = 0;
8580
for (Object v : list) {
86-
this.doSingleValue(v, formattedList);
81+
this.doSingleValue(normalizeDateString(v), formattedList);
8782
if (c++ < list.size() - 1) {
8883
this.doAddValueRangeSpitter(filterValue, formattedList);
8984
}
@@ -98,7 +93,7 @@ protected void doValue(Filter.Value filterValue, StringBuilder context) {
9893
}
9994
}
10095
else {
101-
this.doSingleValue(filterValue.value(), context);
96+
this.doSingleValue(normalizeDateString(filterValue.value()), context);
10297
}
10398
}
10499

@@ -114,6 +109,43 @@ private void appendSpELContains(StringBuilder formattedList, StringBuilder conte
114109
context.append(formattedList).append(".contains(").append(metadata).append(")");
115110
}
116111

112+
/**
113+
* Emit a SpEL-formatted string value with single quote wrapping and escaping by
114+
* appending to the provided context.
115+
* <p>
116+
* Escapes single quotes (using backslash) and backslashes (double backslash)
117+
* according to SpEL string literal rules.
118+
* <p>
119+
* This method prevents SpEL injection attacks by properly escaping special
120+
* characters.
121+
* @param text the string value to format
122+
* @param context the context to append the SpEL string literal to
123+
* @since 2.0.0
124+
*/
125+
protected static void emitSpelString(String text, StringBuilder context) {
126+
context.append("'"); // Opening quote
127+
128+
for (int i = 0; i < text.length(); i++) {
129+
char c = text.charAt(i);
130+
131+
switch (c) {
132+
case '\'':
133+
// SpEL: single quote → backslash escaped
134+
context.append("\\'");
135+
break;
136+
case '\\':
137+
// SpEL: backslash → double backslash
138+
context.append("\\\\");
139+
break;
140+
default:
141+
context.append(c);
142+
break;
143+
}
144+
}
145+
146+
context.append("'"); // Closing quote
147+
}
148+
117149
@Override
118150
protected void doSingleValue(Object value, StringBuilder context) {
119151
if (value instanceof Date date) {
@@ -122,20 +154,7 @@ protected void doSingleValue(Object value, StringBuilder context) {
122154
context.append("'");
123155
}
124156
else if (value instanceof String text) {
125-
context.append("'");
126-
if (DATE_FORMAT_PATTERN.matcher(text).matches()) {
127-
try {
128-
Instant date = Instant.from(this.dateFormat.parse(text));
129-
context.append(this.dateFormat.format(date));
130-
}
131-
catch (DateTimeParseException e) {
132-
throw new IllegalArgumentException("Invalid date type:" + text, e);
133-
}
134-
}
135-
else {
136-
context.append(text);
137-
}
138-
context.append("'");
157+
emitSpelString(text, context);
139158
}
140159
else {
141160
context.append(value);

vector-stores/spring-ai-azure-cosmos-db-store/src/main/java/org/springframework/ai/vectorstore/cosmosdb/CosmosDBFilterExpressionConverter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,9 @@ private String getOperationSymbol(Filter.Expression exp) {
130130
};
131131
}
132132

133+
@Override
134+
protected void doSingleValue(Object value, StringBuilder context) {
135+
emitJsonValue(value, context);
136+
}
137+
133138
}

0 commit comments

Comments
 (0)