Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fb3ab9b
add late bind decorator implementation
hyp0th3rmi4 Apr 13, 2025
8e80b4d
complete implementation for late binding feature
hyp0th3rmi4 Apr 13, 2025
cd7e105
add support for late binding calls with evaluation option
hyp0th3rmi4 Apr 13, 2025
d3d3545
integrate late binding behaviour during program construction and eval…
hyp0th3rmi4 Apr 13, 2025
e61e95c
add unit tests for lateBindActivation and ValidateOverloads()
hyp0th3rmi4 Apr 15, 2025
372b4b4
add unit tests for lateBindEvalZeroArity
hyp0th3rmi4 Apr 16, 2025
e61312d
add unit tests for lateBindEvalXXX implementation (except Eval method)
hyp0th3rmi4 Apr 16, 2025
94023a4
add unit tests for lateBindEvalUnary.Eval and lateBindEvalBinary.Eval
hyp0th3rmi4 Apr 17, 2025
a69a393
refactor code to remove duplication
hyp0th3rmi4 Apr 17, 2025
d542185
add unit test for lateBindEvalVarArgs.Eval
hyp0th3rmi4 Apr 23, 2025
433f272
add unit tests, extend coverage of late bind transformations, and add…
hyp0th3rmi4 Apr 24, 2025
bc255ea
add check to ensure that the AST is checked if we add LateBindCalls p…
hyp0th3rmi4 Apr 24, 2025
e020df0
move late bind activation into activation.go
hyp0th3rmi4 Apr 25, 2025
88906be
add new implementation of LateBindingCalls based on revised logic
hyp0th3rmi4 Apr 25, 2025
d1cb656
add new implementation of LateBindingCalls based on revised logic
hyp0th3rmi4 Apr 25, 2025
7d37820
add LateBindCallOption(s) to LateBindCalls planner option
hyp0th3rmi4 Apr 25, 2025
8a53348
add unit test for decorator and end-to-end expression evaluation, upl…
hyp0th3rmi4 Apr 26, 2025
8dc1747
increase coverage
hyp0th3rmi4 Apr 26, 2025
7de9d91
add integration of options into program and unit test for end-to-end …
hyp0th3rmi4 Apr 27, 2025
6036f3b
Merge branch 'google:master' into featute/function-late-bind-eval
hyp0th3rmi4 Apr 27, 2025
9c081d8
remove old late-binding implementation
hyp0th3rmi4 Apr 27, 2025
c9692da
Merge branch 'featute/function-late-bind-eval' of https://github.com/…
hyp0th3rmi4 Apr 27, 2025
15f5c61
fix fmt.Sprintf format string
hyp0th3rmi4 Apr 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/interpreter"
"github.com/google/cel-go/interpreter/functions"
"github.com/google/cel-go/parser"
"github.com/google/cel-go/test"

Expand Down Expand Up @@ -1169,6 +1170,215 @@ func TestContextEvalUnknowns(t *testing.T) {
}
}

func TestEvalLateBinding(t *testing.T) {

// functions statically bound to function call nodes
// during parsing and program planning.

f1_int := func(_ ...ref.Val) ref.Val {
return types.Int(10)
}

f1_int_int := func(arg ref.Val) ref.Val {
return arg.(traits.Multiplier).Multiply(types.Int(2))
}

f1 := func() EnvOption {
return Function("f1",
Overload("f1_int", []*Type{}, types.IntType, FunctionBinding(f1_int)),
Overload("f1_int_int", []*Type{types.IntType}, types.IntType, UnaryBinding(f1_int_int)),
)
}

// functions supplied during evaluation to override the
// logic implemented in the static bindings.

f1_int_override := func() *functions.Overload {
return &functions.Overload{
Operator: "f1_int",
Function: func(_ ...ref.Val) ref.Val {

return types.Int(0)
},
NonStrict: false,
OperandTrait: 0,
}
}

f1_int_int_override := func() *functions.Overload {
return &functions.Overload{
Operator: "f1_int_int",
Unary: func(arg ref.Val) ref.Val {
return arg.(traits.Adder).Add(types.Int(100))
},
NonStrict: false,
OperandTrait: 0,
}
}

activation := func(t *testing.T, vars map[string]any, ovls ...*functions.Overload) Activation {
t.Helper()
act, err := NewActivation(vars)
if err == nil {
act, err = interpreter.NewLateBindActivation(act, ovls...)
}
if err != nil {
t.Fatalf("pre-condition failed: could not create activation (cause: %v)", err)
}
return act
}

// expectValue generates an expectation function that checks that the outcome of the
// evaluation has generated no error and has returned the value originally passed as
// argument. The comparision is performed by invoking Equal on the expected value and
// passing the outcome of the evaluation as argument.
expectValue := func(expected ref.Val) func(t *testing.T, actual ref.Val, _ *EvalDetails, err error) {

return func(t *testing.T, actual ref.Val, _ *EvalDetails, err error) {

if err != nil {
t.Errorf("unexpected error (cause: %v)", err)
}

if expected.Equal(actual) != types.True {
t.Errorf("unexpected value (got: %v, want: %v)", actual, expected)
}
}
}

// expectError generates an expectation function that checks whether the outcome of the
// execution of the test (program generation, and evaluation) has generated an error and
// that error contains a predefined message.
expectError := func(errMsg string) func(t *testing.T, _ ref.Val, _ *EvalDetails, err error) {

return func(t *testing.T, _ ref.Val, _ *EvalDetails, err error) {

if err == nil {
t.Fatal("expected error, but error is nil")
}

if !strings.Contains(err.Error(), errMsg) {
t.Errorf("the evaluation error does not contain expected message (got: %s, want: %s)", err.Error(), errMsg)
}
}
}

testCases := []struct {
name string
env *Env
expression string
parseOnly bool
opts []ProgramOption
activation Activation
expect func(t *testing.T, out ref.Val, details *EvalDetails, err error)
}{
{
name: "OK_Happy_Path_No_Overrides",
env: testEnv(t, f1()),
expression: `f1() + f1(10)`,
parseOnly: false,
opts: []ProgramOption{EvalOptions(OptLateBindCalls)},
activation: NoVars(),
expect: expectValue(types.Int(10 + 10*2)),
}, {
name: "OK_Happy_Path_With_Overrides",
env: testEnv(t,
Variable("a", types.IntType),
f1(),
),
expression: `f1() + f1(a)`,
parseOnly: false,
opts: []ProgramOption{EvalOptions(OptLateBindCalls)},
activation: activation(t,
map[string]any{
"a": 15,
},
f1_int_override(),
),
expect: expectValue(types.Int(0 + 15*2)),
}, {
name: "OK_Happy_Path_With_Overrides_Explicit_Program_Option",
env: testEnv(t,
Variable("a", types.IntType),
f1(),
),
expression: "f1() + f1(a)",
parseOnly: false,
opts: []ProgramOption{LateBindOptions()},
activation: activation(t,
map[string]any{
"a": 10,
},
f1_int_override(),
f1_int_int_override(),
),
expect: expectValue(types.Int(0 + 100 + 10)),
}, {
name: "ERROR_Invalid_Overloads",
env: testEnv(t, f1()),
expression: "f1() + f1(10)",
parseOnly: false,
opts: []ProgramOption{LateBindOptions()},
activation: activation(t, map[string]any{},
f1_int_override(),
&functions.Overload{
Operator: "f1_int_int",
Binary: func(lhs ref.Val, rhs ref.Val) ref.Val {
return types.Int(50)
},
NonStrict: false,
OperandTrait: 0,
},
),
expect: expectError(
interpreter.OverloadSignatureError(
"<unknown>",
"f1_int_int",
"binary{ func(ref.Val, ref.Val) ref.Val }",
"unary{ func(ref.Val) ref.Val }",
).Error(),
),
}, {
name: "ERROR_Unchecked_AST",
env: testEnv(t, f1()),
expression: "f1 + f1(20)",
parseOnly: true,
opts: []ProgramOption{EvalOptions(OptLateBindCalls)},
activation: activation(t, map[string]any{}),
expect: expectError(interpreter.UncheckedAstError().Error()),
},
}

for _, testCase := range testCases {

t.Run(testCase.name, func(t *testing.T) {

var ast *Ast
var issues *Issues

if testCase.parseOnly == true {
ast, issues = testCase.env.Parse(testCase.expression)
} else {
ast, issues = testCase.env.Compile(testCase.expression)
}

err := issues.Err()
if err != nil {
t.Fatalf("pre-condition failed could not parse/compile expression (cause: %v)", err)
}

prg, err := testCase.env.Program(ast, testCase.opts...)
if err != nil {
testCase.expect(t, nil, nil, err)
} else {

out, details, err := prg.Eval(testCase.activation)
testCase.expect(t, out, details, err)
}
})
}
}

func BenchmarkContextEval(b *testing.B) {
env := testEnv(b,
Variable("items", ListType(IntType)),
Expand Down
18 changes: 18 additions & 0 deletions cel/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,12 @@ const (
//
// Deprecated: use ext.StringsValidateFormatCalls() as this option is now a no-op.
OptCheckStringFormat EvalOption = 1 << iota

// OptLateBindCalls enables overriding the binding of function overloads at evaluation time.
//
// This option works in concert with the a specific implementation of the activation that is
// wraps dispatcher, otherwise it defaults to standard behaviour.
OptLateBindCalls EvalOption = 1 << iota
)

// EvalOptions sets one or more evaluation options which may affect the evaluation or Result.
Expand All @@ -665,6 +671,18 @@ func EvalOptions(opts ...EvalOption) ProgramOption {
}
}

// LateBindOptions sets one of more LateBindCallOption and automatically
// add the OptLateBindCalls to the evaluation options, to enable the late
// binding behaviour.
func LateBindOptions(opts ...interpreter.LateBindCallOption) ProgramOption {
return func(p *prog) (*prog, error) {
p.lateBindOptions = append(p.lateBindOptions, opts...)
p.evalOpts |= OptLateBindCalls
return p, nil
}

}

// InterruptCheckFrequency configures the number of iterations within a comprehension to evaluate
// before checking whether the function evaluation has been interrupted.
func InterruptCheckFrequency(checkFrequency uint) ProgramOption {
Expand Down
37 changes: 33 additions & 4 deletions cel/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ type prog struct {
callCostEstimator interpreter.ActualCostEstimator
costOptions []interpreter.CostTrackerOption
costLimit *uint64

lateBindOptions []interpreter.LateBindCallOption
}

// newProgram creates a program instance with an environment, an ast, and an optional list of
Expand All @@ -176,10 +178,11 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
// Ensure the default attribute factory is set after the adapter and provider are
// configured.
p := &prog{
Env: e,
plannerOptions: []interpreter.PlannerOption{},
dispatcher: disp,
costOptions: []interpreter.CostTrackerOption{},
Env: e,
plannerOptions: []interpreter.PlannerOption{},
dispatcher: disp,
costOptions: []interpreter.CostTrackerOption{},
lateBindOptions: []interpreter.LateBindCallOption{},
}

// Configure the program via the ProgramOption values.
Expand Down Expand Up @@ -264,6 +267,20 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
plannerOptions = append(plannerOptions, observers...)
}
}

// add behaviour for latebinding calls.
if p.evalOpts&OptLateBindCalls != 0 {

// we need to ensure that the AST is checked otherwise
// we won't be able to resolve overloaded functions by
// overload identifiers
if !a.IsChecked() {
return nil, interpreter.UncheckedAstError()
}

plannerOptions = append(plannerOptions, interpreter.LateBindCalls(p.lateBindOptions...))
}

return p.initInterpretable(a, plannerOptions)
}

Expand Down Expand Up @@ -309,6 +326,18 @@ func (p *prog) Eval(input any) (out ref.Val, det *EvalDetails, err error) {
if p.defaultVars != nil {
vars = interpreter.NewHierarchicalActivation(p.defaultVars, vars)
}

// before executing the evaluation if the late bind option was
// configured we ensure that the activation does have compatible
// overloads with the one maintained in the dispatcher.
if p.evalOpts&OptLateBindCalls != 0 {

err := interpreter.ValidateOverloads(p.dispatcher, vars)
if err != nil {
return nil, nil, err
}
}

if p.observable != nil {
det = &EvalDetails{}
out = p.observable.ObserveEval(vars, func(observed any) {
Expand Down
2 changes: 1 addition & 1 deletion checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (c *checker) checkOptSelect(e ast.Expr) {
}
c.errors.notAnOptionalFieldSelectionCall(e.ID(), c.location(e),
fmt.Sprintf(
"incorrect signature.%s argument count: %d%s", t, len(call.Args())))
"incorrect signature.%s argument count: %d", t, len(call.Args())))
return
}

Expand Down
Loading