Skip to content
Draft
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
150 changes: 129 additions & 21 deletions libs/structs/structaccess/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,139 @@ func Set(target any, path *structpath.PathNode, value any) error {
return errors.New("target must be a pointer")
}

// For single-level paths, get the target directly
if pathLen == 1 {
return setValueAtNode(targetVal.Elem(), path, value)
}
return setAt(targetVal.Elem(), path.AsSlice(), path.Parent(), value)
}

// For multi-level paths, get the parent container
parent := path.Parent()
if parent == nil {
return errors.New("failed to get parent path")
// setAt sets value at nodes relative to cur, allocating nil maps, pointers and slices on the way.
// parent is the parent of the final node and is only used in error messages.
func setAt(cur reflect.Value, nodes []*structpath.PathNode, parent *structpath.PathNode, value any) error {
node := nodes[0]
cur, err := allocate(cur, node)
if err != nil {
return err
}
if len(nodes) == 1 {
return setValueAtNode(cur, node, value)
}

// Get the parent container using getValue, passing the original target
parentVal, err := getValue(target, parent)
next, writeBack, err := child(cur, node)
if err != nil {
return fmt.Errorf("failed to navigate to parent %s: %w", parent.String(), err)
}
if err := setAt(next, nodes[1:], parent, value); err != nil {
return err
}
if writeBack != nil {
writeBack()
}
return nil
}

// allocate dereferences cur and makes it ready to hold node: nil pointers are allocated,
// a nil map is made and a nil slice is grown to fit the index.
func allocate(cur reflect.Value, node *structpath.PathNode) (reflect.Value, error) {
for cur.Kind() == reflect.Pointer || cur.Kind() == reflect.Interface {
if cur.IsNil() {
if cur.Kind() == reflect.Interface || !cur.CanSet() {
return cur, nilParentError(node)
}
cur.Set(reflect.New(cur.Type().Elem()))
}
cur = cur.Elem()
}

// Set the value at the final node
return setValueAtNode(parentVal, path, value)
idx, isIndex := node.Index()
if isIndex && cur.Kind() == reflect.Struct {
if embed := findEmbedField(cur); embed.IsValid() {
cur = embed
}
}

var alloc reflect.Value
switch {
case cur.Kind() == reflect.Map && cur.IsNil():
alloc = reflect.MakeMap(cur.Type())
case isIndex && idx >= 0 && cur.Kind() == reflect.Slice && cur.IsNil():
alloc = reflect.MakeSlice(cur.Type(), idx+1, idx+1)
default:
return cur, nil
}
if !cur.CanSet() {
return cur, nilParentError(node)
}
cur.Set(alloc)
return cur, nil
}

// child returns the addressable value selected by node in cur, which allocate has prepared.
// Map values are not addressable, so for maps it returns a copy and a function that stores it back.
func child(cur reflect.Value, node *structpath.PathNode) (reflect.Value, func(), error) {
if idx, isIndex := node.Index(); isIndex {
switch cur.Kind() {
case reflect.Slice, reflect.Array:
if idx < 0 || idx >= cur.Len() {
return reflect.Value{}, nil, &NotFoundError{fmt.Sprintf("%s: index out of range, length is %d", node.String(), cur.Len())}
}
return cur.Index(idx), nil, nil
case reflect.Struct:
// Terraform represents single-block fields as lists and uses [0] to access them.
if idx == 0 {
return cur, nil, nil
}
default:
}
return reflect.Value{}, nil, fmt.Errorf("%s: cannot index %s", node.String(), cur.Kind())
}

if key, value, ok := node.KeyValue(); ok {
if cur.Kind() == reflect.Struct {
if embed := findEmbedField(cur); embed.IsValid() {
cur = embed
}
}
nv, err := accessKeyValue(cur, key, value, node)
return nv, nil, err
}

key, ok := node.StringKey()
if !ok {
return reflect.Value{}, nil, errors.New("unsupported path node type")
}

switch cur.Kind() {
case reflect.Struct:
fv, _, _, ok := findStructFieldByKey(cur, key)
if !ok {
return reflect.Value{}, nil, fmt.Errorf("%s: field %q not found in %s", node.String(), key, cur.Type())
}
return fv, nil, nil
case reflect.Map:
mk, err := mapKey(cur, key)
if err != nil {
return reflect.Value{}, nil, fmt.Errorf("%s: %w", node.String(), err)
}
elem := reflect.New(cur.Type().Elem()).Elem()
if existing := cur.MapIndex(mk); existing.IsValid() {
elem.Set(existing)
}
return elem, func() { cur.SetMapIndex(mk, elem) }, nil
default:
return reflect.Value{}, nil, fmt.Errorf("%s: cannot access key %q on %s", node.String(), key, cur.Kind())
}
}

// mapKey converts key to the key type of the map m, which must be a string type.
func mapKey(m reflect.Value, key string) (reflect.Value, error) {
kt := m.Type().Key()
if kt.Kind() != reflect.String {
return reflect.Value{}, fmt.Errorf("map key must be string, got %s", kt)
}
return reflect.ValueOf(key).Convert(kt), nil
}

// nilParentError reports that the parent of node is nil and cannot be allocated.
func nilParentError(node *structpath.PathNode) error {
return fmt.Errorf("cannot set %s: parent %s is nil", node.String(), node.Parent().String())
}

// SetByString sets the value at the given path string inside the target object.
Expand All @@ -74,7 +188,6 @@ func setValueAtNode(parentVal reflect.Value, node *structpath.PathNode, value an
}
parentVal = parentVal.Elem()
}

valueVal := reflect.ValueOf(value)

if idx, isIndex := node.Index(); isIndex {
Expand Down Expand Up @@ -162,14 +275,9 @@ func setStructField(parentVal reflect.Value, fieldName string, valueVal reflect.

// setMapValue sets a value in a map
func setMapValue(parentVal reflect.Value, key string, valueVal reflect.Value) error {
kt := parentVal.Type().Key()
if kt.Kind() != reflect.String {
return fmt.Errorf("map key must be string, got %s", kt)
}

mk := reflect.ValueOf(key)
if kt != mk.Type() {
mk = mk.Convert(kt)
mk, err := mapKey(parentVal, key)
if err != nil {
return err
}

// For maps, we need to handle the value type
Expand Down
70 changes: 70 additions & 0 deletions libs/structs/structaccess/set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -910,3 +910,73 @@ func TestGetSet_DashIsAFieldNameWhenTheTagHasOptions(t *testing.T) {
assert.Equal(t, "set", target.Named, "Set must write to Named")
assert.Equal(t, "s", target.Skipped, "json:\"-\" field must stay out of reach")
}

func TestSet_AllocatesNilParents(t *testing.T) {
type Leaf struct {
N int `json:"n,omitempty"`
}
type Mid struct {
C *Leaf `json:"c,omitempty"`
LM map[string]Leaf `json:"lm,omitempty"`
}
type T struct {
M map[string]string `json:"m,omitempty"`
P *Leaf `json:"p,omitempty"`
A *Mid `json:"a,omitempty"`
S []string `json:"s,omitempty"`
SL []Leaf `json:"sl,omitempty"`
NM map[string]string `json:"nm"`
NP *Leaf `json:"np"`
MM map[string]map[string]string `json:"mm,omitempty"`
}

tests := []struct {
path string
value any
expected T
}{
{"m.k", "x", T{M: map[string]string{"k": "x"}}},
{"nm.k", "x", T{NM: map[string]string{"k": "x"}}},
{"p.n", 5, T{P: &Leaf{N: 5}}},
{"np.n", 5, T{NP: &Leaf{N: 5}}},
{"a.c.n", 5, T{A: &Mid{C: &Leaf{N: 5}}}},
{"a.lm.k.n", 5, T{A: &Mid{LM: map[string]Leaf{"k": {N: 5}}}}},
{"mm.a.b", "x", T{MM: map[string]map[string]string{"a": {"b": "x"}}}},
{"s[0]", "x", T{S: []string{"x"}}},
{"s[2]", "x", T{S: []string{"", "", "x"}}},
{"sl[0].n", 5, T{SL: []Leaf{{N: 5}}}},
}

for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
target := &T{}
require.NoError(t, structaccess.SetByString(target, tt.path, tt.value))
assert.Equal(t, tt.expected, *target)
})
}
}

func TestSet_NilParentNotAllocatable(t *testing.T) {
type T struct {
I any `json:"i,omitempty"`
}
err := structaccess.SetByString(&T{}, "i.k", "x")
assert.EqualError(t, err, "cannot set i.k: parent i is nil")
}

func TestSet_AllocationWithExistingParents(t *testing.T) {
type T struct {
M map[string]map[string]string `json:"m,omitempty"`
SL []map[string]string `json:"sl,omitempty"`
}
target := &T{M: map[string]map[string]string{"a": {"b": "1"}}, SL: []map[string]string{nil}}
require.NoError(t, structaccess.SetByString(target, "m.a.c", "2"))
require.NoError(t, structaccess.SetByString(target, "sl[0].k", "3"))
assert.Equal(t, T{
M: map[string]map[string]string{"a": {"b": "1", "c": "2"}},
SL: []map[string]string{{"k": "3"}},
}, *target)

err := structaccess.SetByString(target, "sl[1].k", "3")
assert.EqualError(t, err, "failed to navigate to parent sl[1]: sl[1]: index out of range, length is 1") //nolint:dupword
}
Loading