Skip to content

Commit

Permalink
types: Introduce ListValueFrom, MapValueFrom, ObjectValueFrom, and Se…
Browse files Browse the repository at this point in the history
…tValueFrom functions (#522)

Reference: #520

These will enable provider developers to use the framework type system's built-in conversion rules to create collection types, rather than using more generic `tfsdk.ValueFrom()` or other methodologies.

In this example, a map using standard Go types is used to create a `types.Map` framework type with known values:

```go
apiMap := map[string]string{
  "key1": "value1",
  "key2": "value2",
}
fwMap, diags := types.MapValueFrom(ctx, types.StringType, apiMap)
```

There may be additional use cases or needs that get teased out with this introduction, such as the ability to create a `types.Object` from a `map[string]any`, however those can be handled in potential future feature requests.
  • Loading branch information
bflad committed Oct 25, 2022
1 parent 4b21cf8 commit de565fa
Show file tree
Hide file tree
Showing 11 changed files with 648 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .changelog/522.txt
@@ -0,0 +1,3 @@
```release-note:enhancement
types: Added `ListValueFrom()`, `MapValueFrom()`, `ObjectValueFrom()`, and `SetValueFrom()` functions, which can create value types from standard Go types using reflection similar to `tfsdk.ValueFrom()`
```
29 changes: 29 additions & 0 deletions types/list.go
Expand Up @@ -230,6 +230,35 @@ func ListValue(elementType attr.Type, elements []attr.Value) (List, diag.Diagnos
}, nil
}

// ListValueFrom creates a List with a known value, using reflection rules.
// The elements must be a slice which can convert into the given element type.
// Access the value via the List type Elements or ElementsAs methods.
func ListValueFrom(ctx context.Context, elementType attr.Type, elements any) (List, diag.Diagnostics) {
attrValue, diags := reflect.FromValue(
ctx,
ListType{ElemType: elementType},
elements,
path.Empty(),
)

if diags.HasError() {
return ListUnknown(elementType), diags
}

list, ok := attrValue.(List)

// This should not happen, but ensure there is an error if it does.
if !ok {
diags.AddError(
"Unable to Convert List Value",
"An unexpected result occurred when creating a List using ListValueFrom. "+
"This is an issue with terraform-plugin-framework and should be reported to the provider developers.",
)
}

return list, diags
}

// ListValueMust creates a List with a known value, converting any diagnostics
// into a panic at runtime. Access the value via the List
// type Elements or ElementsAs methods.
Expand Down
118 changes: 118 additions & 0 deletions types/list_test.go
Expand Up @@ -334,6 +334,124 @@ func TestListValue(t *testing.T) {
}
}

func TestListValueFrom(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
elementType attr.Type
elements any
expected List
expectedDiags diag.Diagnostics
}{
"valid-StringType-[]attr.Value-empty": {
elementType: StringType,
elements: []attr.Value{},
expected: List{
ElemType: StringType,
Elems: []attr.Value{},
},
},
"valid-StringType-[]types.String-empty": {
elementType: StringType,
elements: []String{},
expected: List{
ElemType: StringType,
Elems: []attr.Value{},
},
},
"valid-StringType-[]types.String": {
elementType: StringType,
elements: []String{
StringNull(),
StringUnknown(),
StringValue("test"),
},
expected: List{
ElemType: StringType,
Elems: []attr.Value{
String{Null: true},
String{Unknown: true},
String{Value: "test"},
},
},
},
"valid-StringType-[]*string": {
elementType: StringType,
elements: []*string{
nil,
pointer("test1"),
pointer("test2"),
},
expected: List{
ElemType: StringType,
Elems: []attr.Value{
String{Null: true},
String{Value: "test1"},
String{Value: "test2"},
},
},
},
"valid-StringType-[]string": {
elementType: StringType,
elements: []string{
"test1",
"test2",
},
expected: List{
ElemType: StringType,
Elems: []attr.Value{
String{Value: "test1"},
String{Value: "test2"},
},
},
},
"invalid-not-slice": {
elementType: StringType,
elements: "oops",
expected: ListUnknown(StringType),
expectedDiags: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Empty(),
"List Type Validation Error",
"An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+
"expected List value, received tftypes.Value with value: tftypes.String<\"oops\">",
),
},
},
"invalid-type": {
elementType: StringType,
elements: []bool{true},
expected: ListUnknown(StringType),
expectedDiags: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Empty().AtListIndex(0),
"Value Conversion Error",
"An unexpected error was encountered trying to convert the Terraform value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+
"can't unmarshal tftypes.Bool into *string, expected string",
),
},
},
}

for name, testCase := range testCases {
name, testCase := name, testCase

t.Run(name, func(t *testing.T) {
t.Parallel()

got, diags := ListValueFrom(context.Background(), testCase.elementType, testCase.elements)

if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
}

if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" {
t.Errorf("unexpected diagnostics difference: %s", diff)
}
})
}
}

// This test verifies the assumptions that creating the Value via function then
// setting the fields directly has no effects.
func TestListValue_DeprecatedFieldSetting(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions types/map.go
Expand Up @@ -234,6 +234,36 @@ func MapValue(elementType attr.Type, elements map[string]attr.Value) (Map, diag.
}, nil
}

// MapValueFrom creates a Map with a known value, using reflection rules.
// The elements must be a map of string keys to values which can convert into
// the given element type. Access the value via the Map type Elements or
// ElementsAs methods.
func MapValueFrom(ctx context.Context, elementType attr.Type, elements any) (Map, diag.Diagnostics) {
attrValue, diags := reflect.FromValue(
ctx,
MapType{ElemType: elementType},
elements,
path.Empty(),
)

if diags.HasError() {
return MapUnknown(elementType), diags
}

m, ok := attrValue.(Map)

// This should not happen, but ensure there is an error if it does.
if !ok {
diags.AddError(
"Unable to Convert Map Value",
"An unexpected result occurred when creating a Map using MapValueFrom. "+
"This is an issue with terraform-plugin-framework and should be reported to the provider developers.",
)
}

return m, diags
}

// MapValueMust creates a Map with a known value, converting any diagnostics
// into a panic at runtime. Access the value via the Map
// type Elements or ElementsAs methods.
Expand Down
118 changes: 118 additions & 0 deletions types/map_test.go
Expand Up @@ -313,6 +313,124 @@ func TestMapValue(t *testing.T) {
}
}

func TestMapValueFrom(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
elementType attr.Type
elements any
expected Map
expectedDiags diag.Diagnostics
}{
"valid-StringType-map[string]attr.Value-empty": {
elementType: StringType,
elements: map[string]attr.Value{},
expected: Map{
ElemType: StringType,
Elems: map[string]attr.Value{},
},
},
"valid-StringType-map[string]types.String-empty": {
elementType: StringType,
elements: map[string]String{},
expected: Map{
ElemType: StringType,
Elems: map[string]attr.Value{},
},
},
"valid-StringType-map[string]types.String": {
elementType: StringType,
elements: map[string]String{
"key1": StringNull(),
"key2": StringUnknown(),
"key3": StringValue("test"),
},
expected: Map{
ElemType: StringType,
Elems: map[string]attr.Value{
"key1": String{Null: true},
"key2": String{Unknown: true},
"key3": String{Value: "test"},
},
},
},
"valid-StringType-map[string]*string": {
elementType: StringType,
elements: map[string]*string{
"key1": nil,
"key2": pointer("test1"),
"key3": pointer("test2"),
},
expected: Map{
ElemType: StringType,
Elems: map[string]attr.Value{
"key1": String{Null: true},
"key2": String{Value: "test1"},
"key3": String{Value: "test2"},
},
},
},
"valid-StringType-map[string]string": {
elementType: StringType,
elements: map[string]string{
"key1": "test1",
"key2": "test2",
},
expected: Map{
ElemType: StringType,
Elems: map[string]attr.Value{
"key1": String{Value: "test1"},
"key2": String{Value: "test2"},
},
},
},
"invalid-not-map": {
elementType: StringType,
elements: "oops",
expected: MapUnknown(StringType),
expectedDiags: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Empty(),
"Map Type Validation Error",
"An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+
"expected Map value, received tftypes.Value with value: tftypes.String<\"oops\">",
),
},
},
"invalid-type": {
elementType: StringType,
elements: map[string]bool{"key1": true},
expected: MapUnknown(StringType),
expectedDiags: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Empty().AtMapKey("key1"),
"Value Conversion Error",
"An unexpected error was encountered trying to convert the Terraform value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+
"can't unmarshal tftypes.Bool into *string, expected string",
),
},
},
}

for name, testCase := range testCases {
name, testCase := name, testCase

t.Run(name, func(t *testing.T) {
t.Parallel()

got, diags := MapValueFrom(context.Background(), testCase.elementType, testCase.elements)

if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
}

if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" {
t.Errorf("unexpected diagnostics difference: %s", diff)
}
})
}
}

// This test verifies the assumptions that creating the Value via function then
// setting the fields directly has no effects.
func TestMapValue_DeprecatedFieldSetting(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions types/object.go
Expand Up @@ -239,6 +239,36 @@ func ObjectValue(attributeTypes map[string]attr.Type, attributes map[string]attr
}, nil
}

// ObjectValueFrom creates a Object with a known value, using reflection rules.
// The attributes must be a map of string attribute names to attribute values
// which can convert into the given attribute type or a struct with tfsdk field
// tags. Access the value via the Object type Elements or ElementsAs methods.
func ObjectValueFrom(ctx context.Context, attributeTypes map[string]attr.Type, attributes any) (Object, diag.Diagnostics) {
attrValue, diags := reflect.FromValue(
ctx,
ObjectType{AttrTypes: attributeTypes},
attributes,
path.Empty(),
)

if diags.HasError() {
return ObjectUnknown(attributeTypes), diags
}

m, ok := attrValue.(Object)

// This should not happen, but ensure there is an error if it does.
if !ok {
diags.AddError(
"Unable to Convert Object Value",
"An unexpected result occurred when creating a Object using ObjectValueFrom. "+
"This is an issue with terraform-plugin-framework and should be reported to the provider developers.",
)
}

return m, diags
}

// ObjectValueMust creates a Object with a known value, converting any diagnostics
// into a panic at runtime. Access the value via the Object
// type Elements or ElementsAs methods.
Expand Down

0 comments on commit de565fa

Please sign in to comment.