Escape LIKE wildcards in Milvus containsString filter (match #5522/#5553) (#5600)

`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) <noreply@anthropic.com>
This commit is contained in:
greymoth 2026-06-26 17:26:40 +09:00 committed by GitHub
parent d2e7af569b
commit e3ded475a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 58 additions and 1 deletions

View File

@ -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) {

View File

@ -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%\"");
}
}