From f6ff862a241f7d17c27582aaa9a6370c9353fe4c Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 5 Jun 2026 12:47:55 +0800 Subject: [PATCH] fix: restore case-insensitive contains/not contains/not in and consolidate metadata filter pipeline (#15686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- internal/common/metadata_utils.go | 91 +++++++--- internal/common/metadata_utils_test.go | 191 ++++++++++++++++++++ internal/service/metadata_filter.go | 135 ++++---------- internal/service/metadata_filter_test.go | 218 +++++++++++++++++++++++ 4 files changed, 516 insertions(+), 119 deletions(-) diff --git a/internal/common/metadata_utils.go b/internal/common/metadata_utils.go index a44b25b75c..24cad1bfc1 100644 --- a/internal/common/metadata_utils.go +++ b/internal/common/metadata_utils.go @@ -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) } diff --git a/internal/common/metadata_utils_test.go b/internal/common/metadata_utils_test.go index 519751bc54..12bb87d36a 100644 --- a/internal/common/metadata_utils_test.go +++ b/internal/common/metadata_utils_test.go @@ -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) + } +} diff --git a/internal/service/metadata_filter.go b/internal/service/metadata_filter.go index 723ffb1b88..d8b8dd161c 100644 --- a/internal/service/metadata_filter.go +++ b/internal/service/metadata_filter.go @@ -199,113 +199,54 @@ func GenMetaFilter(ctx context.Context, chatModel *modelModule.ChatModel, metaDa return &result, nil } -// ApplyMetaFilter applies filter conditions to metadata and returns matching doc IDs +// ApplyMetaFilter applies filter conditions to metadata and returns matching doc IDs. +// It converts service-layer MetaFilterCondition to common.MetaCondition, then delegates +// all conditions and their logic to common.MetaFilter which handles multi-condition +// AND/OR merging internally. This eliminates the duplicate merge logic that previously +// existed between ApplyMetaFilter and common.MetaFilter. func ApplyMetaFilter(metaData common.MetaData, filters []MetaFilterCondition, logic string) []string { if len(filters) == 0 { return []string{} } - docIDSet := make(map[string]bool) - - for i, condition := range filters { - matchingIDs := applySingleCondition(metaData, condition) - if i == 0 { - for _, id := range matchingIDs { - docIDSet[id] = true - } - } else { - if logic == "or" { - // Union - for _, id := range matchingIDs { - docIDSet[id] = true - } - } else { - // AND - intersection - newSet := make(map[string]bool) - for _, id := range matchingIDs { - if docIDSet[id] { - newSet[id] = true - } - } - docIDSet = newSet - } - } + conditions := make([]common.MetaCondition, 0, len(filters)) + for _, f := range filters { + conditions = append(conditions, convertToMetaCondition(f)) } - // Convert to list - result := make([]string, 0, len(docIDSet)) - for id := range docIDSet { - result = append(result, id) - } - return result + return common.MetaFilter(metaData, &common.MetaFilterInput{ + Conditions: conditions, + Logic: logic, + }) } -// applySingleCondition applies a single filter condition and returns matching doc IDs -func applySingleCondition(metaData common.MetaData, condition MetaFilterCondition) []string { - key := condition.Key - value := condition.Value - op := condition.Op - - valueMap := metaData[key] - - var result []string - - switch op { - case "=", "==": - if docIDs, exists := valueMap[value]; exists { - result = append(result, docIDs...) - } - case "!=", "≠": - for val, docIDs := range valueMap { - if val != value { - result = append(result, docIDs...) - } - } - case "contains": - for val, docIDs := range valueMap { - if strings.Contains(strings.ToLower(val), strings.ToLower(value)) { - result = append(result, docIDs...) - } - } - case "not contains": - for val, docIDs := range valueMap { - if !strings.Contains(strings.ToLower(val), strings.ToLower(value)) { - result = append(result, docIDs...) - } - } - case "in": - values := strings.Split(value, ",") - for _, v := range values { - v = strings.TrimSpace(v) - if docIDs, exists := valueMap[v]; exists { - result = append(result, docIDs...) - } - } - case "not in": - excludeValues := make(map[string]bool) - for _, v := range strings.Split(value, ",") { - excludeValues[strings.TrimSpace(strings.ToLower(v))] = true - } - for val, docIDs := range valueMap { - if !excludeValues[strings.ToLower(val)] { - result = append(result, docIDs...) - } - } - case "start with": - for val, docIDs := range valueMap { - if strings.HasPrefix(strings.ToLower(val), strings.ToLower(value)) { - result = append(result, docIDs...) - } - } - case "end with": - for val, docIDs := range valueMap { - if strings.HasSuffix(strings.ToLower(val), strings.ToLower(value)) { - result = append(result, docIDs...) - } - } +// convertToMetaCondition converts a MetaFilterCondition to common.MetaCondition, +// normalizing operator symbols and value types for compatibility with common.MetaFilter. +// +// Operator normalization: +// - "==" = "=" "!=" = "≠" +// - ">=" = "≥" "<=" = "≤" +// - "is" = "=" "not is" = "≠" +// (see common.metadata_utils.operatorMapping for the full list) +// Value conversion: +// - "in" / "not in": comma-separated string → []interface{} (as expected by common.MetaFilter) +// - all other operators: passed through as-is (string) +func convertToMetaCondition(f MetaFilterCondition) common.MetaCondition { + mc := common.MetaCondition{ + Key: f.Key, + Operator: common.NormalizeOperator(f.Op), + Value: f.Value, } - - return result + switch f.Op { + case "in", "not in": + parts := strings.Split(f.Value, ",") + arr := make([]interface{}, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { arr = append(arr, trimmed) } + } + mc.Value = arr + } + return mc } diff --git a/internal/service/metadata_filter_test.go b/internal/service/metadata_filter_test.go index 7919870a4c..cccb067ea4 100644 --- a/internal/service/metadata_filter_test.go +++ b/internal/service/metadata_filter_test.go @@ -154,3 +154,221 @@ func TestApplyMetaFilter_KeyNotFound(t *testing.T) { t.Errorf("expected 0, got %v", result) } } + +func TestApplyMetaFilter_EqualsAlias(t *testing.T) { + metas := common.MetaData{"author": {"Zhang San": {"doc1"}}} + filters := []MetaFilterCondition{{Key: "author", Value: "Zhang San", Op: "=="}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 { + t.Errorf("expected 1 doc for ==, got %d: %v", len(result), result) + } +} + +func TestApplyMetaFilter_NotEqualsAlias(t *testing.T) { + metas := common.MetaData{"author": {"Zhang San": {"doc1"}, "Li Si": {"doc2"}}} + filters := []MetaFilterCondition{{Key: "author", Value: "Zhang San", Op: "≠"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 || result[0] != "doc2" { + t.Errorf("expected [doc2] for ≠, got %v", result) + } +} + +func TestApplyMetaFilter_GreaterThan(t *testing.T) { + metas := common.MetaData{"score": {"85": {"doc1"}, "70": {"doc2"}}} + filters := []MetaFilterCondition{{Key: "score", Value: "80", Op: ">"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 || result[0] != "doc1" { + t.Errorf("expected [doc1] for >80, got %v", result) + } +} + +func TestApplyMetaFilter_LessThan(t *testing.T) { + metas := common.MetaData{"score": {"85": {"doc1"}, "70": {"doc2"}}} + filters := []MetaFilterCondition{{Key: "score", Value: "80", Op: "<"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 || result[0] != "doc2" { + t.Errorf("expected [doc2] for <80, got %v", result) + } +} + +func TestApplyMetaFilter_GreaterThanOrEqual(t *testing.T) { + metas := common.MetaData{"score": {"85": {"doc1"}, "80": {"doc2"}, "70": {"doc3"}}} + filters := []MetaFilterCondition{{Key: "score", Value: "80", Op: "≥"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 2 { + t.Errorf("expected 2 docs for ≥80, got %d: %v", len(result), result) + } +} + +func TestApplyMetaFilter_LessThanOrEqual(t *testing.T) { + metas := common.MetaData{"score": {"85": {"doc1"}, "80": {"doc2"}, "70": {"doc3"}}} + filters := []MetaFilterCondition{{Key: "score", Value: "80", Op: "≤"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 2 { + t.Errorf("expected 2 docs for ≤80, got %d: %v", len(result), result) + } +} + +func TestApplyMetaFilter_Empty(t *testing.T) { + metas := common.MetaData{"status": {"": {"doc1"}, "active": {"doc2"}}} + filters := []MetaFilterCondition{{Key: "status", Value: "", Op: "empty"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 || result[0] != "doc1" { + t.Errorf("expected [doc1] for empty, got %v", result) + } +} + +func TestApplyMetaFilter_NotEmpty(t *testing.T) { + metas := common.MetaData{"status": {"": {"doc1"}, "active": {"doc2"}}} + filters := []MetaFilterCondition{{Key: "status", Value: "", Op: "not empty"}} + result := ApplyMetaFilter(metas, filters, "and") + if len(result) != 1 || result[0] != "doc2" { + t.Errorf("expected [doc2] for not empty, got %v", result) + } +} + +// --- convertToMetaCondition unit tests --- + +func TestConvertToMetaCondition_OperatorNormalization(t *testing.T) { + tests := []struct { + op string + expected string + }{ + {"=", "="}, + {"==", "="}, + {"!=", "≠"}, + {"≠", "≠"}, + {">=", "≥"}, + {"≥", "≥"}, + {"<=", "≤"}, + {"≤", "≤"}, + {"contains", "contains"}, + {"not contains", "not contains"}, + {"in", "in"}, + {"not in", "not in"}, + {"start with", "start with"}, + {"end with", "end with"}, + {"empty", "empty"}, + {"not empty", "not empty"}, + {">", ">"}, + {"<", "<"}, + } + for _, tt := range tests { + f := MetaFilterCondition{Key: "field", Value: "x", Op: tt.op} + mc := convertToMetaCondition(f) + if mc.Operator != tt.expected { + t.Errorf("Op=%q: expected Operator=%q, got %q", tt.op, tt.expected, mc.Operator) + } + if mc.Key != "field" { + t.Errorf("Op=%q: Key changed to %q", tt.op, mc.Key) + } + } +} + +func TestConvertToMetaCondition_InValue(t *testing.T) { + f := MetaFilterCondition{Key: "category", Value: "A,B,C", Op: "in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 3 { + t.Fatalf("expected 3 values, got %d: %v", len(vals), vals) + } + if vals[0] != "A" || vals[1] != "B" || vals[2] != "C" { + t.Errorf("unexpected values: %v", vals) + } +} + +func TestConvertToMetaCondition_NotInValueTrim(t *testing.T) { + f := MetaFilterCondition{Key: "category", Value: " A , B ", Op: "not in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 2 || vals[0] != "A" || vals[1] != "B" { + t.Errorf("expected [A B] after trim, got %v", vals) + } +} + +func TestConvertToMetaCondition_StringValuePassthrough(t *testing.T) { + f := MetaFilterCondition{Key: "author", Value: "Zhang San", Op: "="} + mc := convertToMetaCondition(f) + v, ok := mc.Value.(string) + if !ok { + t.Fatalf("expected string, got %T", mc.Value) + } + if v != "Zhang San" { + t.Errorf("expected 'Zhang San', got %q", v) + } +} + +func TestConvertToMetaCondition_InEmptyParts(t *testing.T) { + f := MetaFilterCondition{Key: "cat", Value: "A,,B", Op: "in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 2 { + t.Errorf("expected 2 values after filtering empty parts, got %d: %v", len(vals), vals) + } + if vals[0] != "A" || vals[1] != "B" { + t.Errorf("expected [A B], got %v", vals) + } +} + +func TestConvertToMetaCondition_InOnlyWhitespaceParts(t *testing.T) { + f := MetaFilterCondition{Key: "cat", Value: "A, , ,B", Op: "in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 2 { + t.Errorf("expected 2 values after filtering whitespace, got %d: %v", len(vals), vals) + } + if vals[0] != "A" || vals[1] != "B" { + t.Errorf("expected [A B], got %v", vals) + } +} + +func TestConvertToMetaCondition_InAllEmptyParts(t *testing.T) { + f := MetaFilterCondition{Key: "cat", Value: ",,,", Op: "in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 0 { + t.Errorf("expected 0 values for all-empty input, got %d: %v", len(vals), vals) + } +} + +func TestConvertToMetaCondition_NotInEmptyParts(t *testing.T) { + f := MetaFilterCondition{Key: "cat", Value: "A,,B", Op: "not in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 2 { + t.Errorf("expected 2 values after filtering empty parts, got %d: %v", len(vals), vals) + } +} + +func TestConvertToMetaCondition_InMixedSpaces(t *testing.T) { + f := MetaFilterCondition{Key: "cat", Value: " A , B , C ", Op: "in"} + mc := convertToMetaCondition(f) + vals, ok := mc.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", mc.Value) + } + if len(vals) != 3 { + t.Errorf("expected 3 values, got %d: %v", len(vals), vals) + } + if vals[0] != "A" || vals[1] != "B" || vals[2] != "C" { + t.Errorf("expected [A B C], got %v", vals) + } +}