Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 24 additions & 3 deletions internal/datautils/maps.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)

Expand All @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions internal/datautils/maps_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
41 changes: 41 additions & 0 deletions internal/datautils/slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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
}
139 changes: 139 additions & 0 deletions internal/datautils/slice_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}