mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
test: add raw-string LIKE escape-model coverage (#50843)
## Summary This PR now contains **only supplementary test coverage** for the `LIKE` escape model — no production code. The production fix it originally carried (`scanLikePattern` in `internal/parser/planparserv2/pattern_match.go`, aligning the Go optimizer with the C++ canonical matcher in `RegexQuery.cpp`) **already landed in master via #50845**, which was stacked on this PR. After rebasing onto master that production commit is dropped as already-upstream, so the diff here reduces to tests only. `git diff master` touches exactly three files, all tests: | file | layer | |---|---| | `internal/parser/planparserv2/plan_parser_v2_test.go` | Go optimizer (parser-level) | | `internal/core/src/common/RegexQueryUtilTest.cpp` | C++ executor / canonical matcher | | `tests/python_client/.../test_milvus_client_scalar_filtering.py` | Python e2e | ## What the tests add Now that raw string literals `r"..."` exist (#50845), the escape tests read with a **single** backslash instead of the doubled/quadrupled backslashes the normal string-literal layer forced — exercising the raw-string path end to end. **Go — `TestExpr_RawString_LikeEscapeModel`** drives the optimizer end-to-end through `r"..."` LIKE patterns and asserts the lowered op + literal operand: - escaped wildcard → literal byte (`r"a\%bc"` → `Equal "a%bc"`); - a literal `%` coexisting with an **unescaped** `%` boundary (`r"abc\%def%"` → `PrefixMatch "abc%def"`, `r"%abc\%def%"` → `InnerMatch "abc%def"`); - `\\` collapsing to one literal `\`; - a dangling trailing backslash — un-expressible as a raw string (parse error), and `OpType_Match` fallback via a normal string. **C++ — `RegexQueryUtilTest.cpp`** keeps the canonical-matcher escape tests (literal `%`/`_` matched verbatim, multi-char decoys, long-span positives). Raw strings are a Go-parser-only feature, so the C++ executor tests use plan-level inputs. **Python e2e** — the escaped-wildcard `LIKE` expressions and `test_like_escaped_wildcard_is_literal` now use `r"..."` (one backslash); the eval oracle gains a raw-`LIKE` branch that re-quotes the verbatim raw content via `repr()` so it stays in lock-step with the server. A raw-string dangling-backslash case (rejected at lex time) is added; the matcher-level dangling case stays a normal string since a raw string cannot end in an odd number of backslashes. ## Verification ``` go test -tags dynamic,test -gcflags="all=-N -l" -count=1 ./internal/parser/planparserv2/ ``` planparserv2 package passes; `gofmt` clean; Python `ruff check` / `ruff format` clean; the oracle's raw-`LIKE` branch verified to reproduce the documented expected result sets. issue: #43864 --------- Signed-off-by: xiaofanluan <xf@hjjaq.com> Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com> Co-authored-by: xiaofanluan <xf@hjjaq.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
xiaofanluan
Claude Opus 4.8
parent
8764b71bba
commit
3353b53de7
@@ -2721,6 +2721,56 @@ TEST(InvalidUTF8ConsistencyTest, InvalidContinuationByte) {
|
||||
<< "RE2/Like must agree on invalid continuation byte";
|
||||
}
|
||||
|
||||
// ============== Literal-wildcard operand Match Tests ==============
|
||||
// The LIKE optimizer (Go side) unescapes "\%" / "\_" into LITERAL bytes when it
|
||||
// lowers a pattern to Equal/Prefix/Postfix/InnerMatch, so the operand handed to
|
||||
// these executors can itself CONTAIN a '%' or '_'. They must be compared
|
||||
// verbatim as bytes — never re-interpreted as wildcards. (issue #43864)
|
||||
TEST(LiteralWildcardMatch, OperandContainsLiteralWildcardByte) {
|
||||
using namespace milvus;
|
||||
|
||||
// InnerMatch: operand "abc%" is a literal substring needle.
|
||||
EXPECT_TRUE(InnerMatch("xxabc%yy", "abc%"));
|
||||
EXPECT_FALSE(InnerMatch("xxabcZyy", "abc%"))
|
||||
<< "literal '%' in the operand must not act as a wildcard";
|
||||
EXPECT_FALSE(InnerMatch("abcdef", "abc%"));
|
||||
|
||||
// PrefixMatch: operand "abc%" is a literal prefix.
|
||||
EXPECT_TRUE(PrefixMatch("abc%def", "abc%"));
|
||||
EXPECT_FALSE(PrefixMatch("abcXdef", "abc%"));
|
||||
|
||||
// PostfixMatch: operand "abc%" is a literal suffix.
|
||||
EXPECT_TRUE(PostfixMatch("xyzabc%", "abc%"));
|
||||
EXPECT_FALSE(PostfixMatch("xyzabcX", "abc%"));
|
||||
|
||||
// A literal underscore must not act as the single-char wildcard either.
|
||||
EXPECT_TRUE(InnerMatch("aa_bb", "a_b"));
|
||||
EXPECT_FALSE(InnerMatch("aaXbb", "a_b"));
|
||||
EXPECT_TRUE(PrefixMatch("a_bc", "a_b"));
|
||||
EXPECT_FALSE(PrefixMatch("aXbc", "a_b"));
|
||||
|
||||
// Stronger decoys: a literal '%' must not behave like a MULTI-char wildcard
|
||||
// ('.*') either. Each value would match if the operand's '%' were treated
|
||||
// as a wildcard spanning several bytes, but it must stay a literal byte.
|
||||
EXPECT_FALSE(InnerMatch("xxabcZZZZyy", "abc%"));
|
||||
EXPECT_FALSE(PrefixMatch("abcXYZdef", "abc%"));
|
||||
EXPECT_FALSE(PostfixMatch("xyzabcXYZ", "abc%"));
|
||||
|
||||
// The UNescaped '%' boundary — realized as the optimized Prefix/Postfix/
|
||||
// Inner op — DOES match an arbitrarily long span on the wildcard side.
|
||||
// (operand has no literal wildcard; long surroundings prove '%' is '.*').
|
||||
const std::string longx(1000, 'x');
|
||||
const std::string longy(1000, 'y');
|
||||
EXPECT_TRUE(InnerMatch(longx + "abc" + longy, "abc")); // from "%abc%"
|
||||
EXPECT_TRUE(PrefixMatch("abc" + longy, "abc")); // from "abc%"
|
||||
EXPECT_TRUE(PostfixMatch(longx + "abc", "abc")); // from "%abc"
|
||||
|
||||
// Combined: a literal '%' in the operand is matched verbatim while the
|
||||
// wildcard boundary still spans the long surrounding text.
|
||||
EXPECT_TRUE(InnerMatch(longx + "abc%" + longy, "abc%"));
|
||||
EXPECT_FALSE(InnerMatch(longx + "abcZ" + longy, "abc%"));
|
||||
}
|
||||
|
||||
// ============== NUL-aware Prefix/Postfix/Inner Match Tests ==============
|
||||
// Verify that PrefixMatch, PostfixMatch, and InnerMatch use length-aware
|
||||
// byte comparison (memcmp) instead of C-string semantics (strncmp),
|
||||
|
||||
@@ -414,6 +414,60 @@ func TestExpr_RawString(t *testing.T) {
|
||||
assert.Equal(t, []string{`\u0041`}, jSq.GetVectorAnns().GetPredicates().GetUnaryRangeExpr().GetColumnInfo().GetNestedPath())
|
||||
}
|
||||
|
||||
// TestExpr_RawString_LikeEscapeModel exercises the LIKE escape model (issue
|
||||
// #43864) end-to-end through the raw-string literal r"...". Because a raw string
|
||||
// drops the string-literal Unquote layer, a backslash reaches the LIKE pattern
|
||||
// layer verbatim, so these read with the same single backslash the C++ canonical
|
||||
// matcher (RegexQuery.cpp) uses — no doubled/quadrupled backslashes. Each case
|
||||
// asserts the optimized op and the literal operand the executor must match
|
||||
// verbatim.
|
||||
func TestExpr_RawString_LikeEscapeModel(t *testing.T) {
|
||||
schema := newTestSchema(true)
|
||||
helper, err := typeutil.CreateSchemaHelper(schema)
|
||||
assert.NoError(t, err)
|
||||
|
||||
check := func(expr string, wantOp planpb.OpType, wantVal string) {
|
||||
plan, err := CreateSearchPlan(helper, expr, "FloatVectorField", &planpb.QueryInfo{}, nil, nil)
|
||||
assert.NoError(t, err, expr)
|
||||
assert.NotNil(t, plan, expr)
|
||||
ur := plan.GetVectorAnns().GetPredicates().GetUnaryRangeExpr()
|
||||
assert.Equal(t, wantOp, ur.GetOp(), expr)
|
||||
assert.Equal(t, wantVal, ur.GetValue().GetStringVal(), expr)
|
||||
}
|
||||
|
||||
// An escaped wildcard is a LITERAL byte in the operand: the optimized op
|
||||
// carries a literal '%'/'_', which the C++ side matches verbatim and must NOT
|
||||
// re-interpret as a wildcard.
|
||||
check(`A like r"a\%bc"`, planpb.OpType_Equal, `a%bc`)
|
||||
check(`A like r"a\_bc"`, planpb.OpType_Equal, `a_bc`)
|
||||
check(`A like r"abc\%%"`, planpb.OpType_PrefixMatch, `abc%`)
|
||||
check(`A like r"%abc\%"`, planpb.OpType_PostfixMatch, `abc%`)
|
||||
check(`A like r"%abc\%%"`, planpb.OpType_InnerMatch, `abc%`)
|
||||
|
||||
// A literal '\%' and an UNescaped '%' coexist: the literal lands in the
|
||||
// operand verbatim, while the bare '%' is the prefix/postfix/inner boundary
|
||||
// the C++ matcher expands to an ANY-length span.
|
||||
check(`A like r"abc\%def%"`, planpb.OpType_PrefixMatch, `abc%def`)
|
||||
check(`A like r"%abc\%def"`, planpb.OpType_PostfixMatch, `abc%def`)
|
||||
check(`A like r"%abc\%def%"`, planpb.OpType_InnerMatch, `abc%def`)
|
||||
|
||||
// A backslash escapes ANY next byte, not only wildcards: r"\a" -> literal "a".
|
||||
check(`A like r"\a"`, planpb.OpType_Equal, `a`)
|
||||
|
||||
// A raw "\\" collapses to one literal backslash at the pattern layer.
|
||||
check(`A like r"a\\b"`, planpb.OpType_Equal, `a\b`)
|
||||
check(`A like r"%a\\b%"`, planpb.OpType_InnerMatch, `a\b`)
|
||||
|
||||
// A dangling trailing backslash cannot be written as a raw string at all — a
|
||||
// raw string may not end in an odd number of backslashes (it would not
|
||||
// terminate). So the unterminated raw form is a parse error, and the literal
|
||||
// trailing-backslash pattern can only be expressed via a normal string, where
|
||||
// it is not optimizable and falls back to OpType_Match (the C++ matcher then
|
||||
// raises ExprInvalid at execution).
|
||||
assertInvalidExpr(t, helper, `A like r"abc\"`)
|
||||
check(`A like "abc\\"`, planpb.OpType_Match, `abc\`)
|
||||
}
|
||||
|
||||
func TestExpr_RegexMatch(t *testing.T) {
|
||||
schema := newTestSchema(true)
|
||||
helper, err := typeutil.CreateSchemaHelper(schema)
|
||||
|
||||
+709
-293
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user