Skip to content

Commit 4d10c17

Browse files
authored
all: Add support for embedded struct types in object conversions (#1021)
* add embedded struct support via field indices * add support for unexported embedded structs and better panic protection * adjusting error messaging and adding tests * add test for duplicates inside of embedded struct * add exported struct to tests * add ignore tests for entire embedded struct * add a test for multiple levels of embedding * comment * comment * move comment * add documentation for embedded structs * added changelog * add tests for tfsdk tags on embedded structs * refactor to use Tag.Lookup and add tests for empty tags * added note changelog for existing structs
1 parent d6c8b06 commit 4d10c17

File tree

7 files changed

+1282
-28
lines changed

7 files changed

+1282
-28
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: ENHANCEMENTS
2+
body: 'all: Added embedded struct support for object to struct conversions with `tfsdk`
3+
tags'
4+
time: 2024-07-22T17:51:16.833264-04:00
5+
custom:
6+
Issue: "1021"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
kind: NOTES
2+
body: |
3+
Framework reflection logic (`Config.Get`, `Plan.Get`, etc.) for structs with
4+
`tfsdk` field tags has been updated to support embedded structs that promote exported
5+
fields. For existing structs that embed unexported structs with exported fields, a tfsdk
6+
ignore tag (``tfsdk:"-"``) can be added to ignore all promoted fields.
7+
8+
For example, the following struct will now return an error diagnostic:
9+
```go
10+
type thingResourceModel struct {
11+
Attr1 types.String `tfsdk:"attr_1"`
12+
Attr2 types.Bool `tfsdk:"attr_2"`
13+
14+
// Previously, this embedded struct was ignored, will now promote underlying fields
15+
embeddedModel
16+
}
17+
18+
type embeddedModel struct {
19+
// No `tfsdk` tag
20+
ExportedField string
21+
}
22+
```
23+
24+
To preserve the original behavior, a tfsdk ignore tag can be added to ignore the entire embedded struct:
25+
```go
26+
type thingResourceModel struct {
27+
Attr1 types.String `tfsdk:"attr_1"`
28+
Attr2 types.Bool `tfsdk:"attr_2"`
29+
30+
// This embedded struct will now be ignored
31+
embeddedModel `tfsdk:"-"`
32+
}
33+
34+
type embeddedModel struct {
35+
ExportedField string
36+
}
37+
```
38+
time: 2024-08-01T17:16:54.043432-04:00
39+
custom:
40+
Issue: "1021"

internal/reflect/helpers.go

+64-13
Original file line numberDiff line numberDiff line change
@@ -46,36 +46,87 @@ func commaSeparatedString(in []string) string {
4646
}
4747

4848
// getStructTags returns a map of Terraform field names to their position in
49-
// the tags of the struct `in`. `in` must be a struct.
50-
func getStructTags(_ context.Context, in reflect.Value, path path.Path) (map[string]int, error) {
51-
tags := map[string]int{}
52-
typ := trueReflectValue(in).Type()
49+
// the fields of the struct `in`. `in` must be a struct.
50+
//
51+
// The position of the field in a struct is represented as an index sequence to support type embedding
52+
// in structs. This index sequence can be used to retrieve the field with the Go "reflect" package FieldByIndex methods:
53+
// - https://pkg.go.dev/reflect#Type.FieldByIndex
54+
// - https://pkg.go.dev/reflect#Value.FieldByIndex
55+
// - https://pkg.go.dev/reflect#Value.FieldByIndexErr
56+
//
57+
// The following are not supported and will return an error if detected in a struct (including embedded structs):
58+
// - Duplicate "tfsdk" tags
59+
// - Exported fields without a "tfsdk" tag
60+
// - Exported fields with an invalid "tfsdk" tag (must be a valid Terraform identifier)
61+
func getStructTags(ctx context.Context, typ reflect.Type, path path.Path) (map[string][]int, error) { //nolint:unparam // False positive, ctx is used below.
62+
tags := make(map[string][]int, 0)
63+
64+
if typ.Kind() == reflect.Pointer {
65+
typ = typ.Elem()
66+
}
5367
if typ.Kind() != reflect.Struct {
54-
return nil, fmt.Errorf("%s: can't get struct tags of %s, is not a struct", path, in.Type())
68+
return nil, fmt.Errorf("%s: can't get struct tags of %s, is not a struct", path, typ)
5569
}
70+
5671
for i := 0; i < typ.NumField(); i++ {
5772
field := typ.Field(i)
58-
if field.PkgPath != "" {
59-
// skip unexported fields
73+
if !field.IsExported() && !field.Anonymous {
74+
// Skip unexported fields. Unexported embedded structs (anonymous fields) are allowed because they may
75+
// contain exported fields that are promoted; which means they can be read/set.
6076
continue
6177
}
62-
tag := field.Tag.Get(`tfsdk`)
78+
79+
// This index sequence is the location of the field within the struct.
80+
// For promoted fields from an embedded struct, the length of this sequence will be > 1
81+
fieldIndexSequence := []int{i}
82+
tag, tagExists := field.Tag.Lookup(`tfsdk`)
83+
84+
// "tfsdk" tags with "-" are being explicitly excluded
6385
if tag == "-" {
64-
// skip explicitly excluded fields
6586
continue
6687
}
67-
if tag == "" {
88+
89+
// Handle embedded structs
90+
if field.Anonymous {
91+
if tagExists {
92+
return nil, fmt.Errorf(`%s: embedded struct field %s cannot have tfsdk tag`, path.AtName(tag), field.Name)
93+
}
94+
95+
embeddedTags, err := getStructTags(ctx, field.Type, path)
96+
if err != nil {
97+
return nil, fmt.Errorf(`error retrieving embedded struct %q field tags: %w`, field.Name, err)
98+
}
99+
for k, v := range embeddedTags {
100+
if other, ok := tags[k]; ok {
101+
otherField := typ.FieldByIndex(other)
102+
return nil, fmt.Errorf("embedded struct %q promotes a field with a duplicate tfsdk tag %q, conflicts with %q tfsdk tag", field.Name, k, otherField.Name)
103+
}
104+
105+
tags[k] = append(fieldIndexSequence, v...)
106+
}
107+
continue
108+
}
109+
110+
// All non-embedded fields must have a tfsdk tag
111+
if !tagExists {
68112
return nil, fmt.Errorf(`%s: need a struct tag for "tfsdk" on %s`, path, field.Name)
69113
}
114+
115+
// Ensure the tfsdk tag has a valid name
70116
path := path.AtName(tag)
71117
if !isValidFieldName(tag) {
72-
return nil, fmt.Errorf("%s: invalid field name, must only use lowercase letters, underscores, and numbers, and must start with a letter", path)
118+
return nil, fmt.Errorf("%s: invalid tfsdk tag, must only use lowercase letters, underscores, and numbers, and must start with a letter", path)
73119
}
120+
121+
// Ensure there are no duplicate tfsdk tags
74122
if other, ok := tags[tag]; ok {
75-
return nil, fmt.Errorf("%s: can't use field name for both %s and %s", path, typ.Field(other).Name, field.Name)
123+
otherField := typ.FieldByIndex(other)
124+
return nil, fmt.Errorf("%s: can't use tfsdk tag %q for both %s and %s fields", path, tag, otherField.Name, field.Name)
76125
}
77-
tags[tag] = i
126+
127+
tags[tag] = fieldIndexSequence
78128
}
129+
79130
return tags, nil
80131
}
81132

0 commit comments

Comments
 (0)