diff --git a/pkg/collections/maps/utils.go b/pkg/collections/maps/utils.go index 38f33e5..77a5153 100644 --- a/pkg/collections/maps/utils.go +++ b/pkg/collections/maps/utils.go @@ -1,6 +1,11 @@ package maps -import "github.com/openmcp-project/controller-utils/pkg/collections/filters" +import ( + "k8s.io/utils/ptr" + + "github.com/openmcp-project/controller-utils/pkg/collections/filters" + "github.com/openmcp-project/controller-utils/pkg/pairs" +) // Filter filters a map by applying a filter function to each key-value pair. // Only the entries for which the filter function returns true are kept in the copy. @@ -50,3 +55,12 @@ func Intersect[K comparable, V any](source map[K]V, maps ...map[K]V) map[K]V { return res } + +// GetAny returns an arbitrary key-value pair from the map as a pointer to a pairs.Pair. +// If the map is empty, it returns nil. +func GetAny[K comparable, V any](m map[K]V) *pairs.Pair[K, V] { + for k, v := range m { + return ptr.To(pairs.New(k, v)) + } + return nil +} diff --git a/pkg/collections/maps/utils_test.go b/pkg/collections/maps/utils_test.go index e3f456b..f27efbb 100644 --- a/pkg/collections/maps/utils_test.go +++ b/pkg/collections/maps/utils_test.go @@ -7,7 +7,7 @@ import ( "github.com/openmcp-project/controller-utils/pkg/collections/maps" ) -var _ = Describe("LinkedIterator Tests", func() { +var _ = Describe("Map Utils Tests", func() { Context("Merge", func() { @@ -60,4 +60,22 @@ var _ = Describe("LinkedIterator Tests", func() { }) + Context("GetAny", func() { + + It("should return a key-value pair from the map", func() { + m1 := map[string]string{"foo": "bar", "bar": "baz", "foobar": "foobaz"} + pair := maps.GetAny(m1) + Expect(pair).ToNot(BeNil()) + Expect(pair.Key).To(BeElementOf("foo", "bar", "foobar")) + Expect(m1[pair.Key]).To(Equal(pair.Value)) + }) + + It("should return nil for an empty or nil map", func() { + var nilMap map[string]string + Expect(maps.GetAny(nilMap)).To(BeNil()) + Expect(maps.GetAny(map[string]string{})).To(BeNil()) + }) + + }) + }) diff --git a/pkg/collections/utils.go b/pkg/collections/utils.go new file mode 100644 index 0000000..3c2c1dc --- /dev/null +++ b/pkg/collections/utils.go @@ -0,0 +1,79 @@ +package collections + +// ProjectSlice takes a slice and a projection function and applies this function to each element of the slice. +// It returns a new slice containing the results of the projection. +// The original slice is not modified. +// If the projection function is nil, it returns nil. +func ProjectSlice[X any, Y any](src []X, project func(X) Y) []Y { + if project == nil { + return nil + } + res := make([]Y, len(src)) + for i, x := range src { + res[i] = project(x) + } + return res +} + +// ProjectMapToSlice takes a map and a projection function and applies this function to each key-value pair in the map. +// It returns a new slice containing the results of the projection. +// The original map is not modified. +// If the projection function is nil, it returns nil. +func ProjectMapToSlice[K comparable, V any, R any](src map[K]V, project func(K, V) R) []R { + if project == nil { + return nil + } + res := make([]R, 0, len(src)) + for k, v := range src { + res = append(res, project(k, v)) + } + return res +} + +// ProjectMapToMap takes a map and a projection function and applies this function to each key-value pair in the map. +// It returns a new map containing the results of the projection. +// The original map is not modified. +// Note that the resulting map may be smaller if the projection function does not guarantee unique keys. +// If the projection function is nil, it returns nil. +func ProjectMapToMap[K1 comparable, V1 any, K2 comparable, V2 any](src map[K1]V1, project func(K1, V1) (K2, V2)) map[K2]V2 { + if project == nil { + return nil + } + res := make(map[K2]V2, len(src)) + for k, v := range src { + newK, newV := project(k, v) + res[newK] = newV + } + return res +} + +// AggregateSlice takes a slice, an aggregation function and an initial value. +// It applies the aggregation function to each element of the slice, also passing in the current result. +// For the first element, it uses the initial value as the current result. +// Returns initial if the aggregation function is nil. +func AggregateSlice[X any, Y any](src []X, agg func(X, Y) Y, initial Y) Y { + if agg == nil { + return initial + } + res := initial + for _, x := range src { + res = agg(x, res) + } + return res +} + +// AggregateMap takes a map, an aggregation function and an initial value. +// It applies the aggregation function to each key-value pair in the map, also passing in the current result. +// For the first key-value pair, it uses the initial value as the current result. +// Returns initial if the aggregation function is nil. +// Note that the iteration order over the map elements is undefined and may vary between executions. +func AggregateMap[K comparable, V any, R any](src map[K]V, agg func(K, V, R) R, initial R) R { + if agg == nil { + return initial + } + res := initial + for k, v := range src { + res = agg(k, v, res) + } + return res +} diff --git a/pkg/collections/utils_test.go b/pkg/collections/utils_test.go new file mode 100644 index 0000000..9d2e61d --- /dev/null +++ b/pkg/collections/utils_test.go @@ -0,0 +1,162 @@ +package collections_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections" + "github.com/openmcp-project/controller-utils/pkg/pairs" +) + +var _ = Describe("Utils Tests", func() { + + Context("ProjectSlice", func() { + + projectFunc := func(i int) int { + return i * 2 + } + + It("should use the projection function on each element of the slice", func() { + src := []int{1, 2, 3, 4} + projected := collections.ProjectSlice(src, projectFunc) + Expect(projected).To(Equal([]int{2, 4, 6, 8})) + Expect(src).To(Equal([]int{1, 2, 3, 4}), "original slice should not be modified") + }) + + It("should return an empty slice for an empty or nil input slice", func() { + Expect(collections.ProjectSlice(nil, projectFunc)).To(BeEmpty()) + Expect(collections.ProjectSlice([]int{}, projectFunc)).To(BeEmpty()) + }) + + It("should return nil for a nil projection function", func() { + src := []int{1, 2, 3, 4} + projected := collections.ProjectSlice[int, int](src, nil) + Expect(projected).To(BeNil()) + Expect(src).To(Equal([]int{1, 2, 3, 4}), "original slice should not be modified") + }) + + }) + + Context("ProjectMapToSlice", func() { + + projectFunc := func(k string, v string) string { + return k + ":" + v + } + + It("should use the projection function on each key-value pair of the map", func() { + src := map[string]string{"a": "1", "b": "2", "c": "3"} + projected := collections.ProjectMapToSlice(src, projectFunc) + Expect(projected).To(ConsistOf("a:1", "b:2", "c:3")) + Expect(src).To(Equal(map[string]string{"a": "1", "b": "2", "c": "3"}), "original map should not be modified") + }) + + It("should return an empty slice for an empty or nil input map", func() { + Expect(collections.ProjectMapToSlice(nil, projectFunc)).To(BeEmpty()) + Expect(collections.ProjectMapToSlice(map[string]string{}, projectFunc)).To(BeEmpty()) + }) + + It("should return nil for a nil projection function", func() { + src := map[string]string{"a": "1", "b": "2", "c": "3"} + projected := collections.ProjectMapToSlice[string, string, string](src, nil) + Expect(projected).To(BeNil()) + Expect(src).To(Equal(map[string]string{"a": "1", "b": "2", "c": "3"}), "original map should not be modified") + }) + + }) + + Context("ProjectMapToMap", func() { + + projectFunc := func(k string, v string) (string, int) { + return k, len(v) + } + + It("should use the projection function on each key-value pair of the map", func() { + src := map[string]string{"a": "1", "b": "22", "c": "333"} + projected := collections.ProjectMapToMap(src, projectFunc) + Expect(projected).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3})) + Expect(src).To(Equal(map[string]string{"a": "1", "b": "22", "c": "333"}), "original map should not be modified") + }) + + It("should return an empty map for an empty or nil input map", func() { + Expect(collections.ProjectMapToMap(nil, projectFunc)).To(BeEmpty()) + Expect(collections.ProjectMapToMap(map[string]string{}, projectFunc)).To(BeEmpty()) + }) + + It("should return nil for a nil projection function", func() { + src := map[string]string{"a": "1", "b": "22", "c": "333"} + projected := collections.ProjectMapToMap[string, string, string, int](src, nil) + Expect(projected).To(BeNil()) + Expect(src).To(Equal(map[string]string{"a": "1", "b": "22", "c": "333"}), "original map should not be modified") + }) + + }) + + Context("AggregateSlice", func() { + + sum := func(val, s int) int { + return val + s + } + stradd := func(val int, s string) string { + return fmt.Sprintf("%s%d", s, val) + } + + It("should return the initial value if the aggregation function is nil", func() { + src := []int{1, 2, 3, 4} + result := collections.AggregateSlice(src, nil, 0) + Expect(result).To(Equal(0)) + Expect(src).To(Equal([]int{1, 2, 3, 4})) + }) + + It("should correctly aggregate the slice using the provided function", func() { + src := []int{1, 2, 3, 4} + result := collections.AggregateSlice(src, sum, 0) + Expect(result).To(Equal(10)) + Expect(src).To(Equal([]int{1, 2, 3, 4})) + + result2 := collections.AggregateSlice(src, stradd, "test") + Expect(result2).To(Equal("test1234")) + Expect(src).To(Equal([]int{1, 2, 3, 4})) + }) + + It("should handle a nil input slice", func() { + result := collections.AggregateSlice[int, int](nil, sum, 100) + Expect(result).To(Equal(100)) + }) + + }) + + Context("AggregateMap", func() { + + aggregate := func(k string, v int, agg pairs.Pair[string, int]) pairs.Pair[string, int] { + return pairs.New(agg.Key+k, agg.Value+v) + } + + It("should return the initial value if the aggregation function is nil", func() { + src := map[string]int{"a": 1, "b": 2, "c": 3} + result := collections.AggregateMap(src, nil, 0) + Expect(result).To(Equal(0)) + Expect(src).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3})) + }) + + It("should correctly aggregate the map using the provided function", func() { + src := map[string]int{"a": 1, "b": 2, "c": 3} + result := collections.AggregateMap(src, aggregate, pairs.New("", 0)) + Expect(result.Key).To(HaveLen(3)) + Expect(result.Key).To(ContainSubstring("a")) + Expect(result.Key).To(ContainSubstring("b")) + Expect(result.Key).To(ContainSubstring("c")) + Expect(result.Value).To(Equal(6)) + Expect(src).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3})) + }) + + It("should handle a nil input map", func() { + result := collections.AggregateMap(nil, aggregate, pairs.New("", 0)) + Expect(result.Key).To(BeEmpty()) + Expect(result.Value).To(Equal(0)) + }) + + }) + +}) diff --git a/pkg/controller/utils_test.go b/pkg/controller/utils_test.go index 431615b..e690f6c 100644 --- a/pkg/controller/utils_test.go +++ b/pkg/controller/utils_test.go @@ -2,49 +2,60 @@ package controller import ( "fmt" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/pairs" "k8s.io/apimachinery/pkg/util/validation" ) -func TestK8sNameHash(t *testing.T) { - tt := []struct { - input []string - expHash string - }{ - { - []string{"test1"}, - "dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka", - }, - { - // check that the same string produces the same hash - []string{"test1"}, - "dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka", - }, - { - []string{"bla"}, - "jxz4h5upzsb3e7u5ileqimnhesm7c6dvzanftg2wnsmitoljm4bq", - }, - { - []string{"some other test", "this is a very, very long string"}, - "rjphpfjbmwn6qqydv6xhtmj3kxrlzepn2tpwy4okw2ypoc3nlffq", - }, - } - - for _, tc := range tt { - t.Run(fmt.Sprint(tc.input), func(t *testing.T) { - res := K8sNameHash(tc.input...) - - if res != tc.expHash { - t.Errorf("exp hash %q, got %q", tc.expHash, res) +var _ = Describe("Predicates", func() { + + Context("K8sNameHash", func() { + + testData := []pairs.Pair[*[]string, string]{ + { + Key: &[]string{"test1"}, + Value: "dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka", + }, + { + Key: &[]string{"bla"}, + Value: "jxz4h5upzsb3e7u5ileqimnhesm7c6dvzanftg2wnsmitoljm4bq", + }, + { + Key: &[]string{"some other test", "this is a very, very long string"}, + Value: "rjphpfjbmwn6qqydv6xhtmj3kxrlzepn2tpwy4okw2ypoc3nlffq", + }, + } + + It("should generate the same hash for the same input value", func() { + for _, p := range testData { + for range 5 { + res := K8sNameHash(*p.Key...) + Expect(res).To(Equal(p.Value)) + } } + }) - // ensure the result is a valid DNS1123Subdomain - if errs := validation.IsDNS1123Subdomain(res); errs != nil { - t.Errorf("value %q is invalid: %v", res, errs) - } + It("should generate different hashes for different input values", func() { + res1 := K8sNameHash(*testData[0].Key...) + res2 := K8sNameHash(*testData[1].Key...) + res3 := K8sNameHash(*testData[2].Key...) + Expect(res1).NotTo(Equal(res2)) + Expect(res1).NotTo(Equal(res3)) + Expect(res2).NotTo(Equal(res3)) + }) + It("should generate a valid DNS1123Subdomain", func() { + for _, p := range testData { + res := K8sNameHash(*p.Key...) + errs := validation.IsDNS1123Subdomain(res) + Expect(errs).To(BeEmpty(), fmt.Sprintf("value %q is invalid: %v", res, errs)) + } }) - } -} + }) + +})