add processor to filter using expr expression

This commit is contained in:
Joel Wetzell
2026-03-01 23:04:04 -06:00
parent 05b0de1dfd
commit d90f103d00
3 changed files with 257 additions and 0 deletions

View File

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

View File

@@ -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 + <nil> (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)
}
})
}
}

View File

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