mirror of https://github.com/usememos/memos.git
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:
parent
acddef1f3d
commit
6c996016eb
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue