From d90f103d00a504c05825fa1e7741f84bd8f549d8 Mon Sep 17 00:00:00 2001 From: Joel Wetzell Date: Sun, 1 Mar 2026 23:04:04 -0600 Subject: [PATCH] add processor to filter using expr expression --- internal/processor/filter-expr.go | 59 +++++++ internal/processor/test/filter-expr_test.go | 176 ++++++++++++++++++++ schema/processors.schema.json | 22 +++ 3 files changed, 257 insertions(+) create mode 100644 internal/processor/filter-expr.go create mode 100644 internal/processor/test/filter-expr_test.go diff --git a/internal/processor/filter-expr.go b/internal/processor/filter-expr.go new file mode 100644 index 0000000..94269c5 --- /dev/null +++ b/internal/processor/filter-expr.go @@ -0,0 +1,59 @@ +package processor + +import ( + "context" + "fmt" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "github.com/jwetzell/showbridge-go/internal/config" +) + +// NOTE(jwetzell): see language definition https://expr-lang.org/docs/language-definition +type FilterExpr struct { + config config.ProcessorConfig + Program *vm.Program +} + +func (se *FilterExpr) Process(ctx context.Context, payload any) (any, error) { + + output, err := expr.Run(se.Program, payload) + if err != nil { + return nil, err + } + + outputBool, ok := output.(bool) + if !ok { + return nil, fmt.Errorf("filter.expr expression did not return a boolean") + } + if !outputBool { + return nil, nil + } + + return payload, nil +} + +func (se *FilterExpr) Type() string { + return se.config.Type +} + +func init() { + RegisterProcessor(ProcessorRegistration{ + Type: "filter.expr", + New: func(config config.ProcessorConfig) (Processor, error) { + params := config.Params + + expressionString, err := params.GetString("expression") + if err != nil { + return nil, fmt.Errorf("filter.expr expression error: %w", err) + } + + program, err := expr.Compile(expressionString) + if err != nil { + return nil, err + } + + return &FilterExpr{config: config, Program: program}, nil + }, + }) +} diff --git a/internal/processor/test/filter-expr_test.go b/internal/processor/test/filter-expr_test.go new file mode 100644 index 0000000..5d20819 --- /dev/null +++ b/internal/processor/test/filter-expr_test.go @@ -0,0 +1,176 @@ +package processor_test + +import ( + "reflect" + "testing" + + "github.com/jwetzell/showbridge-go/internal/config" + "github.com/jwetzell/showbridge-go/internal/processor" +) + +func TestFilterExprFromRegistry(t *testing.T) { + registration, ok := processor.ProcessorRegistry["filter.expr"] + if !ok { + t.Fatalf("filter.expr processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Type: "filter.expr", + Params: map[string]any{ + "expression": "foo + bar", + }, + }) + if err != nil { + t.Fatalf("failed to create filter.expr processor: %s", err) + } + + if processorInstance.Type() != "filter.expr" { + t.Fatalf("filter.expr processor has wrong type: %s", processorInstance.Type()) + } +} + +func TestGoodFilterExpr(t *testing.T) { + tests := []struct { + name string + params map[string]any + payload any + expected any + }{ + { + name: "number", + params: map[string]any{ + "expression": "Int > 0", + }, + payload: TestStruct{ + Int: 1, + }, + expected: TestStruct{ + Int: 1, + }, + }, + { + name: "string", + params: map[string]any{ + "expression": "String == 'hello'", + }, + payload: TestStruct{ + String: "hello", + }, + expected: TestStruct{ + String: "hello", + }, + }, + { + name: "not matching", + params: map[string]any{ + "expression": "Int > 0", + }, + payload: TestStruct{ + Int: 0, + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registration, ok := processor.ProcessorRegistry["filter.expr"] + if !ok { + t.Fatalf("filter.expr processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Type: "filter.expr", + Params: test.params, + }) + + if err != nil { + t.Fatalf("filter.expr failed to create processor: %s", err) + } + + got, err := processorInstance.Process(t.Context(), test.payload) + + if err != nil { + t.Fatalf("filter.expr failed: %s", err) + } + + //TODO(jwetzell): work out better way to compare the any/any + if !reflect.DeepEqual(got, test.expected) { + t.Fatalf("filter.expr got %+v (%T), expected %+v (%T)", got, got, test.expected, test.expected) + } + }) + } +} + +func TestBadFilterExpr(t *testing.T) { + tests := []struct { + name string + params map[string]any + payload any + errorString string + }{ + { + name: "no expression parameter", + params: map[string]any{ + // no expression parameter + }, + payload: TestStruct{}, + errorString: "filter.expr expression error: not found", + }, + { + name: "non-string expression parameter", + params: map[string]any{ + "expression": 12345, + }, + payload: TestStruct{}, + errorString: "filter.expr expression error: not a string", + }, + { + name: "invalid expression", + params: map[string]any{ + "expression": "foo +", + }, + payload: TestStruct{}, + errorString: "unexpected token EOF (1:5)\n | foo +\n | ....^", + }, + { + name: "accessing missing field", + params: map[string]any{ + "expression": "foo + bar", + }, + payload: map[string]any{ + "foo": 1, + }, + errorString: "invalid operation: int + (1:5)\n | foo + bar\n | ....^", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registration, ok := processor.ProcessorRegistry["filter.expr"] + if !ok { + t.Fatalf("filter.expr processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Type: "filter.expr", + Params: test.params, + }) + if err != nil { + if err.Error() != test.errorString { + t.Fatalf("filter.expr got error '%s', expected '%s'", err.Error(), test.errorString) + } + return + } + got, err := processorInstance.Process(t.Context(), test.payload) + + if err == nil { + t.Fatalf("filter.expr expected to fail but succeeded, got: %v", got) + + } + if err.Error() != test.errorString { + t.Fatalf("filter.expr got error '%s', expected '%s'", err.Error(), test.errorString) + } + }) + } +} diff --git a/schema/processors.schema.json b/schema/processors.schema.json index 41eeb3f..8e8164a 100644 --- a/schema/processors.schema.json +++ b/schema/processors.schema.json @@ -64,6 +64,28 @@ "required": ["type"], "additionalProperties": false }, + { + "type": "object", + "title": "Filter by Expr expression", + "properties": { + "type": { + "type": "string", + "const": "filter.expr" + }, + "params": { + "type": "object", + "properties": { + "expression": { + "type": "string" + } + }, + "required": ["expression"], + "additionalProperties": false + } + }, + "required": ["type", "params"], + "additionalProperties": false + }, { "type": "object", "title": "Parse Float",