Skip to content

Instantly share code, notes, and snippets.

@rkbalgi
Last active July 11, 2024 12:41
Show Gist options
  • Save rkbalgi/3d15b0c40afcb34067adb890632366c0 to your computer and use it in GitHub Desktop.
Save rkbalgi/3d15b0c40afcb34067adb890632366c0 to your computer and use it in GitHub Desktop.
Using custom types with cel-go
package test
//A very simple example of using user defined structures with instance functions with cel-go
import (
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/interpreter/functions"
"google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"log"
"reflect"
"testing"
)
//our custom type
type Test struct {
a, b int
}
//operations supported by the custom type
func (t *Test) Add() int {
return t.a + t.b
}
func (t *Test) Subtract() int {
return t.a - t.b
}
//the CEL type to represent Test
var TestType = types.NewTypeValue("Test", traits.ReceiverType)
func (t Test) ConvertToNative(typeDesc reflect.Type) (interface{}, error) {
panic("not required")
}
func (t Test) ConvertToType(typeVal ref.Type) ref.Val {
panic("not required")
}
func (t Test) Equal(other ref.Val) ref.Val {
o, ok := other.Value().(Test)
if ok {
if o == t {
return types.Bool(true)
} else {
return types.Bool(false)
}
} else {
return types.ValOrErr(other, "%v is not of type Test", other)
}
}
func (t Test) Type() ref.Type {
return TestType
}
func (t Test) Value() interface{} {
return t
}
func (t Test) Receive(function string, overload string, args []ref.Val) ref.Val {
if function == "Add" {
return types.Int(t.Add())
} else if function == "Subtract" {
return types.Int(t.Subtract())
}
return types.ValOrErr(TestType, "no such function - %s", function)
}
func (t *Test) HasTrait(trait int) bool {
return trait == traits.ReceiverType
}
func (t *Test) TypeName() string {
return TestType.TypeName()
}
type customTypeAdapter struct {
}
func (customTypeAdapter) NativeToValue(value interface{}) ref.Val {
val, ok := value.(Test)
if ok {
return val
} else {
//let the default adapter handle other cases
return types.DefaultTypeAdapter.NativeToValue(value)
}
}
func TestExprEval_CelGo(t *testing.T) {
env, err := cel.NewEnv(cel.CustomTypeAdapter(&customTypeAdapter{}),
cel.Declarations(
decls.NewIdent("test", decls.NewObjectType("Test"), nil),
decls.NewFunction("MulBy3",
decls.NewOverload("mulby3_int", []*expr.Type{decls.Int}, decls.Int)),
decls.NewFunction("Add",
decls.NewInstanceOverload("test_add", []*expr.Type{decls.NewObjectType("Test")}, decls.Int)),
decls.NewFunction("Subtract",
decls.NewInstanceOverload("test_subtract", []*expr.Type{decls.NewObjectType("Test")}, decls.Int))))
if err != nil {
t.Fatal(err)
}
parsed, issues := env.Parse(`test.Add()==3 && test.Subtract()==-1 && MulBy3(9)==27`)
if issues != nil && issues.Err() != nil {
log.Fatalf("parse error: %s", issues.Err())
}
checked, issues := env.Check(parsed)
if issues != nil && issues.Err() != nil {
log.Fatalf("type-check error: %s", issues.Err())
}
globalFunctions := cel.Functions(
&functions.Overload{
Operator: "MulBy3",
Unary: func(lhs ref.Val) ref.Val {
return types.Int(3 * lhs.Value().(int64))
}})
prg, err := env.Program(checked, globalFunctions)
if err != nil {
log.Fatalf("program construction error: %s", err)
}
out, _, err := prg.Eval(map[string]interface{}{"test": Test{a: 1, b: 2}})
if err != nil {
t.Fatal(err)
} else {
t.Log(out)
}
}
@TristonianJones
Copy link

TristonianJones commented Apr 25, 2019

Nice work! I have a couple of pointers on CEL style and conventions that you might find useful.

Overload Declarations

For overloads, a convention that works well is to specify the argument types and method in snake case. We're trying to use this pattern within CEL going forward.

  • Instance functions: <instance_type>_<method>_<arg_type>_<arg_type>
  • Global functions: <method>_<arg_type>

Example based on the gist:

  decls.NewFunction("test_add", ...)
  decls.NewFunction("test_subtract", ...)

Error Values v. Panics

CEL's logical operators are capable of short-circuiting errors which makes them much more robust to errant expressions or unexpected data, as well as to unknown values (as is the case when evaluating with partial state). The error values also makes it easier to reason about the result of your program. It is strongly recommended that functions return an types.Err value instead of panicking.

Since errors do not necessarily terminate evaluation, this does mean you can get them as input to your overloads. To check whether the input to your function is already an error and return it. For these sort of checks where it's not clear whether the input is already an error or this is a brand new instance of the error, use types.ValOrErr:

&functions.Overload{
  Operator: "MulBy3",
  Unary: func(lhs ref.Val) ref.Val {
    if types.IntType != lhs.Type() {
      return types.ValOrErr(lhs, "no such overload")
    }
    return types.Int(3) * lhs
  }
})

Whenever possible, don't panic :)

Equality testing

Currently, CEL only supports homogeneous equality. There is an open issue to support heterogeneous equality google/cel-spec#54, but until then when the types differ the result should be a types.Err response.

Something like the following would be consistent with how the built-in CEL types behave.

func (t *Test) Equal(other ref.Val) ref.Val {
  o2, ok := other.(Test)
  if !ok {
    return types.ValOrErr(other, "no such overload")
  }
  return t == o2
}

I hope you find these pointers useful. Happy coding!

@rkbalgi
Copy link
Author

rkbalgi commented Apr 26, 2019

Hi Tristan, Thank you for taking time for this useful write up. I didn't realize that there was an Err type that implements ref.Val and types.ValOrErr is absolutely handy!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment