From e3ded475a993f1557d8165cf34c36b6be4eebd5e Mon Sep 17 00:00:00 2001 From: greymoth Date: Fri, 26 Jun 2026 17:26:40 +0900 Subject: [PATCH] Escape LIKE wildcards in Milvus containsString filter (match #5522/#5553) (#5600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MilvusMetadataFilterMapper.mapContains` builds a `ContainsString` query as `field LIKE "%" + value + "%"` but never escapes the user value's own LIKE wildcards. In Milvus, `%` and `_` are wildcards, so `containsString("50%")` matches "50" + anything and `containsString("a_b")` matches "axb" — but the contract is a literal substring (`ContainsString#test` uses `String#contains`). Parent PR #5577 hardened the same mapper's `formatValue` against string-literal breakout but left the wildcard side undone. The identical defect was already fixed in the Hibernate (#5522) and MongoDB (#5553) mappers; Milvus is the store the sweep missed. The fix adds a `formatLikePattern` that escapes `\`, `%`, `_`, `"` (the existing `formatValue` is left untouched — its backslash-doubling would corrupt `\%`). +4 regression tests. Verified by execution (Java 21): the wildcard cases fail on the old code and pass after; non-wildcard values are byte-identical. Co-authored-by: Claude Opus 4.8 (1M context) --- .../milvus/MilvusMetadataFilterMapper.java | 21 +++++++++- .../MilvusMetadataFilterMapperTest.java | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/langchain4j-milvus/src/main/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapper.java b/langchain4j-milvus/src/main/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapper.java index e772e89143..b2e728eb16 100644 --- a/langchain4j-milvus/src/main/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapper.java +++ b/langchain4j-milvus/src/main/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapper.java @@ -46,10 +46,29 @@ class MilvusMetadataFilterMapper { } private static String mapContains(ContainsString containsString, String metadataFieldName) { + // ContainsString is a literal substring match (see Filter / ContainsString#test, which uses + // String#contains). Milvus LIKE treats % and _ as wildcards, so any % or _ in the user-supplied + // value must be escaped to be matched literally; only the surrounding % we add are real wildcards. return format( "%s LIKE %s", formatKey(containsString.key(), metadataFieldName), - formatValue("%" + containsString.comparisonValue() + "%")); + formatLikePattern(containsString.comparisonValue())); + } + + /** + * Builds a quoted Milvus LIKE pattern that matches the given value as a literal substring. The value's + * own LIKE wildcards ({@code %} and {@code _}) are escaped with a backslash so they are matched + * literally, while the surrounding {@code %} characters remain wildcards for the "contains" semantics. + * Backslash and double quote are escaped as in {@link #formatValue(Object)} so the value stays inside + * the string literal. + */ + private static String formatLikePattern(String value) { + // Escape backslash first, then the LIKE wildcards % and _, then the string-literal double quote. + String escaped = value.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + .replace("\"", "\\\""); + return "\"%" + escaped + "%\""; } private static String mapEqual(IsEqualTo isEqualTo, String metadataFieldName) { diff --git a/langchain4j-milvus/src/test/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapperTest.java b/langchain4j-milvus/src/test/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapperTest.java index db6c90712b..b38efb9bba 100644 --- a/langchain4j-milvus/src/test/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapperTest.java +++ b/langchain4j-milvus/src/test/java/dev/langchain4j/store/embedding/milvus/MilvusMetadataFilterMapperTest.java @@ -43,4 +43,42 @@ class MilvusMetadataFilterMapperTest { assertThat(expr).isEqualTo("metadata[\"key\"] == \"foo\""); } + + @Test + void contains_should_wrap_plain_value_in_like_wildcards() { + Filter filter = metadataKey("key").containsString("foo"); + + String expr = MilvusMetadataFilterMapper.map(filter, "metadata"); + + assertThat(expr).isEqualTo("metadata[\"key\"] LIKE \"%foo%\""); + } + + @Test + void contains_should_escape_percent_wildcard_in_value() { + // "50%" must be matched as a literal substring; the user's '%' must NOT act as a LIKE wildcard. + Filter filter = metadataKey("key").containsString("50%"); + + String expr = MilvusMetadataFilterMapper.map(filter, "metadata"); + + assertThat(expr).isEqualTo("metadata[\"key\"] LIKE \"%50\\%%\""); + } + + @Test + void contains_should_escape_underscore_wildcard_in_value() { + // "a_b" must be matched literally; the user's '_' must NOT act as a single-character wildcard. + Filter filter = metadataKey("key").containsString("a_b"); + + String expr = MilvusMetadataFilterMapper.map(filter, "metadata"); + + assertThat(expr).isEqualTo("metadata[\"key\"] LIKE \"%a\\_b%\""); + } + + @Test + void contains_should_escape_backslash_percent_and_underscore_together() { + Filter filter = metadataKey("key").containsString("a\\b100%_done"); + + String expr = MilvusMetadataFilterMapper.map(filter, "metadata"); + + assertThat(expr).isEqualTo("metadata[\"key\"] LIKE \"%a\\\\b100\\%\\_done%\""); + } }