diff --git a/.changelog/350.txt b/.changelog/350.txt new file mode 100644 index 000000000..5101d5169 --- /dev/null +++ b/.changelog/350.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +tfsdk: New function `ValueFrom` that takes a Go value and populates a compatible `attr.Value`, given a descriptive `attr.Type`. +``` diff --git a/tfsdk/value_as.go b/tfsdk/value_as.go index 2b60d0ac1..45ba22c65 100644 --- a/tfsdk/value_as.go +++ b/tfsdk/value_as.go @@ -9,9 +9,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/reflect" ) -// ValueAs populates the Go value passed as `target` with -// the contents of `val`, using the reflection rules -// defined for `Get` and `GetAttribute`. +// ValueAs takes the attr.Value `val` and populates the Go value `target` with its content. +// +// This is achieved using reflection rules provided by the internal/reflect package. func ValueAs(ctx context.Context, val attr.Value, target interface{}) diag.Diagnostics { if reflect.IsGenericAttrValue(ctx, target) { *(target.(*attr.Value)) = val diff --git a/tfsdk/value_from.go b/tfsdk/value_from.go new file mode 100644 index 000000000..f0445ca0d --- /dev/null +++ b/tfsdk/value_from.go @@ -0,0 +1,23 @@ +package tfsdk + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/reflect" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// ValueFrom takes the Go value `val` and populates `target` with an attr.Value, +// based on the type definition provided in `targetType`. +// +// This is achieved using reflection rules provided by the internal/reflect package. +func ValueFrom(ctx context.Context, val interface{}, targetType attr.Type, target interface{}) diag.Diagnostics { + v, diags := reflect.FromValue(ctx, targetType, val, tftypes.NewAttributePath()) + if diags.HasError() { + return diags + } + + return ValueAs(ctx, v, target) +} diff --git a/tfsdk/value_from_test.go b/tfsdk/value_from_test.go new file mode 100644 index 000000000..e52181f12 --- /dev/null +++ b/tfsdk/value_from_test.go @@ -0,0 +1,220 @@ +package tfsdk + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type person struct { + Name types.String `tfsdk:"name"` + Age types.Int64 `tfsdk:"age"` + OptedIn types.Bool `tfsdk:"opted_in"` + Address types.List `tfsdk:"address"` + FullName types.Map `tfsdk:"full_name"` +} + +func TestValueFrom(t *testing.T) { + t.Parallel() + + personAttrTypes := map[string]attr.Type{ + "name": types.StringType, + "age": types.Int64Type, + "opted_in": types.BoolType, + "address": types.ListType{ + ElemType: types.StringType, + }, + "full_name": types.MapType{ + ElemType: types.StringType, + }, + } + + mrX := person{ + Name: types.String{Value: "x"}, + Age: types.Int64{Value: 30}, + OptedIn: types.Bool{Value: true}, + Address: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "1"}, + types.String{Value: "Beckford Close"}, + types.String{Value: "Gotham"}, + }, + }, + FullName: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "first": types.String{Value: "x"}, + "middle": types.String{Value: "b"}, + "last": types.String{Value: "c"}, + }, + }, + } + + mrsY := person{ + Name: types.String{Value: "y"}, + Age: types.Int64{Value: 23}, + OptedIn: types.Bool{Value: false}, + Address: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "2"}, + types.String{Value: "Windmill Close"}, + types.String{Value: "Smallville"}, + }, + }, + FullName: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "first": types.String{Value: "y"}, + "middle": types.String{Value: "e"}, + "last": types.String{Value: "f"}, + }, + }, + } + + expectedMrXObj := types.Object{ + AttrTypes: personAttrTypes, + Attrs: map[string]attr.Value{ + "name": types.String{Value: "x", Unknown: false, Null: false}, + "age": types.Int64{Value: 30, Unknown: false, Null: false}, + "opted_in": types.Bool{Value: true, Unknown: false, Null: false}, + "address": types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "1"}, + types.String{Value: "Beckford Close"}, + types.String{Value: "Gotham"}, + }, + }, + "full_name": types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "first": types.String{Value: "x"}, + "middle": types.String{Value: "b"}, + "last": types.String{Value: "c"}, + }, + }, + }, + } + + expectedMrsYObj := types.Object{ + AttrTypes: personAttrTypes, + Attrs: map[string]attr.Value{ + "name": types.String{Value: "y", Unknown: false, Null: false}, + "age": types.Int64{Value: 23, Unknown: false, Null: false}, + "opted_in": types.Bool{Value: false, Unknown: false, Null: false}, + "address": types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "2"}, + types.String{Value: "Windmill Close"}, + types.String{Value: "Smallville"}, + }, + }, + "full_name": types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "first": types.String{Value: "y"}, + "middle": types.String{Value: "e"}, + "last": types.String{Value: "f"}, + }, + }, + }, + } + + type testCase struct { + val interface{} + target attr.Value + expected attr.Value + expectedDiags diag.Diagnostics + } + + tests := map[string]testCase{ + "primitive": { + val: "hello", + target: types.String{}, + expected: types.String{Value: "hello", Unknown: false, Null: false}, + }, + "struct": { + val: mrX, + target: types.Object{ + AttrTypes: personAttrTypes, + }, + expected: expectedMrXObj, + }, + "list": { + val: []person{mrX, mrsY}, + target: types.List{ + ElemType: types.ObjectType{ + AttrTypes: personAttrTypes, + }, + }, + expected: types.List{ + ElemType: types.ObjectType{ + AttrTypes: personAttrTypes, + }, + Elems: []attr.Value{expectedMrXObj, expectedMrsYObj}, + }, + }, + "map": { + val: map[string]person{ + "x": mrX, + "y": mrsY, + }, + target: types.Map{ + ElemType: types.ObjectType{ + AttrTypes: personAttrTypes, + }, + }, + expected: types.Map{ + ElemType: types.ObjectType{ + AttrTypes: personAttrTypes, + }, + Elems: map[string]attr.Value{ + "x": expectedMrXObj, + "y": expectedMrsYObj, + }, + }, + }, + "incompatible-type": { + val: 0, + target: types.String{}, + expectedDiags: diag.Diagnostics{ + diag.WithPath( + tftypes.NewAttributePath(), + diag.NewErrorDiagnostic( + "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\ncan't unmarshal tftypes.Number into *string, expected string", + ), + ), + }, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := ValueFrom(context.Background(), tc.val, tc.target.Type(context.Background()), &tc.target) + + if diff := cmp.Diff(tc.expectedDiags, diags); diff != "" { + t.Fatalf("Unexpected diff in diagnostics (-wanted, +got): %s", diff) + } + + if diags.HasError() { + return + } + + if diff := cmp.Diff(tc.expected, tc.target); diff != "" { + t.Fatalf("Unexpected diff in results (-wanted, +got): %s", diff) + } + }) + } +}