Skip to content

Commit 09bfaf2

Browse files
authored
feat: add collection utility functions (#66)
* add collection utility functions * add RemoveFinalizersWithPrefix method and rewrite utils tests * add unit tests for AggregateSlice and AggregateMap * remove RemoveFinalizersWithPrefix function because it is somewhat specific and not used currently * rename variable to avoid confusing overshadowing
1 parent 2058178 commit 09bfaf2

File tree

5 files changed

+323
-39
lines changed

5 files changed

+323
-39
lines changed

pkg/collections/maps/utils.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package maps
22

3-
import "github.com/openmcp-project/controller-utils/pkg/collections/filters"
3+
import (
4+
"k8s.io/utils/ptr"
5+
6+
"github.com/openmcp-project/controller-utils/pkg/collections/filters"
7+
"github.com/openmcp-project/controller-utils/pkg/pairs"
8+
)
49

510
// Filter filters a map by applying a filter function to each key-value pair.
611
// 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 {
5055

5156
return res
5257
}
58+
59+
// GetAny returns an arbitrary key-value pair from the map as a pointer to a pairs.Pair.
60+
// If the map is empty, it returns nil.
61+
func GetAny[K comparable, V any](m map[K]V) *pairs.Pair[K, V] {
62+
for k, v := range m {
63+
return ptr.To(pairs.New(k, v))
64+
}
65+
return nil
66+
}

pkg/collections/maps/utils_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"github.com/openmcp-project/controller-utils/pkg/collections/maps"
88
)
99

10-
var _ = Describe("LinkedIterator Tests", func() {
10+
var _ = Describe("Map Utils Tests", func() {
1111

1212
Context("Merge", func() {
1313

@@ -60,4 +60,22 @@ var _ = Describe("LinkedIterator Tests", func() {
6060

6161
})
6262

63+
Context("GetAny", func() {
64+
65+
It("should return a key-value pair from the map", func() {
66+
m1 := map[string]string{"foo": "bar", "bar": "baz", "foobar": "foobaz"}
67+
pair := maps.GetAny(m1)
68+
Expect(pair).ToNot(BeNil())
69+
Expect(pair.Key).To(BeElementOf("foo", "bar", "foobar"))
70+
Expect(m1[pair.Key]).To(Equal(pair.Value))
71+
})
72+
73+
It("should return nil for an empty or nil map", func() {
74+
var nilMap map[string]string
75+
Expect(maps.GetAny(nilMap)).To(BeNil())
76+
Expect(maps.GetAny(map[string]string{})).To(BeNil())
77+
})
78+
79+
})
80+
6381
})

pkg/collections/utils.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package collections
2+
3+
// ProjectSlice takes a slice and a projection function and applies this function to each element of the slice.
4+
// It returns a new slice containing the results of the projection.
5+
// The original slice is not modified.
6+
// If the projection function is nil, it returns nil.
7+
func ProjectSlice[X any, Y any](src []X, project func(X) Y) []Y {
8+
if project == nil {
9+
return nil
10+
}
11+
res := make([]Y, len(src))
12+
for i, x := range src {
13+
res[i] = project(x)
14+
}
15+
return res
16+
}
17+
18+
// ProjectMapToSlice takes a map and a projection function and applies this function to each key-value pair in the map.
19+
// It returns a new slice containing the results of the projection.
20+
// The original map is not modified.
21+
// If the projection function is nil, it returns nil.
22+
func ProjectMapToSlice[K comparable, V any, R any](src map[K]V, project func(K, V) R) []R {
23+
if project == nil {
24+
return nil
25+
}
26+
res := make([]R, 0, len(src))
27+
for k, v := range src {
28+
res = append(res, project(k, v))
29+
}
30+
return res
31+
}
32+
33+
// ProjectMapToMap takes a map and a projection function and applies this function to each key-value pair in the map.
34+
// It returns a new map containing the results of the projection.
35+
// The original map is not modified.
36+
// Note that the resulting map may be smaller if the projection function does not guarantee unique keys.
37+
// If the projection function is nil, it returns nil.
38+
func ProjectMapToMap[K1 comparable, V1 any, K2 comparable, V2 any](src map[K1]V1, project func(K1, V1) (K2, V2)) map[K2]V2 {
39+
if project == nil {
40+
return nil
41+
}
42+
res := make(map[K2]V2, len(src))
43+
for k, v := range src {
44+
newK, newV := project(k, v)
45+
res[newK] = newV
46+
}
47+
return res
48+
}
49+
50+
// AggregateSlice takes a slice, an aggregation function and an initial value.
51+
// It applies the aggregation function to each element of the slice, also passing in the current result.
52+
// For the first element, it uses the initial value as the current result.
53+
// Returns initial if the aggregation function is nil.
54+
func AggregateSlice[X any, Y any](src []X, agg func(X, Y) Y, initial Y) Y {
55+
if agg == nil {
56+
return initial
57+
}
58+
res := initial
59+
for _, x := range src {
60+
res = agg(x, res)
61+
}
62+
return res
63+
}
64+
65+
// AggregateMap takes a map, an aggregation function and an initial value.
66+
// It applies the aggregation function to each key-value pair in the map, also passing in the current result.
67+
// For the first key-value pair, it uses the initial value as the current result.
68+
// Returns initial if the aggregation function is nil.
69+
// Note that the iteration order over the map elements is undefined and may vary between executions.
70+
func AggregateMap[K comparable, V any, R any](src map[K]V, agg func(K, V, R) R, initial R) R {
71+
if agg == nil {
72+
return initial
73+
}
74+
res := initial
75+
for k, v := range src {
76+
res = agg(k, v, res)
77+
}
78+
return res
79+
}

pkg/collections/utils_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package collections_test
2+
3+
import (
4+
"fmt"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/openmcp-project/controller-utils/pkg/collections"
10+
"github.com/openmcp-project/controller-utils/pkg/pairs"
11+
)
12+
13+
var _ = Describe("Utils Tests", func() {
14+
15+
Context("ProjectSlice", func() {
16+
17+
projectFunc := func(i int) int {
18+
return i * 2
19+
}
20+
21+
It("should use the projection function on each element of the slice", func() {
22+
src := []int{1, 2, 3, 4}
23+
projected := collections.ProjectSlice(src, projectFunc)
24+
Expect(projected).To(Equal([]int{2, 4, 6, 8}))
25+
Expect(src).To(Equal([]int{1, 2, 3, 4}), "original slice should not be modified")
26+
})
27+
28+
It("should return an empty slice for an empty or nil input slice", func() {
29+
Expect(collections.ProjectSlice(nil, projectFunc)).To(BeEmpty())
30+
Expect(collections.ProjectSlice([]int{}, projectFunc)).To(BeEmpty())
31+
})
32+
33+
It("should return nil for a nil projection function", func() {
34+
src := []int{1, 2, 3, 4}
35+
projected := collections.ProjectSlice[int, int](src, nil)
36+
Expect(projected).To(BeNil())
37+
Expect(src).To(Equal([]int{1, 2, 3, 4}), "original slice should not be modified")
38+
})
39+
40+
})
41+
42+
Context("ProjectMapToSlice", func() {
43+
44+
projectFunc := func(k string, v string) string {
45+
return k + ":" + v
46+
}
47+
48+
It("should use the projection function on each key-value pair of the map", func() {
49+
src := map[string]string{"a": "1", "b": "2", "c": "3"}
50+
projected := collections.ProjectMapToSlice(src, projectFunc)
51+
Expect(projected).To(ConsistOf("a:1", "b:2", "c:3"))
52+
Expect(src).To(Equal(map[string]string{"a": "1", "b": "2", "c": "3"}), "original map should not be modified")
53+
})
54+
55+
It("should return an empty slice for an empty or nil input map", func() {
56+
Expect(collections.ProjectMapToSlice(nil, projectFunc)).To(BeEmpty())
57+
Expect(collections.ProjectMapToSlice(map[string]string{}, projectFunc)).To(BeEmpty())
58+
})
59+
60+
It("should return nil for a nil projection function", func() {
61+
src := map[string]string{"a": "1", "b": "2", "c": "3"}
62+
projected := collections.ProjectMapToSlice[string, string, string](src, nil)
63+
Expect(projected).To(BeNil())
64+
Expect(src).To(Equal(map[string]string{"a": "1", "b": "2", "c": "3"}), "original map should not be modified")
65+
})
66+
67+
})
68+
69+
Context("ProjectMapToMap", func() {
70+
71+
projectFunc := func(k string, v string) (string, int) {
72+
return k, len(v)
73+
}
74+
75+
It("should use the projection function on each key-value pair of the map", func() {
76+
src := map[string]string{"a": "1", "b": "22", "c": "333"}
77+
projected := collections.ProjectMapToMap(src, projectFunc)
78+
Expect(projected).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3}))
79+
Expect(src).To(Equal(map[string]string{"a": "1", "b": "22", "c": "333"}), "original map should not be modified")
80+
})
81+
82+
It("should return an empty map for an empty or nil input map", func() {
83+
Expect(collections.ProjectMapToMap(nil, projectFunc)).To(BeEmpty())
84+
Expect(collections.ProjectMapToMap(map[string]string{}, projectFunc)).To(BeEmpty())
85+
})
86+
87+
It("should return nil for a nil projection function", func() {
88+
src := map[string]string{"a": "1", "b": "22", "c": "333"}
89+
projected := collections.ProjectMapToMap[string, string, string, int](src, nil)
90+
Expect(projected).To(BeNil())
91+
Expect(src).To(Equal(map[string]string{"a": "1", "b": "22", "c": "333"}), "original map should not be modified")
92+
})
93+
94+
})
95+
96+
Context("AggregateSlice", func() {
97+
98+
sum := func(val, s int) int {
99+
return val + s
100+
}
101+
stradd := func(val int, s string) string {
102+
return fmt.Sprintf("%s%d", s, val)
103+
}
104+
105+
It("should return the initial value if the aggregation function is nil", func() {
106+
src := []int{1, 2, 3, 4}
107+
result := collections.AggregateSlice(src, nil, 0)
108+
Expect(result).To(Equal(0))
109+
Expect(src).To(Equal([]int{1, 2, 3, 4}))
110+
})
111+
112+
It("should correctly aggregate the slice using the provided function", func() {
113+
src := []int{1, 2, 3, 4}
114+
result := collections.AggregateSlice(src, sum, 0)
115+
Expect(result).To(Equal(10))
116+
Expect(src).To(Equal([]int{1, 2, 3, 4}))
117+
118+
result2 := collections.AggregateSlice(src, stradd, "test")
119+
Expect(result2).To(Equal("test1234"))
120+
Expect(src).To(Equal([]int{1, 2, 3, 4}))
121+
})
122+
123+
It("should handle a nil input slice", func() {
124+
result := collections.AggregateSlice[int, int](nil, sum, 100)
125+
Expect(result).To(Equal(100))
126+
})
127+
128+
})
129+
130+
Context("AggregateMap", func() {
131+
132+
aggregate := func(k string, v int, agg pairs.Pair[string, int]) pairs.Pair[string, int] {
133+
return pairs.New(agg.Key+k, agg.Value+v)
134+
}
135+
136+
It("should return the initial value if the aggregation function is nil", func() {
137+
src := map[string]int{"a": 1, "b": 2, "c": 3}
138+
result := collections.AggregateMap(src, nil, 0)
139+
Expect(result).To(Equal(0))
140+
Expect(src).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3}))
141+
})
142+
143+
It("should correctly aggregate the map using the provided function", func() {
144+
src := map[string]int{"a": 1, "b": 2, "c": 3}
145+
result := collections.AggregateMap(src, aggregate, pairs.New("", 0))
146+
Expect(result.Key).To(HaveLen(3))
147+
Expect(result.Key).To(ContainSubstring("a"))
148+
Expect(result.Key).To(ContainSubstring("b"))
149+
Expect(result.Key).To(ContainSubstring("c"))
150+
Expect(result.Value).To(Equal(6))
151+
Expect(src).To(Equal(map[string]int{"a": 1, "b": 2, "c": 3}))
152+
})
153+
154+
It("should handle a nil input map", func() {
155+
result := collections.AggregateMap(nil, aggregate, pairs.New("", 0))
156+
Expect(result.Key).To(BeEmpty())
157+
Expect(result.Value).To(Equal(0))
158+
})
159+
160+
})
161+
162+
})

pkg/controller/utils_test.go

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,60 @@ package controller
22

33
import (
44
"fmt"
5-
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/openmcp-project/controller-utils/pkg/pairs"
610

711
"k8s.io/apimachinery/pkg/util/validation"
812
)
913

10-
func TestK8sNameHash(t *testing.T) {
11-
tt := []struct {
12-
input []string
13-
expHash string
14-
}{
15-
{
16-
[]string{"test1"},
17-
"dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka",
18-
},
19-
{
20-
// check that the same string produces the same hash
21-
[]string{"test1"},
22-
"dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka",
23-
},
24-
{
25-
[]string{"bla"},
26-
"jxz4h5upzsb3e7u5ileqimnhesm7c6dvzanftg2wnsmitoljm4bq",
27-
},
28-
{
29-
[]string{"some other test", "this is a very, very long string"},
30-
"rjphpfjbmwn6qqydv6xhtmj3kxrlzepn2tpwy4okw2ypoc3nlffq",
31-
},
32-
}
33-
34-
for _, tc := range tt {
35-
t.Run(fmt.Sprint(tc.input), func(t *testing.T) {
36-
res := K8sNameHash(tc.input...)
37-
38-
if res != tc.expHash {
39-
t.Errorf("exp hash %q, got %q", tc.expHash, res)
14+
var _ = Describe("Predicates", func() {
15+
16+
Context("K8sNameHash", func() {
17+
18+
testData := []pairs.Pair[*[]string, string]{
19+
{
20+
Key: &[]string{"test1"},
21+
Value: "dnhq5gcrs4mzrzzsa6cujsllg3b5ahhn67fkgmrvtvxr3a2woaka",
22+
},
23+
{
24+
Key: &[]string{"bla"},
25+
Value: "jxz4h5upzsb3e7u5ileqimnhesm7c6dvzanftg2wnsmitoljm4bq",
26+
},
27+
{
28+
Key: &[]string{"some other test", "this is a very, very long string"},
29+
Value: "rjphpfjbmwn6qqydv6xhtmj3kxrlzepn2tpwy4okw2ypoc3nlffq",
30+
},
31+
}
32+
33+
It("should generate the same hash for the same input value", func() {
34+
for _, p := range testData {
35+
for range 5 {
36+
res := K8sNameHash(*p.Key...)
37+
Expect(res).To(Equal(p.Value))
38+
}
4039
}
40+
})
4141

42-
// ensure the result is a valid DNS1123Subdomain
43-
if errs := validation.IsDNS1123Subdomain(res); errs != nil {
44-
t.Errorf("value %q is invalid: %v", res, errs)
45-
}
42+
It("should generate different hashes for different input values", func() {
43+
res1 := K8sNameHash(*testData[0].Key...)
44+
res2 := K8sNameHash(*testData[1].Key...)
45+
res3 := K8sNameHash(*testData[2].Key...)
46+
Expect(res1).NotTo(Equal(res2))
47+
Expect(res1).NotTo(Equal(res3))
48+
Expect(res2).NotTo(Equal(res3))
49+
})
4650

51+
It("should generate a valid DNS1123Subdomain", func() {
52+
for _, p := range testData {
53+
res := K8sNameHash(*p.Key...)
54+
errs := validation.IsDNS1123Subdomain(res)
55+
Expect(errs).To(BeEmpty(), fmt.Sprintf("value %q is invalid: %v", res, errs))
56+
}
4757
})
48-
}
4958

50-
}
59+
})
60+
61+
})

0 commit comments

Comments
 (0)