diff --git a/.golangci.yml b/.golangci.yml index 1aea9b0..f87625a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -140,7 +140,7 @@ linters-settings: default-signifies-exhaustive: false funlen: lines: 65 - statements: 40 + statements: 50 gocognit: min-complexity: 25 gocyclo: diff --git a/core/util/testutil/map_extract.go b/core/util/testutil/map_extract.go new file mode 100644 index 0000000..f1c1ee5 --- /dev/null +++ b/core/util/testutil/map_extract.go @@ -0,0 +1,138 @@ +package testutil + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "testing" +) + +// MapValue extracts nested map values using dot notation. Keys are separated by dots, slices and arrays can be +// accessed by integer indexes. +// Example: +// +// MapValue(m, "key1") // Access value with key "key1" +// MapValue(m, "key1.key2") // Access nested value with key "key2" +// MapValue(m, "key1.key2.key3") // Access nested value with key "key3" +// MapValue(m, "key1.key2.key3.0") // Access the first slice / array element in the nested map. +func MapValue(data interface{}, path string) (interface{}, error) { + if path == "" { + return data, nil + } + + parts := strings.Split(path, ".") + current := data + + for i, part := range parts { + v := reflect.ValueOf(current) + + if v.Kind() == reflect.Map { + converted, err := convertKeyToKind(part, v.Type().Key().Kind()) + if err != nil { + return nil, err + } + + keyValue := reflect.ValueOf(converted) + valueValue := v.MapIndex(keyValue) + + if !valueValue.IsValid() { + return nil, fmt.Errorf("key '%s' not found at path '%s'", + part, strings.Join(parts[:i], ".")) + } + + current = valueValue.Interface() + + continue + } + + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + index, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("'%s' is not a valid slice / array index", part) + } + + if index < 0 || index >= v.Len() { + return nil, fmt.Errorf("index %d out of bounds for %s of length %d at path '%s'", + index, v.Kind().String(), v.Len(), strings.Join(parts[:i], ".")) + } + + current = v.Index(index).Interface() + + continue + } + + return nil, fmt.Errorf("value at path '%s' is not a map, slice or array", + strings.Join(parts[:i], ".")) + } + + return current, nil +} + +// AssertMapValue is the MapValue variant useful in tests. +func AssertMapValue(t *testing.T, data interface{}, path string) interface{} { + val, err := MapValue(data, path) + if err != nil { + t.Error(err) + } + return val +} + +// MustMapValue is the same as MapValue but it panics in case of error. +func MustMapValue(data interface{}, path string) interface{} { + val, err := MapValue(data, path) + if err != nil { + panic(err) + } + return val +} + +// convertKeyToKind converts a string to the given kind. +func convertKeyToKind(part string, kind reflect.Kind) (interface{}, error) { + switch kind { //nolint:exhaustive + case reflect.String: + return part, nil + case reflect.Bool: + return part == "true" || part == "1", nil + case reflect.Int: + return strconv.Atoi(part) + case reflect.Int8: + val, err := strconv.ParseInt(part, 10, 8) + return int8(val), err + case reflect.Int16: + val, err := strconv.ParseInt(part, 10, 16) + return int16(val), err + case reflect.Int32: + val, err := strconv.ParseInt(part, 10, 32) + return int32(val), err + case reflect.Int64: + val, err := strconv.ParseInt(part, 10, 64) + return val, err + case reflect.Uint: + val, err := strconv.ParseUint(part, 10, 32) + return uint(val), err + case reflect.Uint8: + val, err := strconv.ParseUint(part, 10, 8) + return uint8(val), err + case reflect.Uint16: + val, err := strconv.ParseUint(part, 10, 16) + return uint16(val), err + case reflect.Uint32: + val, err := strconv.ParseUint(part, 10, 32) + return uint32(val), err + case reflect.Uint64: + val, err := strconv.ParseUint(part, 10, 64) + return val, err + case reflect.Uintptr: + val, err := strconv.ParseUint(part, 10, 64) + return uintptr(val), err + case reflect.Float32: + val, err := strconv.ParseFloat(strings.Replace(part, ",", ".", 1), 32) + return float32(val), err + case reflect.Float64: + val, err := strconv.ParseFloat(strings.Replace(part, ",", ".", 1), 64) + return val, err + default: + return nil, fmt.Errorf("unsupported reflect.Kind: %s", kind) + } +} diff --git a/core/util/testutil/map_extract_test.go b/core/util/testutil/map_extract_test.go new file mode 100644 index 0000000..a902bef --- /dev/null +++ b/core/util/testutil/map_extract_test.go @@ -0,0 +1,108 @@ +package testutil + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" +) + +func TestMapValue_DifferentKeyTypes(t *testing.T) { + mString := map[string]interface{}{"key": 1} + mInt := map[int]interface{}{2: 1} + mInt8 := map[int8]int{2: 1} + mInt16 := map[int16]int{2: 1} + mInt32 := map[int32]int{2: 1} + mInt64 := map[int64]int{2: 1} + mUInt := map[uint]interface{}{2: 1} + mUInt8 := map[uint8]int{2: 1} + mUInt16 := map[uint16]int{2: 1} + mUInt32 := map[uint32]int{2: 1} + mUInt64 := map[uint64]int{2: 1} + mUIntptr := map[uintptr]int{2: 1} + mFloat32 := map[float32]int{2.5: 1} + mFloat64 := map[float64]int{2.5: 1} + + assert.Equal(t, 1, MustMapValue(mString, "key").(int)) + assert.Equal(t, 1, MustMapValue(mInt, "2").(int)) + assert.Equal(t, 1, MustMapValue(mInt8, "2").(int)) + assert.Equal(t, 1, MustMapValue(mInt16, "2").(int)) + assert.Equal(t, 1, MustMapValue(mInt32, "2").(int)) + assert.Equal(t, 1, MustMapValue(mInt64, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUInt, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUInt8, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUInt16, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUInt32, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUInt64, "2").(int)) + assert.Equal(t, 1, MustMapValue(mUIntptr, "2").(int)) + assert.Equal(t, 1, MustMapValue(mFloat32, "2,5").(int)) + assert.Equal(t, 1, MustMapValue(mFloat64, "2,5").(int)) +} + +func TestMapValue_ErrorUnsupportedKeyType(t *testing.T) { + _, err := MapValue(map[complex64]interface{}{}, "key") + require.Error(t, err) + assert.Equal(t, "unsupported reflect.Kind: complex64", err.Error()) +} + +func TestMapValue_Nested(t *testing.T) { + assert.Equal(t, "value", MustMapValue(map[string]map[string]interface{}{ + "key1": { + "key2": "value", + }, + }, "key1.key2").(string)) + + m := map[string]interface{}{ + "key1": map[string]map[string]interface{}{ + "key2": { + "key3": "value", + }, + }, + "key4": []interface{}{ + "value5", + map[string]interface{}{ + "key6": "value7", + }, + }, + } + assert.Equal(t, "value", MustMapValue(m, "key1.key2.key3").(string)) + assert.Equal(t, "value", MustMapValue(m, "key1.key2").(map[string]interface{})["key3"].(string)) + assert.Equal(t, "value5", MustMapValue(m, "key4.0")) + assert.Equal(t, "value7", MustMapValue(m, "key4.1.key6")) + assert.Equal(t, "value", MustMapValue([]map[string]string{ + {"key": "value"}, + }, "0.key")) +} + +func TestMapValue_ErrorNotAMap(t *testing.T) { + _, err := MapValue(1, "key") + require.Error(t, err) + assert.Equal(t, "value at path '' is not a map, slice or array", err.Error()) + + _, err = MapValue(map[string]int{"key": 1}, "key.key2") + require.Error(t, err) + assert.Equal(t, "value at path 'key' is not a map, slice or array", err.Error()) +} + +func TestMapValue_ErrorKeyNotFound(t *testing.T) { + _, err := MapValue(map[string]int{"key": 1}, "key2") + require.Error(t, err) + assert.Equal(t, "key 'key2' not found at path ''", err.Error()) + + _, err = MapValue(map[string]map[string]int{"key": {"key2": 1}}, "key.key3") + require.Error(t, err) + assert.Equal(t, "key 'key3' not found at path 'key'", err.Error()) +} + +func TestMapValue_ErrorOutOfBounds(t *testing.T) { + _, err := MapValue(map[string][]int{"key": {1}}, "key.1") + require.Error(t, err) + assert.Equal(t, "index 1 out of bounds for slice of length 1 at path 'key'", err.Error()) +} + +func TestMustMapValue_Panics(t *testing.T) { + assert.Panics(t, func() { + MustMapValue(map[string]int{"key": 1}, "key2") + }) +} diff --git a/go.mod b/go.mod index edf4449..93f3363 100644 --- a/go.mod +++ b/go.mod @@ -92,4 +92,4 @@ require ( google.golang.org/protobuf v1.34.1 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) +) \ No newline at end of file diff --git a/go.sum b/go.sum index a97671f..0586d1a 100644 --- a/go.sum +++ b/go.sum @@ -625,4 +625,4 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= \ No newline at end of file