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>
This commit is contained in:
majiayu000 2026-03-25 11:11:41 +08:00
parent acddef1f3d
commit 6c996016eb
No known key found for this signature in database
GPG Key ID: 29C2AE46ADC3B14B
4 changed files with 134 additions and 7 deletions

View File

@ -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
}

View File

@ -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))

View File

@ -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,
}

View File

@ -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)
}