diff --git a/pkg/cel/environment.go b/pkg/cel/environment.go index db8921daf..1b78f68cc 100644 --- a/pkg/cel/environment.go +++ b/pkg/cel/environment.go @@ -57,6 +57,7 @@ func DefaultEnvironment(options ...EnvOption) (*cel.Env, error) { cel.OptionalTypes(), ext.Encoders(), library.Random(), + library.Maps(), } opts := &envOptions{} diff --git a/pkg/cel/library/maps.go b/pkg/cel/library/maps.go new file mode 100644 index 000000000..b30d23a65 --- /dev/null +++ b/pkg/cel/library/maps.go @@ -0,0 +1,112 @@ +// Copyright 2025 The Kube Resource Orchestrator Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package library + +import ( + "math" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +// Maps returns a cel.EnvOption to configure extended functions for map manipulation. +// +// # Merge +// +// Merges two maps. Keys from the second map overwrite already available keys in the first map. +// Keys must be of type string, value types must be identical in the maps merged. +// +// map(string, T).merge(map(string, T)) -> map(string, T) +// +// Examples: +// +// {}.merge({}) == {} +// {}.merge({'a': 1}) == {'a': 1} +// {'a': 1}.merge({}) == {'a': 1} +// {'a': 1}.merge({'b': 2}) == {'a': 1, 'b': 2} +// {'a': 1}.merge({'a': 2, 'b': 2}) == {'a': 1, 'b': 2} +func Maps(options ...MapsOption) cel.EnvOption { + l := &mapsLib{version: math.MaxUint32} + for _, opt := range options { + opt(l) + } + return cel.Lib(l) +} + +type mapsLib struct { + version uint32 +} + +type MapsOption func(*mapsLib) *mapsLib + +// LibraryName implements the cel.SingletonLibrary interface method. +func (l *mapsLib) LibraryName() string { + return "cel.lib.ext.kro.maps" +} + +// CompileOptions implements the cel.Library interface method. +func (l *mapsLib) CompileOptions() []cel.EnvOption { + mapType := cel.MapType(cel.TypeParamType("K"), cel.TypeParamType("V")) + // mapDynType := cel.MapType(cel.DynType, cel.DynType) + opts := []cel.EnvOption{ + cel.Function("merge", + cel.MemberOverload("map_merge", + []*cel.Type{mapType, mapType}, + mapType, + cel.BinaryBinding(mergeVals), + ), + ), + } + return opts +} + +// ProgramOptions implements the cel.Library interface method. +func (l *mapsLib) ProgramOptions() []cel.ProgramOption { + return []cel.ProgramOption{} +} + +func mergeVals(lhs, rhs ref.Val) ref.Val { + left, lok := lhs.(traits.Mapper) + right, rok := rhs.(traits.Mapper) + if !lok || !rok { + return types.ValOrErr(lhs, "no such overload: %v.merge(%v)", lhs.Type(), rhs.Type()) + } + return merge(left, right) +} + +// merge returns a new map containing entries from both maps. +// Keys in 'other' overwrite keys in 'self'. +func merge(self, other traits.Mapper) traits.Mapper { + result := mapperTraitToMutableMapper(other) + for i := self.Iterator(); i.HasNext().(types.Bool); { + k := i.Next() + if !result.Contains(k).(types.Bool) { + result.Insert(k, self.Get(k)) + } + } + return result.ToImmutableMap() +} + +// mapperTraitToMutableMapper copies a traits.Mapper into a MutableMap. +func mapperTraitToMutableMapper(m traits.Mapper) traits.MutableMapper { + vals := make(map[ref.Val]ref.Val, m.Size().(types.Int)) + for it := m.Iterator(); it.HasNext().(types.Bool); { + k := it.Next() + vals[k] = m.Get(k) + } + return types.NewMutableMap(types.DefaultTypeAdapter, vals) +} diff --git a/pkg/cel/library/maps_test.go b/pkg/cel/library/maps_test.go new file mode 100644 index 000000000..ffa8808ee --- /dev/null +++ b/pkg/cel/library/maps_test.go @@ -0,0 +1,76 @@ +// Copyright 2025 The Kube Resource Orchestrator Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package library + +import ( + "fmt" + "testing" + + "github.com/google/cel-go/cel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaps(t *testing.T) { + mapsTests := []struct { + expr string + err require.ErrorAssertionFunc + }{ + {expr: `{}.merge({}) == {}`}, + {expr: `{}.merge({'a': 1}) == {'a': 1}`}, + {expr: `{}.merge({'a': 2.1}) == {'a': 2.1}`}, + {expr: `{}.merge({'a': 'foo'}) == {'a': 'foo'}`}, + {expr: `{'a': 1}.merge({}) == {'a': 1}`}, + {expr: `{'a': 1}.merge({'b': 2}) == {'a': 1, 'b': 2}`}, + {expr: `{'a': 1}.merge({'a': 2, 'b': 2}) == {'a': 2, 'b': 2}`}, + + {expr: `{}.merge([])`, err: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "no matching overload for 'merge'") + }}, + } + + env := testMapsEnv(t) + for i, tc := range mapsTests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + r := require.New(t) + + ast, iss := env.Compile(tc.expr) + if tc.err != nil { + tc.err(t, iss.Err()) + return + } + r.NoError(iss.Err(), "compile failed for expr: %s", tc.expr) + + prg, err := env.Program(ast) + require.NoError(t, err) + + out, _, err := prg.Eval(cel.NoVars()) + require.NoError(t, err) + assert.True(t, out.Value().(bool)) + }) + } +} + +func testMapsEnv(t *testing.T, opts ...cel.EnvOption) *cel.Env { + t.Helper() + baseOpts := []cel.EnvOption{ + Maps(), + } + env, err := cel.NewEnv(append(baseOpts, opts...)...) + if err != nil { + t.Fatalf("cel.NewEnv(Maps()) failed: %v", err) + } + return env +}