diff --git a/internal/datautils/maps.go b/internal/datautils/maps.go index e0e37d8dcf..ab26284570 100644 --- a/internal/datautils/maps.go +++ b/internal/datautils/maps.go @@ -1,7 +1,12 @@ // nolint:ireturn package datautils -import "encoding/json" +import ( + "encoding/gob" + "encoding/json" + + "github.com/amp-labs/connectors/internal/goutils" +) // Map is a generic version of map with useful methods. // It can return Keys as a slice or a Set. @@ -19,8 +24,10 @@ func FromMap[K comparable, V any](source map[K]V) Map[K, V] { return source } -// ShallowCopy performs copying which should cover most cases. -// For deep copy you could use goutils.Clone. +// ShallowCopy creates a shallow copy of the map. +// It copies the top-level keys and values, but does not clone +// nested or referenced objects. Use this when you only need +// a separate map container, not deep copies of the values. func (m Map[K, V]) ShallowCopy() Map[K, V] { result := make(map[K]V) @@ -31,6 +38,20 @@ func (m Map[K, V]) ShallowCopy() Map[K, V] { return result } +func init() { + gob.Register(Map[string, any]{}) +} + +// DeepCopy creates a deep copy of the map using `goutils.Clone`. +// +// Internally this uses `encoding/gob`, so all concrete key/value types +// must be registered with `gob.Register` before use. +// +// Register the missing types (e.g. `gob.Register(MyStruct{})`) before calling DeepCopy. +func (m Map[K, V]) DeepCopy() (Map[K, V], error) { + return goutils.Clone(m) +} + func (m Map[K, V]) Keys() []K { keys := make([]K, 0) for k := range m { diff --git a/internal/datautils/maps_test.go b/internal/datautils/maps_test.go new file mode 100644 index 0000000000..72964b5af6 --- /dev/null +++ b/internal/datautils/maps_test.go @@ -0,0 +1,104 @@ +package datautils + +import ( + "encoding/gob" + "testing" + + "github.com/amp-labs/connectors/test/utils/testutils" +) + +func TestMapDeepCopy(t *testing.T) { // nolint:funlen + t.Parallel() + + type Sample struct { + ID int + Name string + } + + // Must register with gob types that would be used with deep copy. + gob.Register(Sample{}) + + tests := []struct { + name string + input Map[string, any] + modifyCopy func(Map[string, any]) + expected Map[string, any] + expectError error + }{ + { + name: "Empty map", + input: Map[string, any]{}, + modifyCopy: nil, + expected: Map[string, any]{}, + expectError: nil, + }, + { + name: "Simple primitive values", + input: Map[string, any]{ + "a": 1, + "b": "text", + }, + modifyCopy: func(c Map[string, any]) { + c["a"] = 99 // should not affect original + }, + expected: Map[string, any]{ + "a": 1, + "b": "text", + }, + expectError: nil, + }, + { + name: "Nested map structure", + input: Map[string, any]{ + "config": Map[string, any]{ + "enabled": true, + "retries": 3, + }, + }, + modifyCopy: func(c Map[string, any]) { + nested := c["config"].(Map[string, any]) // nolint:forcetypeassert + nested["retries"] = 10 // change only copy + }, + expected: Map[string, any]{ + "config": Map[string, any]{ + "enabled": true, + "retries": 3, + }, + }, + expectError: nil, + }, + { + name: "Complex object values", + input: Map[string, any]{ + "user": Sample{ID: 1, Name: "Alice"}, + }, + modifyCopy: func(c Map[string, any]) { + user := c["user"].(Sample) // nolint:forcetypeassert + user.Name = "Bob" // should not affect original + c["user"] = user + }, + expected: Map[string, any]{ + "user": Sample{ID: 1, Name: "Alice"}, + }, + expectError: nil, + }, + } + + for _, tt := range tests { // nolint:varnamelen + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + replica, err := tt.input.DeepCopy() + + testutils.CheckOutputWithError(t, tt.name, tt.expected, tt.expectError, replica, err) + + // If no error and we have a modifyCopy function, verify deep copy isolation + if err == nil && tt.modifyCopy != nil { + tt.modifyCopy(replica) + + // Recheck that original is unchanged after mutation of copy + testutils.CheckOutputWithError(t, tt.name+" (post-mutation)", tt.expected, nil, tt.input, nil) + } + }) + } +} diff --git a/internal/datautils/slice.go b/internal/datautils/slice.go index 04b7528fc9..6998f3422f 100644 --- a/internal/datautils/slice.go +++ b/internal/datautils/slice.go @@ -32,6 +32,24 @@ func SliceToMap[K comparable, V any](list []V, makeKey func(V) K) Map[K, V] { return result } +// ToAnySlice converts a slice of any type T to a slice of empty interface values ([]any). +// This is useful when you need to pass a typed slice to functions that expect []any. +// +// Example: +// +// ints := []int{1, 2, 3} +// anySlice := datautils.ToAnySlice(ints) +// // anySlice == []any{1, 2, 3} +func ToAnySlice[T any](slice []T) []any { + result := make([]any, len(slice)) + + for i, v := range slice { + result[i] = v + } + + return result +} + // ForEach applies the provided function f to each element of s, // returning a new slice containing the results. // @@ -52,3 +70,26 @@ func ForEach[F, T any](input []F, mapper func(F) T) []T { return output } + +// ForEachWithErr applies the provided mapper function to each element of the input slice. +// If the mapper returns an error for any element, the function returns immediately with that error. +// Otherwise, it returns a new slice containing the mapped results. +func ForEachWithErr[F, T any](input []F, mapper func(F) (T, error)) ([]T, error) { + if len(input) == 0 { + return make([]T, 0), nil + } + + var ( + err error + output = make([]T, len(input)) + ) + + for index, value := range input { + output[index], err = mapper(value) + if err != nil { + return nil, err + } + } + + return output, nil +} diff --git a/internal/datautils/slice_test.go b/internal/datautils/slice_test.go new file mode 100644 index 0000000000..8b6a4336b7 --- /dev/null +++ b/internal/datautils/slice_test.go @@ -0,0 +1,139 @@ +package datautils + +import ( + "errors" + "strconv" + "testing" + + "github.com/amp-labs/connectors/test/utils/testutils" +) + +func TestForEachWithErr(t *testing.T) { // nolint:funlen + t.Parallel() + + type mapper func(string) (int, error) + + problem := errors.New("awesome custom error") // nolint:err113 + + tests := []struct { + name string + input []string + mapper mapper + expected []int + expectError error + }{ + { + name: "Empty input slice", + input: []string{}, + mapper: func(text string) (int, error) { + return len(text), nil + }, + expected: []int{}, + expectError: nil, + }, + { + name: "All valid conversions", + input: []string{"1", "2", "3"}, + mapper: strconv.Atoi, + expected: []int{1, 2, 3}, + expectError: nil, + }, + { + name: "Contains invalid conversion", + input: []string{"10", "x", "30"}, + mapper: func(text string) (int, error) { + num, err := strconv.Atoi(text) + if err != nil { + return 0, problem + } + + return num, nil + }, + expected: nil, + expectError: problem, + }, + { + name: "Custom mapper that errors on specific input", + input: []string{"a", "b", "error", "c"}, + mapper: func(text string) (int, error) { + if text == "error" { + return 0, problem + } + + return len(text), nil + }, + expected: nil, + expectError: problem, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output, err := ForEachWithErr(tt.input, tt.mapper) + testutils.CheckOutputWithError(t, tt.name, tt.expected, tt.expectError, output, err) + }) + } +} + +func TestToAnySlice(t *testing.T) { + t.Parallel() + + type order struct{ ID int } + + tests := []struct { + name string + input any + expected []any + }{ + { + name: "Empty slice", + input: []int{}, + expected: []any{}, + }, + { + name: "Integers", + input: []int{1, 2, 3}, + expected: []any{1, 2, 3}, + }, + { + name: "Strings", + input: []string{"a", "b", "c"}, + expected: []any{"a", "b", "c"}, + }, + { + name: "Booleans", + input: []bool{true, false, true}, + expected: []any{true, false, true}, + }, + { + name: "Structs", + input: []order{{1}, {2}}, + expected: []any{order{1}, order{2}}, + }, + } + + for _, tt := range tests { // nolint:varnamelen + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Use generic that corresponds to each data type. + var output []any + switch v := tt.input.(type) { + case []int: + output = ToAnySlice(v) + case []string: + output = ToAnySlice(v) + case []bool: + output = ToAnySlice(v) + case []order: + output = ToAnySlice(v) + default: + t.Fatalf("unsupported test input type %T", v) + } + + testutils.CheckOutputWithError(t, tt.name, tt.expected, nil, output, nil) + }) + } +}