From 6c996016ebdb71f57582ac7ee4cf2dba45cb148c Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Wed, 25 Mar 2026 11:11:41 +0800 Subject: [PATCH] fix(filter): register tag_search and property as CEL variables Add missing CEL variable declarations for tag_search and property in the filter environment so the documented filter syntax works. - schema.go: Add tag_search as VirtualAlias for tags, add property as DynType CEL variable - parser.go: Convert VirtualAlias==value to InCondition for JSONList targets, handle property.X selector expressions - render.go: Generalize hardcoded tag check to support any VirtualAlias resolving to JSONList - Add integration tests for tag_search, property, and combined filters Fixes #5761 Signed-off-by: majiayu000 <1835304752@qq.com> --- plugin/filter/parser.go | 39 +++++++++++++++- plugin/filter/render.go | 12 ++--- plugin/filter/schema.go | 8 ++++ store/test/memo_filter_test.go | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/plugin/filter/parser.go b/plugin/filter/parser.go index 36e52d1db..12bdcfb0b 100644 --- a/plugin/filter/parser.go +++ b/plugin/filter/parser.go @@ -36,6 +36,20 @@ func buildCondition(expr *exprv1.Expr, schema Schema) (Condition, error) { return nil, errors.Errorf("identifier %q is not boolean", name) } return &FieldPredicateCondition{Field: name}, nil + case *exprv1.Expr_SelectExpr: + operand := v.SelectExpr.GetOperand() + if ident := operand.GetIdentExpr(); ident != nil && ident.GetName() == "property" { + fieldName := v.SelectExpr.GetField() + field, ok := schema.Field(fieldName) + if !ok { + return nil, errors.Errorf("unknown property field %q", fieldName) + } + if field.Type != FieldTypeBool { + return nil, errors.Errorf("property field %q is not boolean", fieldName) + } + return &FieldPredicateCondition{Field: fieldName}, nil + } + return nil, errors.New("unsupported select expression") case *exprv1.Expr_ComprehensionExpr: return buildComprehensionCondition(v.ComprehensionExpr, schema) default: @@ -131,10 +145,19 @@ func buildComparisonCondition(call *exprv1.Expr_Call, schema Schema) (Condition, return nil, errors.Errorf("unknown identifier %q", field.Name) } if def.Kind == FieldKindVirtualAlias { - def, exists = schema.ResolveAlias(field.Name) - if !exists { + resolved, ok := schema.ResolveAlias(field.Name) + if !ok { return nil, errors.Errorf("invalid alias %q", field.Name) } + // Convert alias == value to InCondition for JSONList targets + // (e.g., tag_search == "work" behaves like tag in ["work"]). + if resolved.Kind == FieldKindJSONList && op == CompareEq { + return &InCondition{ + Left: left, + Values: []ValueExpr{right}, + }, nil + } + def = resolved } if def.AllowedComparisonOps != nil { if _, allowed := def.AllowedComparisonOps[op]; !allowed { @@ -240,6 +263,18 @@ func buildValueExpr(expr *exprv1.Expr, schema Schema) (ValueExpr, error) { return &FieldRef{Name: identName}, nil } + // Handle property.X selector expressions (e.g., property.has_incomplete_tasks). + if sel, ok := expr.ExprKind.(*exprv1.Expr_SelectExpr); ok { + operand := sel.SelectExpr.GetOperand() + if ident := operand.GetIdentExpr(); ident != nil && ident.GetName() == "property" { + fieldName := sel.SelectExpr.GetField() + if _, ok := schema.Field(fieldName); !ok { + return nil, errors.Errorf("unknown property field %q", fieldName) + } + return &FieldRef{Name: fieldName}, nil + } + } + if literal, err := getConstValue(expr); err == nil { return &LiteralValue{Value: literal}, nil } diff --git a/plugin/filter/render.go b/plugin/filter/render.go index c91096a7b..89b1559f9 100644 --- a/plugin/filter/render.go +++ b/plugin/filter/render.go @@ -316,8 +316,10 @@ func (r *renderer) renderInCondition(cond *InCondition) (renderResult, error) { return renderResult{}, errors.New("IN operator requires a field on the left-hand side") } - if fieldRef.Name == "tag" { - return r.renderTagInList(cond.Values) + if def, ok := r.schema.Field(fieldRef.Name); ok && def.Kind == FieldKindVirtualAlias { + if target, resolved := r.schema.ResolveAlias(fieldRef.Name); resolved && target.Kind == FieldKindJSONList { + return r.renderTagInList(fieldRef.Name, cond.Values) + } } field, ok := r.schema.Field(fieldRef.Name) @@ -332,10 +334,10 @@ func (r *renderer) renderInCondition(cond *InCondition) (renderResult, error) { return r.renderScalarInCondition(field, cond.Values) } -func (r *renderer) renderTagInList(values []ValueExpr) (renderResult, error) { - field, ok := r.schema.ResolveAlias("tag") +func (r *renderer) renderTagInList(aliasName string, values []ValueExpr) (renderResult, error) { + field, ok := r.schema.ResolveAlias(aliasName) if !ok { - return renderResult{}, errors.New("tag attribute is not configured") + return renderResult{}, errors.Errorf("%s attribute is not configured", aliasName) } conditions := make([]string, 0, len(values)) diff --git a/plugin/filter/schema.go b/plugin/filter/schema.go index ad70e1a35..9554f08e8 100644 --- a/plugin/filter/schema.go +++ b/plugin/filter/schema.go @@ -195,6 +195,12 @@ func NewSchema() Schema { Type: FieldTypeString, AliasFor: "tags", }, + "tag_search": { + Name: "tag_search", + Kind: FieldKindVirtualAlias, + Type: FieldTypeString, + AliasFor: "tags", + }, "has_task_list": { Name: "has_task_list", Kind: FieldKindJSONBool, @@ -255,6 +261,8 @@ func NewSchema() Schema { cel.Variable("has_link", cel.BoolType), cel.Variable("has_code", cel.BoolType), cel.Variable("has_incomplete_tasks", cel.BoolType), + cel.Variable("tag_search", cel.StringType), + cel.Variable("property", cel.DynType), nowFunction, } diff --git a/store/test/memo_filter_test.go b/store/test/memo_filter_test.go index aaa25488d..aec5ef15e 100644 --- a/store/test/memo_filter_test.go +++ b/store/test/memo_filter_test.go @@ -963,3 +963,85 @@ func TestMemoFilterJSONBooleanLogic(t *testing.T) { memos = tc.ListWithFilter(`has_task_list && !has_link`) require.Len(t, memos, 1, "Should find 1 memo (task only)") } + +// ============================================================================= +// Issue #5761: tag_search and property filter support +// ============================================================================= + +func TestMemoFilterTagSearchEquality(t *testing.T) { + t.Parallel() + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-work", tc.User.ID).Content("Work memo").Tags("work", "important")) + tc.CreateMemo(NewMemoBuilder("memo-personal", tc.User.ID).Content("Personal memo").Tags("personal")) + tc.CreateMemo(NewMemoBuilder("memo-no-tags", tc.User.ID).Content("No tags")) + + // Test: tag_search == "work" should behave like tag in ["work"] + memos := tc.ListWithFilter(`tag_search == "work"`) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Payload.Tags, "work") +} + +func TestMemoFilterPropertyHasIncompleteTasks(t *testing.T) { + t.Parallel() + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-incomplete", tc.User.ID). + Content("Has incomplete tasks"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = true + })) + tc.CreateMemo(NewMemoBuilder("memo-complete", tc.User.ID). + Content("All complete"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = false + })) + + // Test: property.has_incomplete_tasks == true + memos := tc.ListWithFilter(`property.has_incomplete_tasks == true`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasIncompleteTasks) + + // Test: property.has_incomplete_tasks as standalone predicate + memos = tc.ListWithFilter(`property.has_incomplete_tasks`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasIncompleteTasks) +} + +func TestMemoFilterTagSearchAndPropertyCombined(t *testing.T) { + t.Parallel() + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-work-incomplete", tc.User.ID). + Content("Work with tasks"). + Tags("work"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = true + })) + tc.CreateMemo(NewMemoBuilder("memo-work-complete", tc.User.ID). + Content("Work all done"). + Tags("work"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = false + })) + tc.CreateMemo(NewMemoBuilder("memo-personal-incomplete", tc.User.ID). + Content("Personal tasks"). + Tags("personal"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = true + })) + + // Test: exact filter from issue #5761 + memos := tc.ListWithFilter(`tag_search == "work" && property.has_incomplete_tasks == true`) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Payload.Tags, "work") + require.True(t, memos[0].Payload.Property.HasIncompleteTasks) +}