Skip to content

Commit cbf46a2

Browse files
committed
feat(filter): add CEL list comprehension support for tag filtering
Add support for CEL exists() comprehension with startsWith, endsWith, and contains predicates to enable powerful tag filtering patterns. Features: - tags.exists(t, t.startsWith("prefix")) - Match tags by prefix - tags.exists(t, t.endsWith("suffix")) - Match tags by suffix - tags.exists(t, t.contains("substring")) - Match tags by substring - Negation: !tags.exists(...) to exclude matching tags - Works with all operators (AND, OR, NOT) and other filters Implementation: - Added ListComprehensionCondition IR type for comprehension expressions - Parser detects exists() macro and extracts predicates - Renderer generates optimized SQL for SQLite, MySQL, PostgreSQL - Proper NULL/empty array handling across all database dialects - Helper functions reduce code duplication Design decisions: - Only exists() supported (all() rejected at parse time with clear error) - Only simple predicates (matches() excluded to avoid regex complexity) - Fail-fast validation with helpful error messages Tests: - Comprehensive test suite covering all predicates and edge cases - Tests for NULL/empty arrays, combined filters, negation - Real-world use case test for Issue #5480 (archive workflow) - All tests pass on SQLite, MySQL, PostgreSQL Closes #5480
1 parent f600fff commit cbf46a2

File tree

4 files changed

+552
-0
lines changed

4 files changed

+552
-0
lines changed

plugin/filter/ir.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,46 @@ type FunctionValue struct {
114114
}
115115

116116
func (*FunctionValue) isValueExpr() {}
117+
118+
// ListComprehensionCondition represents CEL macros like exists(), all(), filter().
119+
type ListComprehensionCondition struct {
120+
Kind ComprehensionKind
121+
Field string // The list field to iterate over (e.g., "tags")
122+
IterVar string // The iteration variable name (e.g., "t")
123+
Predicate PredicateExpr // The predicate to evaluate for each element
124+
}
125+
126+
func (*ListComprehensionCondition) isCondition() {}
127+
128+
// ComprehensionKind enumerates the types of list comprehensions.
129+
type ComprehensionKind string
130+
131+
const (
132+
ComprehensionExists ComprehensionKind = "exists"
133+
)
134+
135+
// PredicateExpr represents predicates used in comprehensions.
136+
type PredicateExpr interface {
137+
isPredicateExpr()
138+
}
139+
140+
// StartsWithPredicate represents t.startsWith("prefix").
141+
type StartsWithPredicate struct {
142+
Prefix string
143+
}
144+
145+
func (*StartsWithPredicate) isPredicateExpr() {}
146+
147+
// EndsWithPredicate represents t.endsWith("suffix").
148+
type EndsWithPredicate struct {
149+
Suffix string
150+
}
151+
152+
func (*EndsWithPredicate) isPredicateExpr() {}
153+
154+
// ContainsPredicate represents t.contains("substring").
155+
type ContainsPredicate struct {
156+
Substring string
157+
}
158+
159+
func (*ContainsPredicate) isPredicateExpr() {}

plugin/filter/parser.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ func buildCondition(expr *exprv1.Expr, schema Schema) (Condition, error) {
3636
return nil, errors.Errorf("identifier %q is not boolean", name)
3737
}
3838
return &FieldPredicateCondition{Field: name}, nil
39+
case *exprv1.Expr_ComprehensionExpr:
40+
return buildComprehensionCondition(v.ComprehensionExpr, schema)
3941
default:
4042
return nil, errors.New("unsupported top-level expression")
4143
}
@@ -415,3 +417,170 @@ func evaluateNumeric(expr *exprv1.Expr) (int64, bool, error) {
415417
func timeNowUnix() int64 {
416418
return time.Now().Unix()
417419
}
420+
421+
// buildComprehensionCondition handles CEL comprehension expressions (exists, all, etc.).
422+
func buildComprehensionCondition(comp *exprv1.Expr_Comprehension, schema Schema) (Condition, error) {
423+
// Determine the comprehension kind by examining the loop initialization and step
424+
kind, err := detectComprehensionKind(comp)
425+
if err != nil {
426+
return nil, err
427+
}
428+
429+
// Get the field being iterated over
430+
iterRangeIdent := comp.IterRange.GetIdentExpr()
431+
if iterRangeIdent == nil {
432+
return nil, errors.New("comprehension range must be a field identifier")
433+
}
434+
fieldName := iterRangeIdent.GetName()
435+
436+
// Validate the field
437+
field, ok := schema.Field(fieldName)
438+
if !ok {
439+
return nil, errors.Errorf("unknown field %q in comprehension", fieldName)
440+
}
441+
if field.Kind != FieldKindJSONList {
442+
return nil, errors.Errorf("field %q does not support comprehension (must be a list)", fieldName)
443+
}
444+
445+
// Extract the predicate from the loop step
446+
predicate, err := extractPredicate(comp, schema)
447+
if err != nil {
448+
return nil, err
449+
}
450+
451+
return &ListComprehensionCondition{
452+
Kind: kind,
453+
Field: fieldName,
454+
IterVar: comp.IterVar,
455+
Predicate: predicate,
456+
}, nil
457+
}
458+
459+
// detectComprehensionKind determines if this is an exists() macro.
460+
// Only exists() is currently supported.
461+
func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind, error) {
462+
// Check the accumulator initialization
463+
accuInit := comp.AccuInit.GetConstExpr()
464+
if accuInit == nil {
465+
return "", errors.New("comprehension accumulator must be initialized with a constant")
466+
}
467+
468+
// exists() starts with false and uses OR (||) in loop step
469+
if accuInit.GetBoolValue() == false {
470+
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_||_" {
471+
return ComprehensionExists, nil
472+
}
473+
}
474+
475+
// all() starts with true and uses AND (&&) - not supported
476+
if accuInit.GetBoolValue() == true {
477+
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
478+
return "", errors.New("all() comprehension is not supported; use exists() instead")
479+
}
480+
}
481+
482+
return "", errors.New("unsupported comprehension type; only exists() is supported")
483+
}
484+
485+
// extractPredicate extracts the predicate expression from the comprehension loop step.
486+
func extractPredicate(comp *exprv1.Expr_Comprehension, schema Schema) (PredicateExpr, error) {
487+
// The loop step is: @result || predicate(t) for exists
488+
// or: @result && predicate(t) for all
489+
step := comp.LoopStep.GetCallExpr()
490+
if step == nil {
491+
return nil, errors.New("comprehension loop step must be a call expression")
492+
}
493+
494+
if len(step.Args) != 2 {
495+
return nil, errors.New("comprehension loop step must have two arguments")
496+
}
497+
498+
// The predicate is the second argument
499+
predicateExpr := step.Args[1]
500+
predicateCall := predicateExpr.GetCallExpr()
501+
if predicateCall == nil {
502+
return nil, errors.New("comprehension predicate must be a function call")
503+
}
504+
505+
// Handle different predicate functions
506+
switch predicateCall.Function {
507+
case "startsWith":
508+
return buildStartsWithPredicate(predicateCall, comp.IterVar)
509+
case "endsWith":
510+
return buildEndsWithPredicate(predicateCall, comp.IterVar)
511+
case "contains":
512+
return buildContainsPredicate(predicateCall, comp.IterVar)
513+
default:
514+
return nil, errors.Errorf("unsupported predicate function %q in comprehension (supported: startsWith, endsWith, contains)", predicateCall.Function)
515+
}
516+
}
517+
518+
// buildStartsWithPredicate extracts the pattern from t.startsWith("prefix").
519+
func buildStartsWithPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
520+
// Verify the target is the iteration variable
521+
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
522+
return nil, errors.Errorf("startsWith target must be the iteration variable %q", iterVar)
523+
}
524+
525+
if len(call.Args) != 1 {
526+
return nil, errors.New("startsWith expects exactly one argument")
527+
}
528+
529+
prefix, err := getConstValue(call.Args[0])
530+
if err != nil {
531+
return nil, errors.Wrap(err, "startsWith argument must be a constant string")
532+
}
533+
534+
prefixStr, ok := prefix.(string)
535+
if !ok {
536+
return nil, errors.New("startsWith argument must be a string")
537+
}
538+
539+
return &StartsWithPredicate{Prefix: prefixStr}, nil
540+
}
541+
542+
// buildEndsWithPredicate extracts the pattern from t.endsWith("suffix").
543+
func buildEndsWithPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
544+
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
545+
return nil, errors.Errorf("endsWith target must be the iteration variable %q", iterVar)
546+
}
547+
548+
if len(call.Args) != 1 {
549+
return nil, errors.New("endsWith expects exactly one argument")
550+
}
551+
552+
suffix, err := getConstValue(call.Args[0])
553+
if err != nil {
554+
return nil, errors.Wrap(err, "endsWith argument must be a constant string")
555+
}
556+
557+
suffixStr, ok := suffix.(string)
558+
if !ok {
559+
return nil, errors.New("endsWith argument must be a string")
560+
}
561+
562+
return &EndsWithPredicate{Suffix: suffixStr}, nil
563+
}
564+
565+
// buildContainsPredicate extracts the pattern from t.contains("substring").
566+
func buildContainsPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
567+
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
568+
return nil, errors.Errorf("contains target must be the iteration variable %q", iterVar)
569+
}
570+
571+
if len(call.Args) != 1 {
572+
return nil, errors.New("contains expects exactly one argument")
573+
}
574+
575+
substring, err := getConstValue(call.Args[0])
576+
if err != nil {
577+
return nil, errors.Wrap(err, "contains argument must be a constant string")
578+
}
579+
580+
substringStr, ok := substring.(string)
581+
if !ok {
582+
return nil, errors.New("contains argument must be a string")
583+
}
584+
585+
return &ContainsPredicate{Substring: substringStr}, nil
586+
}

plugin/filter/render.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ func (r *renderer) renderCondition(cond Condition) (renderResult, error) {
7474
return r.renderElementInCondition(c)
7575
case *ContainsCondition:
7676
return r.renderContainsCondition(c)
77+
case *ListComprehensionCondition:
78+
return r.renderListComprehension(c)
7779
case *ConstantCondition:
7880
if c.Value {
7981
return renderResult{trivial: true}, nil
@@ -461,6 +463,101 @@ func (r *renderer) renderContainsCondition(cond *ContainsCondition) (renderResul
461463
}
462464
}
463465

466+
func (r *renderer) renderListComprehension(cond *ListComprehensionCondition) (renderResult, error) {
467+
field, ok := r.schema.Field(cond.Field)
468+
if !ok {
469+
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
470+
}
471+
472+
if field.Kind != FieldKindJSONList {
473+
return renderResult{}, errors.Errorf("field %q is not a JSON list", cond.Field)
474+
}
475+
476+
// Render based on predicate type
477+
switch pred := cond.Predicate.(type) {
478+
case *StartsWithPredicate:
479+
return r.renderTagStartsWith(field, pred.Prefix, cond.Kind)
480+
case *EndsWithPredicate:
481+
return r.renderTagEndsWith(field, pred.Suffix, cond.Kind)
482+
case *ContainsPredicate:
483+
return r.renderTagContains(field, pred.Substring, cond.Kind)
484+
default:
485+
return renderResult{}, errors.Errorf("unsupported predicate type %T in comprehension", pred)
486+
}
487+
}
488+
489+
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix"))
490+
func (r *renderer) renderTagStartsWith(field Field, prefix string, _ ComprehensionKind) (renderResult, error) {
491+
arrayExpr := jsonArrayExpr(r.dialect, field)
492+
493+
switch r.dialect {
494+
case DialectSQLite, DialectMySQL:
495+
// Match exact tag or tags with this prefix (hierarchical support)
496+
exactMatch := r.buildJSONArrayLike(arrayExpr, fmt.Sprintf(`%%"%s"%%`, prefix))
497+
prefixMatch := r.buildJSONArrayLike(arrayExpr, fmt.Sprintf(`%%"%s%%`, prefix))
498+
condition := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
499+
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, condition)}, nil
500+
501+
case DialectPostgres:
502+
// Use PostgreSQL's powerful JSON operators
503+
exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", arrayExpr, r.addArg(fmt.Sprintf(`"%s"`, prefix)))
504+
prefixMatch := fmt.Sprintf("(%s)::text LIKE %s", arrayExpr, r.addArg(fmt.Sprintf(`%%"%s%%`, prefix)))
505+
condition := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
506+
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, condition)}, nil
507+
508+
default:
509+
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
510+
}
511+
}
512+
513+
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix"))
514+
func (r *renderer) renderTagEndsWith(field Field, suffix string, _ ComprehensionKind) (renderResult, error) {
515+
arrayExpr := jsonArrayExpr(r.dialect, field)
516+
pattern := fmt.Sprintf(`%%%s"%%`, suffix)
517+
518+
likeExpr := r.buildJSONArrayLike(arrayExpr, pattern)
519+
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
520+
}
521+
522+
// renderTagContains generates SQL for tags.exists(t, t.contains("substring"))
523+
func (r *renderer) renderTagContains(field Field, substring string, _ ComprehensionKind) (renderResult, error) {
524+
arrayExpr := jsonArrayExpr(r.dialect, field)
525+
pattern := fmt.Sprintf(`%%%s%%`, substring)
526+
527+
likeExpr := r.buildJSONArrayLike(arrayExpr, pattern)
528+
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
529+
}
530+
531+
// buildJSONArrayLike builds a LIKE expression for matching within a JSON array.
532+
// Returns the LIKE clause without NULL/empty checks.
533+
func (r *renderer) buildJSONArrayLike(arrayExpr, pattern string) string {
534+
switch r.dialect {
535+
case DialectSQLite, DialectMySQL:
536+
return fmt.Sprintf("%s LIKE %s", arrayExpr, r.addArg(pattern))
537+
case DialectPostgres:
538+
return fmt.Sprintf("(%s)::text LIKE %s", arrayExpr, r.addArg(pattern))
539+
default:
540+
return ""
541+
}
542+
}
543+
544+
// wrapWithNullCheck wraps a condition with NULL and empty array checks.
545+
// This ensures we don't match against NULL or empty JSON arrays.
546+
func (r *renderer) wrapWithNullCheck(arrayExpr, condition string) string {
547+
var nullCheck string
548+
switch r.dialect {
549+
case DialectSQLite:
550+
nullCheck = fmt.Sprintf("%s IS NOT NULL AND %s != '[]'", arrayExpr, arrayExpr)
551+
case DialectMySQL:
552+
nullCheck = fmt.Sprintf("%s IS NOT NULL AND JSON_LENGTH(%s) > 0", arrayExpr, arrayExpr)
553+
case DialectPostgres:
554+
nullCheck = fmt.Sprintf("%s IS NOT NULL AND jsonb_array_length(%s) > 0", arrayExpr, arrayExpr)
555+
default:
556+
return condition
557+
}
558+
return fmt.Sprintf("(%s AND %s)", condition, nullCheck)
559+
}
560+
464561
func (r *renderer) jsonBoolPredicate(field Field) (string, error) {
465562
expr := jsonExtractExpr(r.dialect, field)
466563
switch r.dialect {

0 commit comments

Comments
 (0)