mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user