fix: restore case-insensitive contains/not contains/not in and consolidate metadata filter pipeline (#15686)

## Summary

This PR fixes case-sensitivity regressions introduced in #15656 and
consolidates the metadata filtering pipeline by removing the duplicate
`applySingleCondition` adapter layer.

### Bug fixes
1. **contains / not contains**: restored case-insensitive matching (was
lost when `applySingleCondition` was replaced by
`common.MetaFilter.matchValue` which lacked `strings.ToLower`)
2. **not in**: restored case-insensitive matching (was lost for same
reason; uses `strings.EqualFold`)
3. **!= with date filter values**: non-date metadata values now
correctly match the `≠` operator (a non-date value IS not equal to any
date, but was returning false)

### Architecture
4. **Removed `applySingleCondition`** (65 lines) — the inline switch was
a duplicate of `common.MetaFilter` logic. `ApplyMetaFilter` now converts
conditions and delegates to `common.MetaFilter` once per filter set,
eliminating ~25 lines of duplicate AND/OR merge logic.
5. **Added `filterSet`** — O(n+m) hash-map fast path for `in`/`not in`
operators, replacing the O(n*m) linear scan in `matchValue`.
6. **Exported `NormalizeOperator`** from `common` for consistent
operator alias handling.

### Cleanup
7. Removed 18 lines of dead code (`matchValue`'s `in`/`not in` branches
already bypassed by `filterOut` delegation)
8. Fixed orphaned godoc comment for `convertOperator`
9. Fixed incorrect `filterSet` doc comment (claimed "matching EqualFold"
but used `strings.ToLower`)
10. Completed `convertToMetaCondition` operator normalization
documentation

### Testing
- 60 tests (24 service + 36 common), all passing
- New tests: `==`, `≠`, `>`, `<`, `≥`, `≤`, `empty`, `not empty` through
`ApplyMetaFilter`
- New tests: `<`, `≤`, `≠` through `MetaFilter`; `not-in-empty-list`
through `filterSet`
- All 18 `MetaFilter` tests pass; all 10 `filterSet` unit tests pass

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jack
2026-06-05 12:47:55 +08:00
committed by GitHub
parent ee32d91aab
commit f6ff862a24
4 changed files with 516 additions and 119 deletions

View File

@@ -49,6 +49,7 @@ var operatorMapping = map[string]string{
">=": "≥",
"<=": "≤",
"!=": "≠",
"==": "=",
}
// ParseAndConvert converts raw API conditions into MetaFilterInput.
@@ -97,7 +98,8 @@ func ParseAndConvert(metadataCondition map[string]interface{}) *MetaFilterInput
}
}
// convertOperator translates Python-style operator to internal symbol.
// convertOperator translates operator aliases to their canonical form.
func convertOperator(op string) string {
if mapped, exists := operatorMapping[op]; exists {
return mapped
@@ -105,6 +107,9 @@ func convertOperator(op string) string {
return op
}
// NormalizeOperator is the exported equivalent of convertOperator.
func NormalizeOperator(op string) string { return convertOperator(op) }
// MetaFilter applies filter conditions against metadata and returns matching doc IDs.
// Python equivalent: common/metadata_utils.py::meta_filter()
func MetaFilter(metas MetaData, input *MetaFilterInput) []string {
@@ -167,7 +172,12 @@ func MetaFilter(metas MetaData, input *MetaFilterInput) []string {
}
// filterOut returns matching doc IDs for a single (value → matchedDocs) map and operator.
// For "in" and "not in", it delegates to filterSet for O(n+m) hash-map-based filtering;
// all other operators use matchValue for per-element predicate evaluation.
func filterOut(v2docs MetaValueDocs, operator string, value interface{}) []string {
if operator == "in" || operator == "not in" {
return filterSet(v2docs, operator, value)
}
var ids []string
for input, docids := range v2docs {
if matchValue(input, operator, value) {
@@ -177,6 +187,56 @@ func filterOut(v2docs MetaValueDocs, operator string, value interface{}) []strin
return ids
}
// filterSet handles "in" and "not in" operators using O(1) hash map lookups.
//
// Instead of the O(n×m) linear scan that matchValue performs for these operators
// (n = distinct metadata values, m = filter list size), filterSet builds a lookup
// map from the filter value list once (O(m)) then tests each metadata entry in
// O(1) time (O(n)), yielding O(n+m) overall.
//
// Case sensitivity follows the same contract as matchValue:
// - "in": case-sensitive (exact match via toString(item) == input)
// - "not in": case-insensitive (strings.ToLower on both sides)
//
// When value is not a []interface{} (should not happen in normal call paths),
// filterSet returns nil — no metadata values match "in", and for "not in" it
// defensively returns nil as well (rather than returning all entries, which could
// silently bypass a misconfigured filter).
func filterSet(v2docs MetaValueDocs, operator string, value interface{}) []string {
list, ok := value.([]interface{})
if !ok {
return nil
}
if operator == "not in" {
// Build case-insensitive exclusion set.
lookup := make(map[string]bool, len(list))
for _, item := range list {
lookup[strings.ToLower(toString(item))] = true
}
var ids []string
for input, docids := range v2docs {
if !lookup[strings.ToLower(input)] {
ids = append(ids, docids...)
}
}
return ids
}
// "in": build case-sensitive inclusion set.
lookup := make(map[string]bool, len(list))
for _, item := range list {
lookup[toString(item)] = true
}
var ids []string
for input, docids := range v2docs {
if lookup[input] {
ids = append(ids, docids...)
}
}
return ids
}
// matchValue checks if a single metadata value matches the operator+value.
func matchValue(input string, operator string, value interface{}) bool {
switch operator {
@@ -190,31 +250,18 @@ func matchValue(input string, operator string, value interface{}) bool {
switch operator {
case "contains":
return strings.Contains(input, valStr)
return strings.Contains(strings.ToLower(input), strings.ToLower(valStr))
case "not contains":
return !strings.Contains(input, valStr)
return !strings.Contains(strings.ToLower(input), strings.ToLower(valStr))
case "start with":
return strings.HasPrefix(strings.ToLower(input), strings.ToLower(valStr))
case "end with":
return strings.HasSuffix(strings.ToLower(input), strings.ToLower(valStr))
case "in":
if list, ok := value.([]interface{}); ok {
for _, item := range list {
if toString(item) == input {
return true
}
}
}
return false
case "not in":
if list, ok := value.([]interface{}); ok {
for _, item := range list {
if toString(item) == input {
return false
}
}
}
return true
// "in" and "not in" are intentionally omitted from matchValue.
// filterOut (line 177) intercepts these operators and delegates
// them to filterSet for O(n+m) hash-map-based filtering, so they
// never reach this function through normal call paths.
}
// Comparison operators: =, ≠, >, <, ≥, ≤
@@ -227,7 +274,7 @@ func compareValues(a, b, operator string) bool {
// Non-date values should not be compared against date filters (matching Python behavior).
if isDate(b) {
if !isDate(a) {
return false
return operator == "≠"
}
return compareString(a, b, operator)
}

View File

@@ -98,6 +98,7 @@ func TestConvertOperator(t *testing.T) {
{"<=", "≤"},
{"!=", "≠"},
{"contains", "contains"},
{"==", "="},
{"start with", "start with"},
}
for _, tt := range tests {
@@ -336,3 +337,193 @@ func TestMetaFilter_EmptyInput(t *testing.T) {
t.Errorf("expected nil, got %v", result)
}
}
// --- filterSet unit tests ---
func TestFilterSet_In_Basic(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}, "C": {"doc3"}}
value := []interface{}{"A", "B"}
result := filterSet(v2docs, "in", value)
if len(result) != 2 {
t.Errorf("expected 2 docs, got %d: %v", len(result), result)
}
}
func TestFilterSet_In_NoMatch(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}}
value := []interface{}{"X"}
result := filterSet(v2docs, "in", value)
if len(result) != 0 {
t.Errorf("expected 0 docs, got %d: %v", len(result), result)
}
}
func TestFilterSet_In_CaseSensitive(t *testing.T) {
v2docs := MetaValueDocs{"ABC": {"doc1"}, "abc": {"doc2"}}
value := []interface{}{"abc"}
result := filterSet(v2docs, "in", value)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected only [doc2] (case-sensitive), got %v", result)
}
}
func TestFilterSet_In_NonListValue(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}}
result := filterSet(v2docs, "in", "not a list")
if result != nil {
t.Errorf("expected nil for non-list value, got %v", result)
}
}
func TestFilterSet_In_EmptyList(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}}
result := filterSet(v2docs, "in", []interface{}{})
if len(result) != 0 {
t.Errorf("expected 0 docs for empty list, got %d", len(result))
}
}
func TestFilterSet_NotIn_Basic(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}, "C": {"doc3"}}
value := []interface{}{"A"}
result := filterSet(v2docs, "not in", value)
if len(result) != 2 {
t.Errorf("expected 2 docs, got %d: %v", len(result), result)
}
}
func TestFilterSet_NotIn_CaseInsensitive(t *testing.T) {
v2docs := MetaValueDocs{"ABC": {"doc1"}, "xyz": {"doc2"}}
value := []interface{}{"abc"}
result := filterSet(v2docs, "not in", value)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected only [doc2] (case-insensitive), got %v", result)
}
}
func TestFilterSet_NotIn_ExcludeAll(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}}
value := []interface{}{"A", "B"}
result := filterSet(v2docs, "not in", value)
if len(result) != 0 {
t.Errorf("expected 0 docs when all excluded, got %d", len(result))
}
}
func TestFilterSet_NotIn_NonListValue(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}}
result := filterSet(v2docs, "not in", "not a list")
if result != nil {
t.Errorf("expected nil for non-list value, got %v", result)
}
}
func TestFilterSet_NotIn_MultipleExcludes(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}, "C": {"doc3"}}
value := []interface{}{"A", "B"}
result := filterSet(v2docs, "not in", value)
if len(result) != 1 || result[0] != "doc3" {
t.Errorf("expected only [doc3], got %v", result)
}
}
// --- MetaFilter integration tests for "in" / "not in" ---
func TestMetaFilter_In(t *testing.T) {
metas := MetaData{
"category": {"A": {"doc1"}, "B": {"doc2"}, "C": {"doc3"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "in", Key: "category", Value: []interface{}{"A", "B"}}},
}
result := MetaFilter(metas, input)
if len(result) != 2 {
t.Errorf("expected 2 docs, got %d: %v", len(result), result)
}
}
func TestMetaFilter_NotIn(t *testing.T) {
metas := MetaData{
"category": {"A": {"doc1"}, "B": {"doc2"}, "C": {"doc3"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "not in", Key: "category", Value: []interface{}{"A"}}},
}
result := MetaFilter(metas, input)
if len(result) != 2 {
t.Errorf("expected 2 docs (B,C), got %d: %v", len(result), result)
}
}
func TestMetaFilter_NotIn_CaseInsensitive(t *testing.T) {
metas := MetaData{
"code": {"ABC": {"doc1"}, "xyz": {"doc2"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "not in", Key: "code", Value: []interface{}{"abc"}}},
}
result := MetaFilter(metas, input)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected only [doc2] (case-insensitive), got %v", result)
}
}
func TestMetaFilter_In_CaseSensitive(t *testing.T) {
metas := MetaData{
"code": {"ABC": {"doc1"}, "abc": {"doc2"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "in", Key: "code", Value: []interface{}{"abc"}}},
}
result := MetaFilter(metas, input)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected only [doc2] (case-sensitive), got %v", result)
}
}
func TestFilterSet_NotIn_EmptyList(t *testing.T) {
v2docs := MetaValueDocs{"A": {"doc1"}, "B": {"doc2"}}
result := filterSet(v2docs, "not in", []interface{}{})
if len(result) != 2 {
t.Errorf("expected all 2 docs for not in empty list, got %d: %v", len(result), result)
}
}
func TestMetaFilter_LessThan(t *testing.T) {
metas := MetaData{
"score": {"85": {"doc1"}, "70": {"doc2"}, "80": {"doc3"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "<", Key: "score", Value: "80"}},
}
result := MetaFilter(metas, input)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected [doc2] for <80, got %v", result)
}
}
func TestMetaFilter_LessThanOrEqual(t *testing.T) {
metas := MetaData{
"score": {"85": {"doc1"}, "70": {"doc2"}, "80": {"doc3"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "≤", Key: "score", Value: "80"}},
}
result := MetaFilter(metas, input)
if len(result) != 2 {
t.Errorf("expected 2 docs for ≤80, got %d: %v", len(result), result)
}
}
func TestMetaFilter_NotEquals(t *testing.T) {
metas := MetaData{
"author": {"Zhang San": {"doc1"}, "Li Si": {"doc2"}},
}
input := &MetaFilterInput{
Conditions: []MetaCondition{{Operator: "≠", Key: "author", Value: "Zhang San"}},
}
result := MetaFilter(metas, input)
if len(result) != 1 || result[0] != "doc2" {
t.Errorf("expected [doc2] for ≠, got %v", result)
}
}