From aacc274318fc96e3eaa076e81e623407b09c8237 Mon Sep 17 00:00:00 2001 From: Michael Bolot Date: Thu, 12 Oct 2023 13:01:41 -0500 Subject: [PATCH] Adding field logic to data package Added a new Field struct which provides the ability to search through unstructed data (such as the data returned from a json call), and easily add or remove values to this data. --- go.mod | 1 + go.sum | 2 + pkg/data/errors.go | 50 +++ pkg/data/field.go | 381 +++++++++++++++++++ pkg/data/field_test.go | 818 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1252 insertions(+) create mode 100644 pkg/data/errors.go create mode 100644 pkg/data/field.go create mode 100644 pkg/data/field_test.go diff --git a/go.mod b/go.mod index 902f112e..2283753d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/rancher/lasso v0.0.0-20230629200414-8a54b32e6792 github.com/sirupsen/logrus v1.9.0 github.com/stretchr/testify v1.8.2 + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e golang.org/x/sync v0.2.0 golang.org/x/text v0.11.0 golang.org/x/tools v0.8.0 diff --git a/go.sum b/go.sum index e2caa9a1..54419a8b 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= diff --git a/pkg/data/errors.go b/pkg/data/errors.go new file mode 100644 index 00000000..2f98e20f --- /dev/null +++ b/pkg/data/errors.go @@ -0,0 +1,50 @@ +package data + +type errorCode int + +const ( + errorInvalidData errorCode = iota + 1 + errorInvalidField + errorFieldValueNotFound +) + +type DataError struct { + message string + code errorCode +} + +// Error returns the underlying error message, satisfying the error interface +func (d *DataError) Error() string { + return d.message +} + +func newDataError(message string, code errorCode) *DataError { + return &DataError{ + message: message, + code: code, + } +} + +// IsInvalidDataError checks if a given error indicates that the provided data field was invalid. +func IsInvalidDataError(err error) bool { + return checkErrTypeAndCode(err, errorInvalidData) +} + +// IsInvalidFieldError checks if a given error indicates that the provided field was invalid. +func IsInvalidFieldError(err error) bool { + return checkErrTypeAndCode(err, errorInvalidField) +} + +// IsFieldValueNotFoundError checks if a given error indicates that the provided field was not found in the provided +// data. +func IsFieldValueNotFoundError(err error) bool { + return checkErrTypeAndCode(err, errorFieldValueNotFound) +} + +func checkErrTypeAndCode(err error, code errorCode) bool { + dataErr, ok := err.(*DataError) + if !ok { + return false + } + return dataErr.code == code +} diff --git a/pkg/data/field.go b/pkg/data/field.go new file mode 100644 index 00000000..bfcd0669 --- /dev/null +++ b/pkg/data/field.go @@ -0,0 +1,381 @@ +package data + +import ( + "fmt" + + "golang.org/x/exp/slices" +) + +// A Field represents the path to the current field in an unstructured object +type Field struct { + // Name is the name of this field in the parent object - only usable for map entries + Name *string + + // ListIndex is the index of this object in the parent array - only usable for array entries + ListIndex *int + + // SubField is the "next field" in the path to a leaf node. + SubField *Field + + // root is the parent object that starts the chain to a leaf field. Should not be directly set by a caller. + root *Field +} + +func NewNameField(name string) *Field { + return &Field{ + Name: &name, + } +} + +func NewIndexField(index int) *Field { + return &Field{ + ListIndex: &index, + } +} + +// Child adds a child field (e.x. entry in a map) to the current field and returns the new field. Can return nil +// if it encounters a cycle (f1.SubField == f2, f2.SubField == f1) in f. +func (f *Field) Child(subfield string) *Field { + if f == nil { + return nil + } + child := &Field{ + Name: &subfield, + } + return f.copyTreeToChild(child) +} + +// Index adds the index of a child field of an array to the current field and returns the new field. Can return nil +// if it encounters a cycle (f1.SubField == f2, f2.SubField == f1) in f +func (f *Field) Index(index int) *Field { + if f == nil { + return nil + } + child := &Field{ + ListIndex: &index, + } + return f.copyTreeToChild(child) +} + +// copyTreeToChild copies the call tree of f into child, with new memory allocated for each entry. This is done +// so that f is not mutated by a child. Returns the mutated child, or nil if it encounters a cycle +func (f *Field) copyTreeToChild(child *Field) *Field { + var current *Field + if f.root == nil { + current = f.Copy() + child.root = current + } else { + current = f.Root().Copy() + // we may encounter a cycle, but don't want to loop forever, so exit with + // nil in that case + seen := map[*Field]struct{}{} + for current != f && current != nil { + if _, ok := seen[current]; ok { + return nil + } + seen[current] = struct{}{} + newCurrent := current.Copy() + current = newCurrent.SubField + } + if current == nil { + // this inidcates that we found a broken link in the path from + // root to current. Exit with nil + return nil + } + child.root = current.root + } + current.SubField = child + return child +} + +// Copy produces a copy of the current field. Does not recursively copy root or subfield, only a shallow copy +func (f *Field) Copy() *Field { + if f == nil { + return nil + } + return &Field{ + Name: f.Name, + ListIndex: f.ListIndex, + SubField: f.SubField, + root: f.root, + } +} + +// Root returns the root field, so that a caller can traverse the full path in an object to a given field +func (f *Field) Root() *Field { + return f.root +} + +// GetField gets the value for field from data. Returns the retrieved value, and an error (which may be a dataError). +// Field must be a valid leaf node (no subfield) and data must be a map[string]any or []any +func GetField(data any, field *Field) (any, error) { + recurser := getRecurser{} + _, err := runRecurse(data, field, &recurser) + if err != nil { + return nil, err + } + return recurser.found, nil +} + +// RemoveField removes field from data. Returns (in order) the modified data, the removed value, and an error (which +// may be a data error). Field must be a valid leaf node (no subfield) and data must be a map[string]any or []any +func RemoveField(data any, field *Field) (any, any, error) { + recurser := removeRecurser{} + newData, err := runRecurse(data, field, &recurser) + if err != nil { + return nil, nil, err + } + return newData, recurser.removed, nil +} + +// PutField puts value into data that the specified field. Returns the modified data and an error (which may be +// a data error). Field must be a valid leaf node (no subfield) and data must be a map[string]any or []any. If +// field (or any parent field of field) is missing, a default value will be initialized. +func PutField(data any, field *Field, value any) (any, error) { + recurser := putRecurser{ + putValue: value, + } + newData, err := runRecurse(data, field, &recurser) + if err != nil { + return nil, err + } + return newData, nil +} + +// runRecurse runs the recurse operation on data for field using recurser. Returns the modified data and +// (optionally) an error +func runRecurse(data any, field *Field, recurser onCaseRecurser) (any, error) { + // if we aren't a top-level node, recurse to the start of the path + start := field + + if field.Root() != nil { + start = field.Root() + } + newData, err := recurseInternal(data, start, field, map[*Field]struct{}{}, recurser) + if err != nil { + return nil, err + } + return newData, nil +} + +type getRecurser struct { + found any +} + +func (g *getRecurser) onFound(data any, field *Field, value any) (any, error) { + g.found = value + return data, nil +} + +func (g *getRecurser) onMissing(data any, field *Field) (any, error) { + message := fmt.Sprintf("field %v not found in data %v", field, data) + return nil, newDataError(message, errorFieldValueNotFound) +} + +type removeRecurser struct { + removed any +} + +func (r *removeRecurser) onFound(data any, field *Field, value any) (any, error) { + r.removed = value + if field.Name != nil { + mapData := data.(map[string]any) + delete(mapData, *field.Name) + return mapData, nil + } + sliceData := data.([]any) + newData := slices.Delete(sliceData, *field.ListIndex, *field.ListIndex+1) + return newData, nil +} + +func (g *removeRecurser) onMissing(data any, field *Field) (any, error) { + message := fmt.Sprintf("field %v not found in data %v", field, data) + return nil, newDataError(message, errorFieldValueNotFound) +} + +type putRecurser struct { + putValue any +} + +func (p *putRecurser) onFound(data any, field *Field, value any) (any, error) { + if field.Name != nil { + mapData := data.(map[string]any) + mapData[*field.Name] = p.putValue + return mapData, nil + } + sliceData := data.([]any) + sliceData[*field.ListIndex] = p.putValue + return sliceData, nil +} + +func (p *putRecurser) onMissing(data any, field *Field) (any, error) { + if field.Name != nil { + mapData := data.(map[string]any) + // if we have a subfield, we need to create a matching sub-entry in data + if field.SubField != nil { + if err := validateFieldNameIndex(field.SubField); err != nil { + return nil, err + } + // name subFields are for a map, so init the default value as a map; not necessary to add the + // specific value since this function will be called on the next recursion + if field.SubField.Name != nil { + mapData[*field.Name] = map[string]any{} + } else { + mapData[*field.Name] = []any{} + } + } + return mapData, nil + } + sliceData := data.([]any) + for len(sliceData) <= *field.ListIndex { + sliceData = append(sliceData, nil) + } + if field.SubField != nil { + if err := validateFieldNameIndex(field.SubField); err != nil { + return nil, err + } + // name subFields are for a map, so init the default value as a map; not necessary to add the + // specific value since this function will be called on the next recursion + if field.SubField.Name != nil { + sliceData[*field.ListIndex] = map[string]any{} + } else { + sliceData[*field.ListIndex] = []any{} + } + } + return sliceData, nil +} + +// onCaseRecurser is an interface providing methods to manipulate data when a leaf case (i.e. missing or found +// resource) is identified +type onCaseRecurser interface { + // onFound identifies what to do with a found value. Must return the modified data as a returned value, and optionally + // an error if the data/value/field could not be processed + onFound(data any, field *Field, value any) (any, error) + // onMissing identifies what to do if a field is determined to be missing. Must return the modified data as a returned + // value, and optionally an error if the data/field could not be processed + onMissing(data any, field *Field) (any, error) +} + +// recurseInternal recurses through data to field, and calls recurser.OnFound when the value is found. If a field is +// not found at any point, recurser.OnMissing is called. When onFound is called, it will return the result immediately. +// When onMissing is called, it will return an error result, but will continue on if no error was returned, after +// updating data with the returned value. Returns the modified data, and optionally, an error. +func recurseInternal(data any, current *Field, want *Field, seen map[*Field]struct{}, recurser onCaseRecurser) (any, error) { + if err := validateFieldNameIndex(current); err != nil { + return nil, err + } + _, isSeen := seen[current] + if isSeen { + message := fmt.Sprintf("cycle detected, %v was already processed", current) + return nil, newDataError(message, errorInvalidField) + } + seen[current] = struct{}{} + mapData, ok := data.(map[string]any) + if ok { + return recurseInternalMap(mapData, current, want, seen, recurser) + } + sliceData, ok := data.([]any) + if !ok { + message := fmt.Sprintf("data %v was not a map[string]any or a []any, cannot process", data) + return nil, newDataError(message, errorInvalidData) + } + return recurseInternalSlice(sliceData, current, want, seen, recurser) +} + +func recurseInternalMap(data map[string]any, current *Field, want *Field, seen map[*Field]struct{}, recurser onCaseRecurser) (map[string]any, error) { + if current.ListIndex != nil { + // this field is for a list index, but this is a map entry + message := fmt.Sprintf("field %v for data %v was a ListIndex field, but data was a map", current, data) + return nil, newDataError(message, errorInvalidField) + } + currentValue, ok := data[*current.Name] + if !ok { + // we only break if onMissing returns an error, some cases may want to keep processing if + // the item is not found + newData, err := recurser.onMissing(data, current) + if err != nil { + return nil, err + } + newDataMap := newData.(map[string]any) + data = newDataMap + // refetch the currentValue which may have been initialized by the onMissing function + currentValue = data[*current.Name] + } + if current == want { + // base case, this field is a leaf node, call onFound + newData, err := recurser.onFound(data, current, currentValue) + if err != nil { + return nil, err + } + mapData, ok := newData.(map[string]any) + if !ok { + return nil, fmt.Errorf("recurser did not produce expected map[string]any onFound, got %v", data) + } + return mapData, nil + + } else { + // this field is not a leaf, recurse to the next level + if current.SubField == nil { + message := fmt.Sprintf("unable to find target field %v; reached leaf node %v", want, current) + return nil, newDataError(message, errorInvalidField) + } + newData, err := recurseInternal(currentValue, current.SubField, want, seen, recurser) + if err != nil { + return nil, err + } + data[*current.Name] = newData + return data, nil + } +} + +func recurseInternalSlice(data []any, current *Field, want *Field, seen map[*Field]struct{}, recurser onCaseRecurser) ([]any, error) { + if current.Name != nil { + // this field is for a map index, but this is a list entry + message := fmt.Sprintf("field %v for data %v was a Name field, but data was a slice", current, data) + return nil, newDataError(message, errorInvalidField) + } + if len(data) <= *current.ListIndex { + // we only break if onMissing returns an error, some cases may want to keep processing if + // the item is not found + newData, err := recurser.onMissing(data, current) + if err != nil { + return nil, err + } + newDataSlice := newData.([]any) + data = newDataSlice + } + currentValue := data[*current.ListIndex] + if current == want { + // base case, this field is a leaf node, extract and return + newData, err := recurser.onFound(data, current, currentValue) + if err != nil { + return nil, err + } + sliceData, ok := newData.([]any) + if !ok { + return nil, fmt.Errorf("recurser did not produce expected []any onFound, got %v", data) + } + return sliceData, nil + } else { + // this field is not a leaf node, recurse to the next level + if current.SubField == nil { + message := fmt.Sprintf("unable to find target field %v; reached leaf node %v", want, current) + return nil, newDataError(message, errorInvalidField) + } + newData, err := recurseInternal(currentValue, current.SubField, want, seen, recurser) + if err != nil { + return nil, err + } + data[*current.ListIndex] = newData + return data, nil + } +} + +// validateFieldNameIndex valides that field has only one of Name/ListIndex set. Will return an error if this is not the case. +func validateFieldNameIndex(field *Field) error { + if field.Name == nil && field.ListIndex == nil || field.Name != nil && field.ListIndex != nil { + message := fmt.Sprintf("field must have exactly one of name or index set, had name: %v, index: %v", field.Name, field.ListIndex) + return newDataError(message, errorInvalidField) + } + return nil +} diff --git a/pkg/data/field_test.go b/pkg/data/field_test.go new file mode 100644 index 00000000..5f837b6b --- /dev/null +++ b/pkg/data/field_test.go @@ -0,0 +1,818 @@ +package data + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type FieldTestSuite struct { + suite.Suite + brokenMapField *Field + brokenSliceField *Field + loopField *Field + invalidNameIndexField *Field + invalidNoNameIndexField *Field +} + +func TestField(t *testing.T) { + t.Parallel() + suite.Run(t, new(FieldTestSuite)) +} + +func (f *FieldTestSuite) SetupSuite() { + rootField := NewNameField("test") + rootField.SubField = nil + brokenMapField := NewNameField("nested") + brokenMapField.root = rootField + + rootSlice := NewIndexField(0) + rootField.SubField = nil + brokenSliceField := NewIndexField(0) + brokenSliceField.root = rootSlice + + rootCopy := rootField.Copy() + child := NewNameField("child") + rootCopy.SubField = child + child.SubField = rootCopy + loopField := NewNameField("nested") + loopField.root = rootCopy + + invalidIndex := 1 + invalidFieldNameAndIndex := NewNameField("test") + invalidFieldNameAndIndex.ListIndex = &invalidIndex + + invalidFieldNoNameAndIndex := &Field{} + + f.brokenMapField = brokenMapField + f.brokenSliceField = brokenSliceField + f.loopField = loopField + f.invalidNameIndexField = invalidFieldNameAndIndex + f.invalidNoNameIndexField = invalidFieldNoNameAndIndex +} + +func (f *FieldTestSuite) TestChild() { + f.T().Parallel() + metadata := NewNameField("metadata") + annotations := metadata.Child("annotations") + f.Require().Nil(metadata.SubField) + f.Require().Equal("metadata", *annotations.Root().Name) + // circular field + metadata.SubField = annotations + annotations.SubField = metadata + loop := NewNameField("loop") + loop.root = metadata + f.Require().Nil(loop.Child("test")) + // test nil values can be chained + f.Require().Nil(loop.Child("test").Child("next")) + // test root value + nested := NewNameField("metadata").Child("annotations").Child("nested") + f.Require().NotNil(nested) + f.Require().NotNil(nested.Root()) + f.Require().Equal("metadata", *nested.Root().Name) + // test broken chain from root + metadata.SubField = nil + annotations.root = metadata + f.Require().Nil(annotations.Child("test")) +} + +func (f *FieldTestSuite) TestIndex() { + f.T().Parallel() + start := NewIndexField(0) + next := start.Index(1) + f.Require().Nil(start.SubField) + f.Require().Equal(0, *next.Root().ListIndex) + // circular field + start.SubField = next + next.SubField = start + loop := NewIndexField(2) + loop.root = start + f.Require().Nil(loop.Index(1)) + // test nil values can be chained + f.Require().Nil(loop.Index(1).Index(0)) + // test root value + nested := NewIndexField(0).Index(1).Index(2) + f.Require().NotNil(nested) + f.Require().NotNil(nested.Root()) + f.Require().Equal(0, *nested.Root().ListIndex) + // test broken chain from root + start.SubField = nil + next.root = start + f.Require().Nil(next.Index(1)) +} + +func (f *FieldTestSuite) TestCopy() { + f.T().Parallel() + start := NewNameField("test").Index(0).Root() + startCopy := start.Copy() + // the values should be equal, but they should now refer to different memory + f.Require().Equal(start, startCopy) + f.Require().False(start == startCopy) + var nilField *Field + f.Require().Nil(nilField.Copy()) +} + +func (f *FieldTestSuite) TestGetField() { + tests := []struct { + name string + data any + field *Field + want any + wantError bool + wantErrorCode errorCode //ignored if wantError == false + }{ + { + name: "basic map", + data: map[string]any{ + "test": "val", + }, + field: NewNameField("test"), + want: "val", + }, + { + name: "nested map", + data: map[string]any{ + "test": map[string]any{ + "val": "nested", + }, + }, + field: NewNameField("test").Child("val"), + want: "nested", + }, + { + name: "basic slice", + data: []any{"value", "other"}, + field: NewIndexField(1), + want: "other", + }, + { + name: "nested slice", + data: []any{"value", []any{"other"}}, + field: NewIndexField(1).Index(0), + want: "other", + }, + { + name: "nested slice in a map", + data: map[string]any{ + "test": []any{"nested"}, + }, + field: NewNameField("test").Index(0), + want: "nested", + }, + { + name: "nested map in a slice", + data: []any{"some", map[string]any{ + "test": "nested", + }}, + field: NewIndexField(1).Child("test"), + want: "nested", + }, + { + name: "deeply nested", + data: map[string]any{ + "test": []any{ + map[string]any{ + "nested": "value", + }, + }, + }, + field: NewNameField("test").Index(0).Child("nested"), + want: "value", + }, + { + name: "missing value map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: NewNameField("test").Child("notFound"), + wantError: true, + wantErrorCode: errorFieldValueNotFound, + }, + { + name: "missing value slice", + data: []any{[]any{"value"}}, + field: NewIndexField(0).Index(1), + wantError: true, + wantErrorCode: errorFieldValueNotFound, + }, + { + name: "invalid value", + data: map[string]any{ + "test": []string{"hello"}, + }, + field: NewNameField("test").Index(0), + wantError: true, + wantErrorCode: errorInvalidData, + }, + { + name: "listIndex given for a map", + data: map[string]any{ + "test": map[string]any{ + "nested": "true", + }, + }, + field: NewNameField("test").Index(0), + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "name given for a slice", + data: map[string]any{ + "test": []any{"nested"}, + }, + field: NewNameField("test").Child("nested"), + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: f.brokenMapField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link slice", + data: []any{[]any{"nested"}}, + field: f.brokenSliceField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "nested loop", + data: map[string]any{ + "test": map[string]any{ + "child": map[string]any{ + "nested": "val", + }, + }, + }, + field: f.loopField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field name + index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNameIndexField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field no name or index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNoNameIndexField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + } + for _, test := range tests { + test := test + f.Run(test.name, func() { + f.T().Parallel() + got, gotError := GetField(test.data, test.field) + if !test.wantError { + f.Require().Nil(gotError) + f.Require().Equal(test.want, got) + return + } + f.Require().NotNil(gotError) + switch test.wantErrorCode { + // don't validate error code if its not a known code + case errorInvalidData: + f.Require().True(IsInvalidDataError(gotError)) + case errorInvalidField: + f.Require().True(IsInvalidFieldError(gotError)) + case errorFieldValueNotFound: + f.Require().True(IsFieldValueNotFoundError(gotError)) + } + }) + } +} + +func (f *FieldTestSuite) TestRemoveField() { + tests := []struct { + name string + data any + field *Field + wantData any + want any + wantError bool + wantErrorCode errorCode //ignored if wantError == false + }{ + { + name: "basic map", + data: map[string]any{ + "test": "val", + }, + wantData: map[string]any{}, + field: NewNameField("test"), + want: "val", + }, + { + name: "nested map", + data: map[string]any{ + "test": map[string]any{ + "val": "nested", + }, + }, + wantData: map[string]any{ + "test": map[string]any{}, + }, + field: NewNameField("test").Child("val"), + want: "nested", + }, + { + name: "basic slice", + data: []any{"value", "other"}, + wantData: []any{"value"}, + field: NewIndexField(1), + want: "other", + }, + { + name: "nested slice", + data: []any{"value", []any{"other"}}, + wantData: []any{"value", []any{}}, + field: NewIndexField(1).Index(0), + want: "other", + }, + { + name: "nested slice in a map", + data: map[string]any{ + "test": []any{"nested"}, + }, + wantData: map[string]any{ + "test": []any{}, + }, + field: NewNameField("test").Index(0), + want: "nested", + }, + { + name: "nested map in a slice", + data: []any{"some", map[string]any{ + "test": "nested", + }}, + wantData: []any{"some", map[string]any{}}, + field: NewIndexField(1).Child("test"), + want: "nested", + }, + { + name: "deeply nested", + data: map[string]any{ + "test": []any{ + map[string]any{ + "nested": "value", + }, + }, + }, + wantData: map[string]any{ + "test": []any{ + map[string]any{}, + }, + }, + field: NewNameField("test").Index(0).Child("nested"), + want: "value", + }, + { + name: "missing value map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: NewNameField("test").Child("notFound"), + wantError: true, + wantErrorCode: errorFieldValueNotFound, + }, + { + name: "missing value slice", + data: []any{[]any{"value"}}, + field: NewIndexField(0).Index(1), + wantError: true, + wantErrorCode: errorFieldValueNotFound, + }, + { + name: "invalid value", + data: map[string]any{ + "test": []string{"hello"}, + }, + field: NewNameField("test").Index(0), + wantError: true, + wantErrorCode: errorInvalidData, + }, + { + name: "listIndex given for a map", + data: map[string]any{ + "test": map[string]any{ + "nested": "true", + }, + }, + field: NewNameField("test").Index(0), + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "name given for a slice", + data: map[string]any{ + "test": []any{"nested"}, + }, + field: NewNameField("test").Child("nested"), + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: f.brokenMapField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link slice", + data: []any{[]any{"nested"}}, + field: f.brokenSliceField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "nested loop", + data: map[string]any{ + "test": map[string]any{ + "child": map[string]any{ + "nested": "val", + }, + }, + }, + field: f.loopField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field name + index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNameIndexField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field no name or index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNoNameIndexField, + wantError: true, + wantErrorCode: errorInvalidField, + }, + } + for _, test := range tests { + test := test + f.Run(test.name, func() { + f.T().Parallel() + gotData, gotRemoved, gotError := RemoveField(test.data, test.field) + if !test.wantError { + f.Require().Nil(gotError) + f.Require().Equal(test.want, gotRemoved) + f.Require().Equal(test.wantData, gotData) + return + } + f.Require().NotNil(gotError) + switch test.wantErrorCode { + // don't validate error code if its not a known code + case errorInvalidData: + f.Require().True(IsInvalidDataError(gotError)) + case errorInvalidField: + f.Require().True(IsInvalidFieldError(gotError)) + case errorFieldValueNotFound: + f.Require().True(IsFieldValueNotFoundError(gotError)) + } + }) + } +} + +func (f *FieldTestSuite) TestPutField() { + invalidIdx := 1 + invalidName := "invalid" + + invalidMapEntry := NewNameField("test").Child("notFound").Child("notFoundNext") + invalidMapEntry.ListIndex = &invalidIdx + + invalidSliceEntry := NewIndexField(0).Index(1).Index(1) + invalidSliceEntry.Name = &invalidName + + tests := []struct { + name string + data any + field *Field + value any + wantData any + wantError bool + wantErrorCode errorCode //ignored if wantError == false + }{ + { + name: "basic map", + data: map[string]any{ + "test": "val", + }, + field: NewNameField("key"), + value: "value", + wantData: map[string]any{ + "test": "val", + "key": "value", + }, + }, + { + name: "nested map", + data: map[string]any{ + "test": map[string]any{ + "val": "nested", + }, + }, + field: NewNameField("test").Child("key"), + value: "value", + wantData: map[string]any{ + "test": map[string]any{ + "val": "nested", + "key": "value", + }, + }, + }, + { + name: "basic slice", + data: []any{"value"}, + field: NewIndexField(1), + value: "other", + wantData: []any{"value", "other"}, + }, + { + name: "nested slice", + data: []any{"value"}, + field: NewIndexField(1).Index(0), + value: "other", + wantData: []any{"value", []any{"other"}}, + }, + { + name: "nested slice in a map", + data: map[string]any{ + "test": []any{}, + }, + field: NewNameField("test").Index(0), + value: "nested", + wantData: map[string]any{ + "test": []any{"nested"}, + }, + }, + { + name: "nested map in a slice", + data: []any{"some", map[string]any{}}, + field: NewIndexField(1).Child("test"), + value: "nested", + wantData: []any{"some", map[string]any{ + "test": "nested", + }}, + }, + { + name: "deeply nested", + data: map[string]any{ + "test": []any{ + map[string]any{ + "nested": "value", + }, + }, + }, + wantData: map[string]any{ + "test": []any{ + map[string]any{ + "nested": "value", + "nested2": "value2", + }, + }, + }, + field: NewNameField("test").Index(0).Child("nested2"), + value: "value2", + }, + { + name: "missing value map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: NewNameField("test").Child("notFound"), + value: "newValue", + wantData: map[string]any{ + "test": map[string]any{ + "nested": "value", + "notFound": "newValue", + }, + }, + }, + { + name: "missing value map, need submap", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: NewNameField("test").Child("notFound").Child("notFoundNext"), + value: "newValue", + wantData: map[string]any{ + "test": map[string]any{ + "nested": "value", + "notFound": map[string]any{ + "notFoundNext": "newValue", + }, + }, + }, + }, + { + name: "missing value map, need subslice", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: NewNameField("test").Child("notFound").Index(1), + value: "newValue", + wantData: map[string]any{ + "test": map[string]any{ + "nested": "value", + "notFound": []any{nil, "newValue"}, + }, + }, + }, + { + name: "missing value map, invalid sub-entry", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: invalidMapEntry, + value: "newValue", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "missing value slice", + data: []any{[]any{"value"}}, + field: NewIndexField(0).Index(1), + value: "newValue", + wantData: []any{[]any{"value", "newValue"}}, + }, + { + name: "missing value slice, need submap", + data: []any{[]any{"value"}}, + field: NewIndexField(0).Index(1).Child("nested"), + value: "newValue", + wantData: []any{[]any{"value", map[string]any{"nested": "newValue"}}}, + }, + { + name: "missing value slice, need subslice", + data: []any{[]any{"value"}}, + field: NewIndexField(0).Index(1).Index(1), + value: "newValue", + wantData: []any{[]any{"value", []any{nil, "newValue"}}}, + }, + { + name: "missing value slice, invalid sub-entry", + data: []any{[]any{"value"}}, + field: invalidSliceEntry, + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid value", + data: map[string]any{ + "test": []string{"hello"}, + }, + field: NewNameField("test").Index(0), + value: "value", + wantError: true, + wantErrorCode: errorInvalidData, + }, + { + name: "listIndex given for a map", + data: map[string]any{ + "test": map[string]any{ + "nested": "true", + }, + }, + field: NewNameField("test").Index(0), + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "name given for a slice", + data: map[string]any{ + "test": []any{"nested"}, + }, + field: NewNameField("test").Child("nested"), + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link map", + data: map[string]any{ + "test": map[string]any{ + "nested": "value", + }, + }, + field: f.brokenMapField, + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "broken link slice", + data: []any{[]any{"nested"}}, + field: f.brokenSliceField, + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "nested loop", + data: map[string]any{ + "test": map[string]any{ + "child": map[string]any{ + "nested": "val", + }, + }, + }, + field: f.loopField, + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field name + index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNameIndexField, + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + { + name: "invalid field no name or index", + data: map[string]any{ + "test": map[string]any{ + "nested": "val", + }, + }, + field: f.invalidNoNameIndexField, + value: "value", + wantError: true, + wantErrorCode: errorInvalidField, + }, + } + for _, test := range tests { + test := test + f.Run(test.name, func() { + f.T().Parallel() + gotData, gotError := PutField(test.data, test.field, test.value) + if !test.wantError { + f.Require().Nil(gotError) + f.Require().Equal(test.wantData, gotData) + return + } + f.Require().NotNil(gotError) + switch test.wantErrorCode { + // don't validate error code if its not a known code + case errorInvalidData: + f.Require().True(IsInvalidDataError(gotError)) + case errorInvalidField: + f.Require().True(IsInvalidFieldError(gotError)) + } + }) + } +}